Что такое race condition
В отличие от Deadlock, когда приложение полностью замирает (0% CPU), при Race Condition программа продолжает внешне успешно работать, но производит ошибочные расчёты или разруша...
🟢 Junior Level
Краткий ответ для собеседования (30 секунд)
Race Condition (состояние гонки) — это архитектурная ошибка многопоточного приложения, при которой корректность работы программы, согласованность данных или результат вычислений зависят от случайного, недетерминированного порядка чередования (interleaving) и скорости исполнения инструкций параллельными потоками.
В отличие от Deadlock, когда приложение полностью замирает (0% CPU), при Race Condition программа продолжает внешне успешно работать, но производит ошибочные расчёты или разрушает внутреннее состояние данных (Silent Data Corruption).
Классические проявления Race Condition делятся на три категории:
- Read-Modify-Write: чтение, изменение и запись разделяемой переменной (например,
count++). - Check-Then-Act: проверка условия и действие на основе устаревшего результата проверки (
if (condition) act();). - Unsafe Publication: использование объекта до того, как его конструктор полностью завершил публикацию полей.
Почему count++ не потокобезопасен
Обычная операция инкремента count++ кажется атомарной в синтаксисе Java, но на уровне байткода и ассемблера состоит из трёх раздельных операций:
read: прочитать текущее значение из памяти в регистр процессора.modify: увеличить значение в регистре на 1.write: записать новое значение обратно в память.
Поток 1: [ Читает 0 ] ───> [ Увеличивает до 1 ] ───────────────────> [ Пишет 1 ]
Поток 2: [ Читает 0 ] ───────────────────> [ Увеличивает до 1 ] ───> [ Пишет 1 (ПЕРЕЗАПИСЬ!) ]
В результате двух параллельных инкрементов значение счётчика увеличилось только на 1 вместо ожидаемых 2. Произошла потеря обновления (Lost Update).
Базовый пример на Java 21
public class RaceConditionDemo {
private int counter = 0;
// ОПАСНО: Read-Modify-Write без синхронизации
public void increment() {
counter++;
}
public int getCounter() {
return counter;
}
public static void main(String[] args) throws InterruptedException {
RaceConditionDemo demo = new RaceConditionDemo();
Thread t1 = new Thread(() -> { for (int i = 0; i < 10_000; i++) demo.increment(); });
Thread t2 = new Thread(() -> { for (int i = 0; i < 10_000; i++) demo.increment(); });
t1.start();
t2.start();
t1.join();
t2.join();
// Ожидаем 20_000, но получим случайное число (например, 14_352)
System.out.println("Итоговый счетчик: " + demo.getCounter());
}
}
🟡 Middle Level
Паттерн Check-Then-Act (Проверил, затем выполнил)
Второй по распространенности сценарий гонки — принятие решения на основе состояния, которое перестаёт быть актуальным в следующий же такт процессора.
// КЛАССИЧЕСКАЯ ОШИБКА: Check-Then-Act
public class UnsafeAccountService {
private int balance = 100;
public void withdraw(int amount) {
// 1. CHECK: проверяем баланс
if (balance >= amount) {
// В этот момент поток может быть вытеснен ОС!
// Другой поток успеет пройти проверку и снять остаток!
// 2. ACT: совершаем действие на основе устаревшей проверки
balance -= amount; // Отрицательный баланс!
}
}
}
Check-Then-Act в коллекциях:
Замена стандартной HashMap на ConcurrentHashMap не защищает от ошибок составной бизнес-логики:
// ОШИБКА: Race Condition присутствует даже с ConcurrentHashMap!
if (!concurrentMap.containsKey(key)) { // CHECK
concurrentMap.put(key, new ExpensiveService()); // ACT: создастся дважды!
}
// ПРАВИЛЬНО: Использовать атомарные композитные методы ConcurrentHashMap
concurrentMap.computeIfAbsent(key, k -> new ExpensiveService());
Почему volatile НЕ защищает от Race Condition?
Часто начинающие разработчики пытаются исправить счётчик добавлением volatile:
private volatile int count = 0;
public void increment() { count++; } // ВСЁ ЕЩЁ СОДЕРЖИТ RACE CONDITION!
- Модификатор
volatileрешает проблему видимости (Visibility) и предотвращает переупорядочивание инструкций (Reordering). - Но
volatileне обеспечивает атомарности составных операций! Операцияcount++по-прежнему транслируется в три раздельные инструкции:getfield,iadd,putfield. Между чтением значения из памяти и записью обновлённого значения другой поток может успеть выполнить свой инкремент.
«Гейзенбаги» (Heisenbugs) и сложность воспроизведения
Ошибки Race Condition называют Heisenbugs (по аналогии с принципом неопределенности Гейзенберга):
- Они не воспроизводятся при отладке в IDE, так как точки останова (breakpoints) и пошаговое выполнение кардинально меняют временные интервалы (timing) работы потоков.
- Они могут годами не проявляться на 2-ядерном ноутбуке разработчика, но мгновенно обрушивать систему в Production на 64-ядерных серверах с высокой плотностью параллельных запросов.
🔴 Senior Level
Data Race против Race Condition: Академическое различие
На интервью уровня Senior/Lead часто проверяют понимание строгого теоретического различия между терминами Data Race и Race Condition:
+--------------------------------------------------------------------------+
| Data Race (Гонка данных) |
| Низкоуровневая ошибка памяти JMM: |
| Как минимум два потока одновременно обращаются к одной ячейке памяти, |
| хотя бы одно обращение является записью, и между ними нет |
| отношения Happens-Before. |
+--------------------------------------------------------------------------+
vs
+--------------------------------------------------------------------------+
| Race Condition (Состояние гонки) |
| Высокоуровневая ошибка бизнес-логики: |
| Недетерминированный результат алгоритма из-за относительного тайминга |
| выполнения потоков (может происходить даже при полном отсутствии |
| низкоуровневых Data Races!). |
+--------------------------------------------------------------------------+
Пример: Race Condition БЕЗ Data Race
// Все операции потокобезопасны (нет Data Race, нет нарушений JMM)
ConcurrentHashMap<String, Integer> stock = new ConcurrentHashMap<>();
public void buyProduct(String item) {
// Две потокобезопасные операции вызываются без единой синхронизации:
if (stock.get(item) > 0) { // Никакого Data Race!
stock.compute(item, (k, v) -> v - 1); // Атомарно!
}
// Но здесь ПРИСУТСТВУЕТ RACE CONDITION:
// Два потока могут одновременно увидеть остаток 1 и продать товар дважды!
}
Безопасные гонки (Benign Data Race): String.hashCode()
В кодовой базе JDK есть редкие примеры намеренного использования гонки данных для оптимизации производительности.
Классический пример — вычисление хеш-кода строки java.lang.String:
// Исходный код java.lang.String:
private int hash; // Поле НЕ volatile!
public int hashCode() {
int h = hash;
if (h == 0 && !hashIsZero) {
h = isLatin1() ? StringLatin1.hashCode(value) : StringUTF16.hashCode(value);
if (h == 0) {
hashIsZero = true;
} else {
hash = h; // Data Race! Потоки могут перезаписывать hash параллельно
}
}
return h;
}
Почему это безопасно (Benign)?
- Иммутабельность: массив символов
valueнеизменяем. - Идемпотентность: функция вычисления хеша чистая (детерминированная). Если пять потоков параллельно посчитают
h, они получат абсолютно одинаковое целое число. - Атомарность 32-битного int: в Java чтение и запись 32-битного
intгарантированно неделимы (нет Word Tearing). - Экономия: отсутствие
volatileна миллиардах объектовStringэкономит барьеры памяти и повышает общую производительность платформы.
Тестирование гонок: JCStress (Java Concurrency Stress)
Традиционные тесты JUnit бессильны против состояний гонки. Для верификации многопоточного кода OpenJDK использует специализированный фреймворк JCStress:
import org.openjdk.jcstress.annotations.*;
import org.openjdk.jcstress.infra.results.I_Result;
@JCStressTest
@Outcome(id = "1", expect = Expect.ACCEPTABLE_INTERESTING, desc = "Потеряно обновление!")
@Outcome(id = "2", expect = Expect.ACCEPTABLE, desc = "Корректно")
@State
public class CounterStressTest {
int count = 0;
@Actor
public void actor1() {
count++; // Первый поток
}
@Actor
public void actor2() {
count++; // Второй поток
}
@Arbiter
public void arbiter(I_Result r) {
r.r1 = count; // Наблюдатель фиксирует итоговое значение
}
}
Фреймворк запускает этот сценарий миллионы раз на разных ядрах CPU с принудительной десинхронизацией конвейеров, гарантированно выявляя редкие коллизии.
🎯 Шпаргалка для интервью
1. 30-секундный эссенс (Elevator Pitch)
Race Condition (состояние гонки) — логическая ошибка многопоточности, при которой результат выполнения программы зависит от случайного чередования инструкций процессора. Проявляется в формах Read-Modify-Write (i++) и Check-Then-Act (if-check-then-put). Отличие от Data Race: Data Race — это нарушение JMM при обращении к памяти без Happens-Before, тогда как Race Condition — это высокоуровневое нарушение инварианта бизнес-логики (может возникать даже на потокобезопасных коллекциях ConcurrentHashMap). Ключевое слово volatile не спасает от составных гонок.
2. 4 каверзных вопроса с глубокими ответами
Вопрос 1: В чём строгое теоретическое отличие между терминами «Data Race» и «Race Condition»?
Ответ:
- Data Race (гонка данных): низкоуровневое понятие модели памяти (JMM). Возникает, когда два потока одновременно обращаются к одной переменной в куче, хотя бы одно обращение — запись, и между ними нет ребра Happens-Before.
- Race Condition (состояние гонки): семантический дефект бизнес-логики приложения, когда результат работы зависит от относительного тайминга потоков.
Важно: программа может содержать Race Condition при полном отсутствии Data Race (например, два потока проверяют баланс в потокобезопаснойConcurrentHashMap, видят 100$ и оба снимают по 100$). И наоборот: программа может содержать Data Race, который не нарушает бизнес-логику (Benign Data Race вString.hashCode()).
Вопрос 2: Почему кэширование хеш-кода в java.lang.String содержит Data Race, но остаётся абсолютно корректным?
Ответ:
Поле hash в классе String объявлено как обычный int (без volatile). Несколько потоков могут параллельно прочитать hash == 0 и начать вычисление, перезаписав результат друг друга. Это явный Data Race по спецификации JMM. Однако он безопасен (Benign), потому что:
- Строка иммутабельна, алгоритм детерминирован — все потоки вычислят в точности одно и то же число.
- Запись 32-битного примитива
intатомарна (нет эффекта повреждения слова / Word Tearing). - Поток запишет то же самое значение, что уже лежит в поле или вычисляется соседом, поэтому никакой потери корректности не происходит, а JVM экономит на барьерах памяти
StoreLoad.
Вопрос 3: Почему код if (!map.containsKey(key)) { map.put(key, value); } содержит Race Condition даже если map — это ConcurrentHashMap?
Ответ:
Потому что методы containsKey() и put() атомарны только по отдельности. Между моментом, когда первый поток выполнил containsKey() и получил false, и моментом, когда он вызвал put(), управление процессором может перейти ко второму потоку. Второй поток успеет проверить ключ и положить своё значение. Когда управление вернётся к первому потоку, он слепо перезапишет значение второго потока. Чтобы устранить гонку Check-Then-Act, необходимо объединить проверку и вставку в единую атомарную транзакцию: map.putIfAbsent(key, value) или map.computeIfAbsent(key, k -> value).
Вопрос 4: Как фреймворк JCStress обнаруживает состояния гонки, которые не ловятся обычными юнит-тестами?
Ответ:
В обычных JUnit-тестах потоки стартуют с небольшим смещением, а JIT-компилятор держит код неоптимизированным, из-за чего вероятность попадания инструкций в узкое окно уязвимости (1–2 такта CPU) ничтожно мала.
JCStress:
- Компилирует тестовые методы
@Actorс агрессивными флагами JIT C2. - Использует фазовые синхронизаторы и ассемблерные спин-циклы для одновременного спуска потоков «такт в такт».
- Раскладывает исполнение на разные физические ядра и сокеты процессора для провокации реального межъядерного переупорядочивания инструкций и задержек кешей L1/L2.
- Прогоняет сценарий миллионы раз и строит статистическую матрицу исходов (
@Outcome).
3. Типичные ошибки и Red Flags
❌ “Добавление volatile к переменной защищает операцию инкремента count++.”
Правильно: volatile даёт только видимость и барьеры порядка, но составная операция чтение-изменение-запись остаётся неатомарной. Требуется AtomicInteger или synchronized.
❌ “Если метод вызывается у ConcurrentHashMap, то весь блок кода вокруг него потокобезопасен.”
Правильно: Потокобезопасны только отдельные вызовы методов коллекции. Логические цепочки вида «проверил-затем-записал» обязаны использовать составные функции (computeIfAbsent, merge).
❌ “Race Condition всегда приводит к выбросу исключений в Java.”
Правильно: В 99% случаев Race Condition протекает молча: данные искажаются, значения затираются, но JVM не выбрасывает ошибок.