Race condition на живом примере
Вопрос-крючок: «Вот код, который считает от двух потоков. Почему результат не всегда 2000000?»
Race condition (состояние гонки) — ситуация, когда результат работы программы зависит от того, в каком порядке и как именно чередуются операции разных потоков, обращающихся к общим данным.
Это, пожалуй, самый практический вопрос из всей темы многопоточности — интервьюер часто показывает код и просит объяснить, почему он ведёт себя непредсказуемо. Давайте разберём его пошагово, как на настоящем собеседовании.
Код-ловушка
Два потока по миллиону раз увеличивают общую переменную. Казалось бы, в конце должно получиться ровно два миллиона.
class Counter {
private int count = 0;
public void increment() {
count++;
}
public int getCount() {
return count;
}
}
public class Main {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Runnable task = () -> {
for (int i = 0; i < 1_000_000; i++) {
counter.increment();
}
};
Thread t1 = new Thread(task);
Thread t2 = new Thread(task);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("Итог: " + counter.getCount());
}
}Вывод (примерный, будет меняться от запуска к запуску):
Итог: 1483219Вместо ожидаемых 2 000 000 получается какое-то другое число, каждый раз разное и почти всегда меньше двух миллионов. Это и есть race condition в чистом виде.
Почему так происходит
Всё дело в том, что count++ — это не одна операция для процессора, а три последовательных шага:
- Прочитать текущее значение
countиз памяти. - Прибавить к нему единицу.
- Записать новое значение обратно в память.
Представим, что count равен 5, и оба потока почти одновременно вызывают increment():
| Момент времени | Поток 1 | Поток 2 | Значение count |
| t1 | читает count = 5 | — | 5 |
| t2 | — | читает count = 5 | 5 |
| t3 | прибавляет 1, получает 6 | прибавляет 1, получает 6 | 5 |
| t4 | записывает count = 6 | — | 6 |
| t5 | — | записывает count = 6 | 6 |
Оба потока честно выполнили свой инкремент, но оба стартовали от одного и того же прочитанного значения 5, поэтому вместо ожидаемых двух прибавлений (5 → 6 → 7) получилось одно (5 → 6). Один инкремент буквально «потерялся». Умножьте это на миллионы итераций — и вы получите непредсказуемый недостачу именно того масштаба, что видно в выводе выше.
Способ 1: synchronized
Самое прямое решение — обернуть операцию в блокировку, чтобы гарантировать, что весь цикл «прочитать-изменить-записать» выполняется одним потоком без вмешательства другого.
class Counter {
private int count = 0;
public synchronized void increment() {
count++;
}
public synchronized int getCount() {
return count;
}
}Вывод:
Итог: 2000000Теперь, пока один поток внутри increment(), второй ждёт снаружи, и потерянных обновлений быть не может. Цена — потоки конкурируют за блокировку, что немного замедляет работу при высокой нагрузке.
Способ 2: AtomicInteger
Для случая «просто безопасно посчитать число» в Java есть специализированный класс AtomicInteger из пакета java.util.concurrent.atomic. Он выполняет операцию инкремента как единое неделимое (atomic) действие, без явной блокировки.
import java.util.concurrent.atomic.AtomicInteger;
class Counter {
private final AtomicInteger count = new AtomicInteger(0);
public void increment() {
count.incrementAndGet();
}
public int getCount() {
return count.get();
}
}Вывод:
Итог: 2000000Под капотом AtomicInteger использует аппаратную инструкцию процессора compare-and-swap: она атомарно сравнивает текущее значение с ожидаемым и, если оно не изменилось, записывает новое — и всё это одной неделимой операцией на уровне процессора, без блокировки потоков. Для простых счётчиков это обычно быстрее, чем synchronized.
Способ 3: явная блокировка Lock
Более гибкий инструмент — ReentrantLock из java.util.concurrent.locks. Он делает примерно то же, что synchronized, но с блокировкой можно работать «руками»: например, попытаться захватить её с таймаутом, вместо того чтобы ждать бесконечно.
import java.util.concurrent.locks.ReentrantLock;
class Counter {
private int count = 0;
private final ReentrantLock lock = new ReentrantLock();
public void increment() {
lock.lock();
try {
count++;
} finally {
lock.unlock();
}
}
}Обратите внимание на try/finally: если внутри защищённого кода вылетит исключение, блокировка всё равно обязана освободиться — иначе другие потоки зависнут навсегда в ожидании.
Частые ошибки на собеседовании
- Утверждают, что
count++— атомарная операция, потому что «выглядит как одна строчка кода». На деле это чтение + прибавление + запись, три отдельных шага. - Предлагают пометить поле
countкакvolatileи считают проблему решённой — как мы разобрали в прошлом уроке, volatile не спасает составные операции вроде инкремента. - Не могут объяснить разницу между
synchronizedиAtomicInteger— оба решают задачу, но AtomicInteger обычно быстрее для простых счётчиков именно потому, что не использует блокировку потоков. - Забывают вызвать
unlock()в блокеfinallyпри работе сReentrantLock— это частая причина «зависшей» программы в реальном коде.
Шпаргалка
- Race condition возникает, когда несколько потоков без защиты одновременно читают и изменяют общие данные.
count++— это три отдельных шага (прочитать, прибавить, записать), а не одна атомарная операция.- Чинится через
synchronized(блокировка блока/метода),AtomicInteger(атомарные операции без блокировки) илиReentrantLock(гибкая явная блокировка). - volatile здесь не помощник — он решает другую задачу (видимость), а не взаимное исключение.