← назад к разделу

Когда несколько потоков работают с одними и теми же данными одновременно, результат может зависеть от того, в каком порядке они успеют выполниться. Это называют гонкой — потоки «соревнуются» за данные, и победитель определяется случайно.

value поток A поток B без синхронизациичитает 00 читает 00 0 + 1 = 10 0 + 1 = 10 пишет 11 пишет 11 две прибавки, а value = 1: вторая запись затёрла первую прибавка целиком, без пересеченийчитает 00 0 + 1 = 10 пишет 11 ждёт своей очереди читает 11 1 + 1 = 21 пишет 22 две прибавки, value = 2: обе на месте

Верхний ряд — значение в памяти после каждого шага. Слева направо идёт время. Без синхронизации оба потока читают ноль, и вторая запись кладёт ту же единицу поверх первой. Когда прибавка выполняется целиком, второй поток читает уже обновлённое значение.

Обязательно

Два разных явления под одним словом

В повседневной речи словом «гонка» называют разные вещи, и их важно различать.

Гонка данных (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++ скрывает три шага:

  1. Прочитать текущее значение value из памяти в регистр.
  2. Прибавить 1 к значению в регистре.
  3. Записать результат обратно в память.

Два потока, работающие одновременно, могут выполнить эти три шага в перемешку:

ШагПоток AПоток Bvalue в памяти
1читает value = 0—0
2—читает value = 00
3считает 0 + 1 = 1—0
4—считает 0 + 1 = 10
5записывает 1—1
6—записывает 11

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

Что почитать дальше