Синхронизация
Когда несколько потоков меняют одни и те же данные, результат может оказаться неверным. Такую ситуацию называют гонкой данных (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 в примерах выше.