Когда несколько потоков работают с одними и теми же данными одновременно, результат может зависеть от того, в каком порядке они успеют выполниться. Это называют гонкой — потоки «соревнуются» за данные, и победитель определяется случайно.
Два разных явления под одним словом
В повседневной речи словом «гонка» называют разные вещи, и их важно различать.
Гонка данных (data race) — строго техническое понятие: два потока обращаются к одной переменной в памяти одновременно, хотя бы один из них пишет, и между ними нет никакой синхронизации. Это нарушение правил языка: Java Memory Model гарантирует непредсказуемое поведение вплоть до «программа делает что угодно».
Состояние гонки (race condition) — логическая проблема: правильность результата зависит от порядка или момента выполнения потоков. Состояние гонки может существовать даже при корректной синхронизации — если алгоритм изначально построен так, что одновременные действия дают неверный ответ.
Короткая формула: data race — это всегда ошибка по спецификации; race condition — это ошибка в логике.
Почему i++ — это не одна операция
Возьмём самый распространённый пример — общий счётчик.
class Counter {
private int value = 0;
public void increment() {
value++; // кажется, одна операция — на самом деле три
}
public int get() {
return value;
}
}
Запись value++ скрывает три шага:
- Прочитать текущее значение
valueиз памяти в регистр. - Прибавить 1 к значению в регистре.
- Записать результат обратно в память.
Два потока, работающие одновременно, могут выполнить эти три шага в перемешку:
Поток A: читает value = 0
Поток B: читает value = 0 ← оба видят 0
Поток A: считает 0 + 1 = 1
Поток B: считает 0 + 1 = 1
Поток A: записывает 1
Поток B: записывает 1 ← 1 вместо ожидаемых 2
Оба потока добавили по единице, но счётчик вырос только на 1. Операция, которая выглядит атомарной, на самом деле не атомарна: между её шагами другой поток может вклиниться.
Это одновременно и data race (нет синхронизации), и race condition (результат неверный).
Классические паттерны гонок
Большинство гонок укладываются в два типичных шаблона.
Check-then-act
Поток проверяет условие и выполняет действие, рассчитывая, что условие не изменится между двумя шагами.
// Кажется безопасным — но нет
if (!map.containsKey(key)) {
map.put(key, computeValue(key)); // другой поток мог уже вставить
}
Между containsKey и put другой поток успевает вставить то же значение. Итог: два одинаковых ключа попадают в очередь обработки, вычисление дублируется или данные затираются.
Read-modify-write
Поток читает значение, изменяет его и записывает. Проблема та же, что и с i++: между чтением и записью вклинивается другой поток.
// Не атомарно без синхронизации
long current = timestamp;
timestamp = System.currentTimeMillis(); // другой поток тоже мог прочитать old value
Три свойства, которые надо держать под контролем
Чтобы разобраться, почему гонки вообще возникают, нужно понять три независимых свойства корректного многопоточного кода.
Атомарность — операция выполняется как единое неделимое целое. Никакой другой поток не видит промежуточное состояние. i++ не атомарна; AtomicInteger.incrementAndGet() — атомарна.
Видимость — изменение, сделанное одним потоком, становится видно другим потокам. Без специальных механизмов JVM разрешает кэшировать значения переменных в регистрах процессора: поток A обновил переменную, а поток B ещё долго видит старое значение из своего кэша.
Упорядочивание — инструкции выполняются в предсказуемом порядке. Компилятор и процессор переставляют инструкции для оптимизации — так, чтобы однопоточная программа работала правильно. Но в многопоточном коде перестановки могут нарушить предположения другого потока.
Эти три свойства регулируются моделью памяти Java (Java Memory Model) через отношение happens-before: если операция A happens-before операции B, то B гарантированно видит все изменения от A. Подробнее об этом — в статье про модель памяти.
Как распознать гонку
Гонки коварны именно потому, что не воспроизводятся стабильно. Несколько признаков, на которые стоит обратить внимание:
- Программа работает правильно при тестировании, но изредка даёт неверный результат на нагрузке.
- Поведение меняется в зависимости от числа процессорных ядер или скорости машины.
- Добавление отладочного вывода (
System.out.println) «лечит» проблему — логирование добавляет синхронизацию, которая маскирует гонку. - Счётчики или агрегаты расходятся при параллельном запуске и совпадают при последовательном.
- Тест проходит с одним потоком, но падает с несколькими.
Последний пункт — первый шаг при отладке: запустите подозрительный код с numberOfThreads = 1 и сравните с numberOfThreads = N. Расхождение почти наверняка укажет на гонку.
// Скаффолд для воспроизведения: запускаем N потоков и проверяем итог
ExecutorService pool = Executors.newFixedThreadPool(4);
Counter counter = new Counter();
List<Future<?>> futures = new ArrayList<>();
for (int i = 0; i < 1000; i++) {
futures.add(pool.submit(counter::increment));
}
for (Future<?> f : futures) f.get(); // дожидаемся всех
pool.shutdown();
System.out.println("Ожидается: 1000, получено: " + counter.get());
// Без синхронизации значение почти всегда меньше 1000
Что с этим делать
Правильный ответ зависит от природы данных и частоты обращений.
synchronized— самый простой способ гарантировать атомарность и видимость для блока кода. О деталях — в статье про synchronized.AtomicIntegerи другие классы изjava.util.concurrent.atomic— если нужна только атомарность одной переменной без блокировки. Подходит для счётчиков и флагов. Подробнее — в статье про атомики.volatile— решает только проблему видимости, не атомарность. Достаточно для флага «работаем/стоп», недостаточно для счётчика.- Неизменяемые объекты — если объект нельзя изменить после создания, гонок нет по определению: нечего делить.
Хорошее правило проектирования: минимизируйте разделяемое изменяемое состояние. Чем меньше данных доступно нескольким потокам одновременно, тем меньше мест, где может возникнуть гонка.
Коротко
- Гонка данных (data race) — одновременный доступ к переменной без синхронизации; нарушение спецификации языка.
- Состояние гонки (race condition) — логическая ошибка, когда правильность зависит от порядка выполнения потоков.
i++— не атомарная операция: чтение, изменение, запись — три шага, между которыми может вклиниться другой поток.- Три свойства, необходимых для корректности: атомарность, видимость, упорядочивание.
- Гонки не воспроизводятся стабильно; типичный признак — расхождение результатов при разном числе потоков.
- Основные инструменты защиты:
synchronized,AtomicInteger,volatile, неизменяемые объекты.
Что почитать дальше
- Модель памяти Java — как JVM управляет видимостью и упорядочиванием через happens-before.
- synchronized: мониторы и взаимное исключение — самый прямолинейный способ устранить гонку данных.
- Атомарные переменные — счётчики и флаги без блокировок через CAS.