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

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

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

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

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

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

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

Поток 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.