Encodera

Спасибо, что читаете!

Пожертвуйте сайту, мы стараемся ради вас.

Оглавление

Синхронизация

Когда несколько потоков меняют одни и те же данные, результат может оказаться неверным. Такую ситуацию называют гонкой данных (race condition): итог зависит от того, какой поток успеет первым. Синхронизация — набор способов сделать так, чтобы потоки не мешали друг другу.

Гонка данных

Два потока по 10 000 раз увеличивают общий счётчик. Ожидаем 20 000:

int count = 0;
 
void increment() {
    int current = count;  // прочитать
    Thread.yield();       // уступить процессор другому потоку
    count = current + 1;  // прибавить и записать
}
 
void main() throws InterruptedException {
    Runnable task = () -> {
        for (int i = 0; i < 10_000; i++) {
            increment();
        }
    };
 
    Thread a = new Thread(task);
    Thread b = new Thread(task);
    a.start();
    b.start();
    a.join();
    b.join();
 
    IO.println(count);
}

Результат (у вас число будет другим и меняется от запуска к запуску):

17896

Даже короткая запись count++ состоит из тех же шагов: прочитать значение, прибавить единицу, записать обратно. Если оба потока прочитали одно и то же число, каждый запишет «своё +1», и одно увеличение потеряется. Здесь шаги расписаны явно, а Thread.yield() подсказывает системе переключиться на другой поток — так потеря видна почти при каждом запуске. С обычным count++ происходит то же самое, только реже, и поэтому такую ошибку трудно поймать.

synchronized

Слово synchronized у метода разрешает выполнять его только одному потоку за раз. Остальные ждут, пока первый выйдет из метода:

int count = 0;
 
synchronized void increment() {
    int current = count;
    Thread.yield();
    count = current + 1;
}
 
void main() throws InterruptedException {
    Runnable task = () -> {
        for (int i = 0; i < 10_000; i++) {
            increment();
        }
    };
 
    Thread a = new Thread(task);
    Thread b = new Thread(task);
    a.start();
    b.start();
    a.join();
    b.join();
 
    IO.println(count);
}

Результат:

20000

Каждый объект в Java имеет монитор — встроенный замок. synchronized-метод захватывает замок своего объекта (this), выполняется и отпускает его.

Блок synchronized

Иногда защищать нужно не весь метод, а несколько строк. Тогда пишут блок synchronized и указывают, чей замок захватить. Важно, чтобы все потоки использовали один и тот же объект:

final Object lock = new Object();
List<String> log = new ArrayList<>();
 
void main() throws InterruptedException {
    List<Thread> threads = new ArrayList<>();
    for (String name : List.of("Анна", "Олег", "Ира")) {
        threads.add(Thread.ofPlatform().start(() -> {
            synchronized (lock) {
                log.add(name);
            }
        }));
    }
    for (Thread t : threads) {
        t.join();
    }
 
    IO.println(log.size() + " записи");
}

Результат:

3 записи

ArrayList не рассчитан на одновременную запись из нескольких потоков, но под общим замком потоки добавляют элементы по одному. Порядок имён в списке при этом может быть любым, поэтому выводим только размер.

Атомарные классы

Для простых счётчиков удобнее атомарные классы из java.util.concurrent.atomic. Их операции выполняются целиком, без промежуточных состояний, и без явных замков:

void main() throws InterruptedException {
    AtomicInteger count = new AtomicInteger();
 
    Runnable task = () -> {
        for (int i = 0; i < 10_000; i++) {
            count.incrementAndGet();
        }
    };
 
    Thread a = new Thread(task);
    Thread b = new Thread(task);
    a.start();
    b.start();
    a.join();
    b.join();
 
    IO.println(count.get());
}

Результат:

20000

Кроме incrementAndGet есть addAndGet, compareAndSet и другие. Для чисел long — класс AtomicLong.

ReentrantLock

ReentrantLock — замок в виде отдельного объекта. Он умеет больше, чем synchronized: например, tryLock() пытается взять замок, не дожидаясь его. Отпускать замок нужно обязательно в finally, иначе после исключения он останется занятым навсегда:

final ReentrantLock lock = new ReentrantLock();
int balance = 100;
 
void withdraw(int amount) {
    lock.lock();
    try {
        if (balance >= amount) {
            balance -= amount;
        }
    } finally {
        lock.unlock();
    }
}
 
void main() throws InterruptedException {
    Thread a = new Thread(() -> withdraw(70));
    Thread b = new Thread(() -> withdraw(70));
    a.start();
    b.start();
    a.join();
    b.join();
 
    IO.println("Остаток: $" + balance);
}

Результат:

Остаток: $30

Без замка оба потока могли бы одновременно увидеть баланс 100 и оба снять деньги. С замком проверка и списание выполняются вместе, и второе снятие не проходит.

Частая ошибка

Замок защищает данные, только если все потоки берут один и тот же замок. Здесь каждый поток создаёт свой объект, и синхронизация ничего не даёт:

int count = 0;
 
void main() throws InterruptedException {
    Runnable task = () -> {
        Object lock = new Object();
        for (int i = 0; i < 10_000; i++) {
            synchronized (lock) {
                int current = count;
                Thread.yield();
                count = current + 1;
            }
        }
    };
 
    Thread a = new Thread(task);
    Thread b = new Thread(task);
    a.start();
    b.start();
    a.join();
    b.join();
 
    IO.println(count);
}

Результат (число меняется от запуска к запуску):

10140

Объявляйте объект замка полем, общим для всех потоков, — как lock в примерах выше.

Спасибо, что читаете!

Пожертвуйте сайту, мы стараемся ради вас.