Когда несколько потоков работают с одними и теми же данными одновременно, результат может зависеть от того, в каком порядке они успеют выполниться. Это называют гонкой — потоки «соревнуются» за данные, и победитель определяется случайно.
Верхний ряд — значение в памяти после каждого шага. Слева направо идёт время. Без синхронизации оба потока читают ноль, и вторая запись кладёт ту же единицу поверх первой. Когда прибавка выполняется целиком, второй поток читает уже обновлённое значение.
Два разных явления под одним словом
В повседневной речи словом «гонка» называют разные вещи, и их важно различать.
Гонка данных (data race) — строго техническое понятие: два потока обращаются к одной переменной в памяти одновременно, хотя бы один из них пишет, и между ними нет никакой синхронизации. Это нарушение правил языка, и результат перестаёт быть предсказуемым: поток может увидеть как новое значение, так и старое, причём разные потоки — разное. Совсем произвольных чисел из воздуха Java почти не допускает: увидеть можно только то, что кто-то когда-то записал. Но и этого достаточно, чтобы счётчик начал врать, а ошибка воспроизводилась раз в неделю под нагрузкой.
Исключение из этого «почти» одно, и оно ровно про гонку данных: запись обычного (не volatile) long или double спецификация разрешает разбить на две 32-битные половины. Соседний поток тогда прочитает половину от одного значения и половину от другого — то есть число, которого не писал никто.
Состояние гонки (race condition) — логическая проблема: правильность результата зависит от порядка или момента выполнения потоков. Состояние гонки может существовать даже при корректной синхронизации — если алгоритм изначально построен так, что одновременные действия дают неверный ответ.
Короткая формула: data race — это всегда ошибка по спецификации; race condition — это ошибка в логике.
Почему i++ — это не одна операция
Возьмём самый распространённый пример — общий счётчик. Два потока по сто тысяч раз прибавляют единицу к одному полю; ждём двести тысяч.
живой пример
public class RaceDemo {
private static int value = 0;
public static void main(String[] args) throws InterruptedException {
Runnable task = () -> {
for (int i = 0; i < 100_000; i++) {
value++;
}
};
Thread a = new Thread(task);
Thread b = new Thread(task);
a.start();
b.start();
a.join();
b.join();
System.out.println("две задачи по 100000: ожидали 200000, получили " + value);
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Запустите несколько раз — число будет разным и почти всегда меньше ожидаемого. Запись value++ скрывает три шага:
- Прочитать текущее значение
valueиз памяти в регистр. - Прибавить 1 к значению в регистре.
- Записать результат обратно в память.
Два потока, работающие одновременно, могут выполнить эти три шага в перемешку:
| Шаг | Поток A | Поток B | value в памяти |
|---|---|---|---|
| 1 | читает value = 0 | — | 0 |
| 2 | — | читает value = 0 | 0 |
| 3 | считает 0 + 1 = 1 | — | 0 |
| 4 | — | считает 0 + 1 = 1 | 0 |
| 5 | записывает 1 | — | 1 |
| 6 | — | записывает 1 | 1 |
Каждый поток честно выполнил свою прибавку, но на шаге 6 вторая запись положила ту же единицу поверх первой — счётчик вырос на 1 вместо 2. Операция, которая выглядит атомарной, на самом деле не атомарна: между её шагами другой поток может вклиниться.
Это одновременно и data race (нет синхронизации), и race condition (результат неверный).
Совпадают они не всегда, и обратный случай стоит увидеть: гонка без гонки данных. Возьмите потокобезопасную карту и напишите на ней проверку остатка:
ConcurrentHashMap<String, Integer> stock = new ConcurrentHashMap<>();
if (stock.get("item") > 0) { // атомарная операция раз
stock.merge("item", -1, Integer::sum); // атомарная операция два
}
Каждая строка по отдельности потокобезопасна, гонки данных нет вовсе: карта сама расставила синхронизацию. А логическая ошибка есть: два потока могут одновременно увидеть остаток 1, и оба спишут — остаток уйдёт в минус. Атомарность частей не даёт атомарности целого, и это самый частый вид гонки в коде, где «мы же взяли конкурентную коллекцию». Лечится тем же приёмом, что и check-then-act выше: сделать проверку и действие одним вызовом, stock.computeIfPresent("item", (k, v) -> v > 0 ? v - 1 : v).
Классические паттерны гонок
Большинство гонок укладываются в два типичных шаблона.
Check-then-act
Поток проверяет условие и выполняет действие, рассчитывая, что условие не изменится между двумя шагами.
// Кажется безопасным — но нет
if (!map.containsKey(key)) {
map.put(key, computeValue(key)); // другой поток мог уже вставить
}
Между containsKey и put другой поток успевает вставить своё значение. Итог: computeValue считает одно и то же дважды, а второй put затирает результат первого — и дальше работают с тем значением, которое просто пришло последним.
Чинится это не замком, а тем, что проверка и действие становятся одним вызовом. У потокобезопасных карт такие вызовы уже есть:
map.putIfAbsent(key, value); // положить, только если ключа нет
map.computeIfAbsent(key, k -> compute(k)); // и посчитать значение ровно один раз
map.merge(key, 1, Integer::sum); // прибавить к текущему
map.replace(key, expected, updated); // заменить, только если там ожидаемое
Все четыре атомарны для своего ключа: вклиниться между проверкой и записью некуда. Разница между первыми двумя важнее, чем кажется: putIfAbsent(key, compute(k)) всё равно вычислит значение, даже если ключ уже есть, а computeIfAbsent вызовет функцию только при отсутствии ключа. Подробности и ограничения этих методов — в статье про потокобезопасные коллекции.
Read-modify-write
Поток читает значение, изменяет его и записывает. Проблема та же, что и с i++: между чтением и записью вклинивается другой поток.
// Не атомарно без синхронизации
long current = balance;
balance = current - amount; // другой поток тоже прочитал старый balance
Два списания, начавшиеся с одного и того же balance, дадут остаток так, будто списание было одно.
Тот же шаблон — и с той же ошибкой — живёт этажом выше, в работе с базой. «Прочитали остаток запросом, посчитали новый в Java, записали UPDATE» это ровно read-modify-write, только между чтением и записью проходят не наносекунды, а миллисекунды сетевого обмена, и вклиниться успевает не соседний поток, а соседний экземпляр сервиса. Замки в приложении здесь бесполезны: процессов несколько. Решения те же по смыслу, но средствами базы: посчитать прямо в запросе (UPDATE accounts SET balance = balance - ?), взять строку под блокировку (SELECT ... FOR UPDATE) или добавить версию и проверять её при записи — оптимистическая блокировка из статьи про Spring Data JPA. Узнавать этот шаблон полезно в обоих мирах: он один и тот же.
Три свойства, которые надо держать под контролем
Чтобы разобраться, почему гонки вообще возникают, нужно понять три независимых свойства корректного многопоточного кода.
Атомарность — операция выполняется как единое неделимое целое. Никакой другой поток не видит промежуточное состояние. i++ не атомарна; AtomicInteger.incrementAndGet() — атомарна.
Видимость — изменение, сделанное одним потоком, становится видно другим потокам. Без специальных механизмов JVM разрешает держать значение переменной в регистре процессора: поток A обновил переменную в памяти, а поток B сверяется не с памятью, а со своей копией в регистре — и старое значение остаётся у него сколь угодно долго.
Упорядочивание — инструкции выполняются в предсказуемом порядке. Компилятор и процессор переставляют инструкции для оптимизации — так, чтобы однопоточная программа работала правильно. Но в многопоточном коде перестановки могут нарушить предположения другого потока. Классический случай: поток пишет data = 42; ready = true;, а другой ждёт ready и читает data. Без синхронизации записи могут поменяться местами, и второй поток увидит ready == true при data == 0.
Эти три свойства регулируются моделью памяти Java (Java Memory Model) через отношение happens-before: если операция A happens-before операции B, то B гарантированно видит все изменения от A. Подробнее об этом — в статье про модель памяти.
Как распознать гонку
Гонки коварны именно потому, что не воспроизводятся стабильно. Несколько признаков, на которые стоит обратить внимание:
- Программа работает правильно при тестировании, но изредка даёт неверный результат на нагрузке.
- Поведение меняется в зависимости от числа процессорных ядер или скорости машины.
- Добавление
System.out.println«лечит» проблему: вывод вPrintStreamсинхронизирован, и эта синхронизация заодно маскирует гонку. С логгером через асинхронный аппендер фокус может и не сработать — там на пути задачи синхронизации почти нет. - Счётчики или агрегаты расходятся при параллельном запуске и совпадают при последовательном.
- Тест проходит с одним потоком, но падает с несколькими.
Последний пункт — первый шаг при отладке. Возьмите пример со счётчиком выше и уберите из него поток b: с одним потоком итог всегда ровно 100000, с двумя почти всегда меньше 200000. Итог, который зависит от числа потоков, — почти наверняка гонка.
Что с этим делать
Правильный ответ зависит от природы данных и частоты обращений.
synchronized— самый простой способ гарантировать атомарность и видимость для блока кода. О деталях — в статье про synchronized.AtomicIntegerи другие классы изjava.util.concurrent.atomic— если нужна только атомарность одной переменной без блокировки. Подходит для счётчиков и флагов. Подробнее — в статье про атомики.volatile— решает только проблему видимости, не атомарность. Достаточно для флага «работаем/стоп», недостаточно для счётчика.- Неизменяемые объекты — если объект нельзя изменить после создания, гонок нет по определению: нечего делить.
Тот же счётчик, у которого чтение, прибавка и запись стали одним неделимым шагом:
живой пример
import java.util.concurrent.atomic.AtomicInteger;
public class AtomicDemo {
private static final AtomicInteger value = new AtomicInteger();
public static void main(String[] args) throws InterruptedException {
Runnable task = () -> {
for (int i = 0; i < 100_000; i++) {
value.incrementAndGet();
}
};
Thread a = new Thread(task);
Thread b = new Thread(task);
a.start();
b.start();
a.join();
b.join();
System.out.println("тот же расчёт атомарной операцией: " + value.get());
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Итог — ровно 200000 при каждом запуске: вклиниться между шагами incrementAndGet уже некуда.
Хорошее правило проектирования: минимизируйте разделяемое изменяемое состояние. Чем меньше данных доступно нескольким потокам одновременно, тем меньше мест, где может возникнуть гонка.
Глубже: не защищать, а не делить: неизменяемость и владение потокомрасширенное
Всё, что выше, про защиту общего изменяемого состояния. Самый дешёвый способ не иметь гонок обратный: не иметь общего изменяемого состояния. Гонка требует двух условий сразу, «общее» и «изменяемое», и достаточно убрать одно.
Убрать «изменяемое». Объект, поля которого не меняются после конструктора, безопасен для любого числа потоков без замков: читать неизменяемое нельзя «посреди изменения». record делает это по умолчанию, у обычного класса поля final, а коллекции внутри защищают копированием: List.copyOf(items) в конструкторе, чтобы тот, кто передал список, не мог изменить его позже. Изменение состояния становится созданием нового объекта, order.withStatus(PAID), и подменой ссылки, а подмена одной ссылки атомарна.
Убрать «общее». Локальная переменная метода принадлежит своему потоку, её никто не видит. Объект, который создал один поток и никому не отдал, тоже. Отсюда приём владения: данные принадлежат одному потоку, другие получают копию на границе или сообщение через очередь. StringBuilder в локальной переменной не нуждается в StringBuffer; парсер, созданный на запрос, не нуждается в синхронизации, в отличие от парсера в поле синглтона.
Проверка дизайна на гонки тогда сводится к двум вопросам про каждое поле: меняется ли оно после создания и виден ли объект больше чем одному потоку. Если хоть на один ответ «нет», защита не нужна. Замки и атомики остаются для того, что действительно и общее, и изменяемое: счётчики, кэши, очереди.
Глубже: как гонку ловят: прогон в N потоков, jcstress, Awaitilityрасширенное
Гонка не воспроизводится по требованию: тест проходит сто раз и падает на сто первый. Поэтому её ловят не одним прогоном, а условиями, в которых она вероятна.
Самый простой инструмент, стресс-тест руками: запустить код из N потоков одновременно, стартовав их по латчу, и сравнить итог с ожидаемым.
CountDownLatch start = new CountDownLatch(1);
ExecutorService pool = Executors.newFixedThreadPool(16);
for (int i = 0; i < 16; i++) {
pool.submit(() -> { start.await(); for (int k = 0; k < 10_000; k++) counter.increment(); return null; });
}
start.countDown();
pool.shutdown();
pool.awaitTermination(10, TimeUnit.SECONDS);
assertEquals(160_000, counter.get());
Одновременный старт важен: без латча потоки стартуют вразнобой и почти не пересекаются. Такой тест держат в CI и гоняют многократно, @RepeatedTest(50), потому что одно совпадение ничего не доказывает. Для проверки самих примитивов есть jcstress от разработчиков JDK: он миллионы раз запускает крохотные сценарии и печатает таблицу наблюдавшихся исходов, включая те, что «не должны» случаться по модели памяти. Детектора гонок уровня ThreadSanitizer в Java нет, ближайшее это анализаторы вроде Error Prone с проверками @GuardedBy.
И отдельно про тесты асинхронного кода: Thread.sleep(1000) в тесте это гонка на стороне теста, он то ждёт слишком мало и падает, то слишком долго. Ждать нужно условие, а не время: Awaitility опрашивает утверждение до срока, await().atMost(5, SECONDS).untilAsserted(() -> assertEquals(PAID, repo.status(id))), и падает, только если условие не наступило.
Коротко
- Гонка данных (data race) — одновременный доступ к переменной без синхронизации; нарушение спецификации языка.
- Состояние гонки (race condition) — логическая ошибка, когда правильность зависит от порядка выполнения потоков.
i++— не атомарная операция: чтение, изменение, запись — три шага, между которыми может вклиниться другой поток.- Три свойства, необходимых для корректности: атомарность, видимость, упорядочивание.
- Гонки не воспроизводятся стабильно; типичный признак — расхождение результатов при разном числе потоков.
- Основные инструменты защиты:
synchronized,AtomicInteger,volatile, неизменяемые объекты. - Гонка требует «общее» и «изменяемое» сразу: неизменяемый объект (
record,final,List.copyOf) или владение одним потоком снимают защиту вовсе. - Гонку ловят стресс-тестом с одновременным стартом по латчу и повторами, примитивы проверяют jcstress, асинхронное ждут Awaitility, а не
sleep. - Check-then-act чинят не замком, а одним вызовом:
putIfAbsent,computeIfAbsent,merge,replace(k, ожидаемое, новое). Тот же шаблон через базу лечится расчётом в запросе,SELECT ... FOR UPDATEили версией строки. - Гонка бывает и без гонки данных: две атомарные операции подряд над потокобезопасной коллекцией дают неверный итог.
Что почитать дальше
- Модель памяти Java — как JVM управляет видимостью и упорядочиванием через happens-before.
- synchronized: мониторы и взаимное исключение — самый прямолинейный способ устранить гонку данных.
- Атомарные переменные — счётчики и флаги без блокировок через CAS.
- Потокобезопасные коллекции — как
computeиmergeубирают check-then-act из работы с картой.