Race condition на живом примере

Вопрос-крючок: «Вот код, который считает от двух потоков. Почему результат не всегда 2000000?»

Race condition (состояние гонки) — ситуация, когда результат работы программы зависит от того, в каком порядке и как именно чередуются операции разных потоков, обращающихся к общим данным.

Это, пожалуй, самый практический вопрос из всей темы многопоточности — интервьюер часто показывает код и просит объяснить, почему он ведёт себя непредсказуемо. Давайте разберём его пошагово, как на настоящем собеседовании.

Код-ловушка

Два потока по миллиону раз увеличивают общую переменную. Казалось бы, в конце должно получиться ровно два миллиона.

class Counter {
    private int count = 0;

    public void increment() {
        count++;
    }

    public int getCount() {
        return count;
    }
}

public class Main {
    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();

        Runnable task = () -> {
            for (int i = 0; i < 1_000_000; i++) {
                counter.increment();
            }
        };

        Thread t1 = new Thread(task);
        Thread t2 = new Thread(task);
        t1.start();
        t2.start();
        t1.join();
        t2.join();

        System.out.println("Итог: " + counter.getCount());
    }
}

Вывод (примерный, будет меняться от запуска к запуску):

Итог: 1483219

Вместо ожидаемых 2 000 000 получается какое-то другое число, каждый раз разное и почти всегда меньше двух миллионов. Это и есть race condition в чистом виде.

Почему так происходит

Всё дело в том, что count++ — это не одна операция для процессора, а три последовательных шага:

  1. Прочитать текущее значение count из памяти.
  2. Прибавить к нему единицу.
  3. Записать новое значение обратно в память.

Представим, что count равен 5, и оба потока почти одновременно вызывают increment():

Момент времениПоток 1Поток 2Значение count
t1читает count = 55
t2читает count = 55
t3прибавляет 1, получает 6прибавляет 1, получает 65
t4записывает count = 66
t5записывает count = 66

Оба потока честно выполнили свой инкремент, но оба стартовали от одного и того же прочитанного значения 5, поэтому вместо ожидаемых двух прибавлений (5 → 6 → 7) получилось одно (5 → 6). Один инкремент буквально «потерялся». Умножьте это на миллионы итераций — и вы получите непредсказуемый недостачу именно того масштаба, что видно в выводе выше.

Способ 1: synchronized

Самое прямое решение — обернуть операцию в блокировку, чтобы гарантировать, что весь цикл «прочитать-изменить-записать» выполняется одним потоком без вмешательства другого.

class Counter {
    private int count = 0;

    public synchronized void increment() {
        count++;
    }

    public synchronized int getCount() {
        return count;
    }
}

Вывод:

Итог: 2000000

Теперь, пока один поток внутри increment(), второй ждёт снаружи, и потерянных обновлений быть не может. Цена — потоки конкурируют за блокировку, что немного замедляет работу при высокой нагрузке.

Способ 2: AtomicInteger

Для случая «просто безопасно посчитать число» в Java есть специализированный класс AtomicInteger из пакета java.util.concurrent.atomic. Он выполняет операцию инкремента как единое неделимое (atomic) действие, без явной блокировки.

import java.util.concurrent.atomic.AtomicInteger;

class Counter {
    private final AtomicInteger count = new AtomicInteger(0);

    public void increment() {
        count.incrementAndGet();
    }

    public int getCount() {
        return count.get();
    }
}

Вывод:

Итог: 2000000

Под капотом AtomicInteger использует аппаратную инструкцию процессора compare-and-swap: она атомарно сравнивает текущее значение с ожидаемым и, если оно не изменилось, записывает новое — и всё это одной неделимой операцией на уровне процессора, без блокировки потоков. Для простых счётчиков это обычно быстрее, чем synchronized.

Способ 3: явная блокировка Lock

Более гибкий инструмент — ReentrantLock из java.util.concurrent.locks. Он делает примерно то же, что synchronized, но с блокировкой можно работать «руками»: например, попытаться захватить её с таймаутом, вместо того чтобы ждать бесконечно.

import java.util.concurrent.locks.ReentrantLock;

class Counter {
    private int count = 0;
    private final ReentrantLock lock = new ReentrantLock();

    public void increment() {
        lock.lock();
        try {
            count++;
        } finally {
            lock.unlock();
        }
    }
}

Обратите внимание на try/finally: если внутри защищённого кода вылетит исключение, блокировка всё равно обязана освободиться — иначе другие потоки зависнут навсегда в ожидании.

Частые ошибки на собеседовании

  • Утверждают, что count++ — атомарная операция, потому что «выглядит как одна строчка кода». На деле это чтение + прибавление + запись, три отдельных шага.
  • Предлагают пометить поле count как volatile и считают проблему решённой — как мы разобрали в прошлом уроке, volatile не спасает составные операции вроде инкремента.
  • Не могут объяснить разницу между synchronized и AtomicInteger — оба решают задачу, но AtomicInteger обычно быстрее для простых счётчиков именно потому, что не использует блокировку потоков.
  • Забывают вызвать unlock() в блоке finally при работе с ReentrantLock — это частая причина «зависшей» программы в реальном коде.

Шпаргалка

  • Race condition возникает, когда несколько потоков без защиты одновременно читают и изменяют общие данные.
  • count++ — это три отдельных шага (прочитать, прибавить, записать), а не одна атомарная операция.
  • Чинится через synchronized (блокировка блока/метода), AtomicInteger (атомарные операции без блокировки) или ReentrantLock (гибкая явная блокировка).
  • volatile здесь не помощник — он решает другую задачу (видимость), а не взаимное исключение.
Проверьте себя
1. Почему в примере с двумя потоками, каждый из которых миллион раз вызывает count++, итоговое значение почти никогда не равно 2 000 000?
AПотому что Java намеренно ограничивает максимальное значение int внутри циклов
BПотому что count++ состоит из трёх шагов (чтение, прибавление, запись), и потоки могут прочитать одинаковое значение до того, как кто-то из них его запишет
CПотому что два потока в Java физически не могут одновременно обращаться к одному объекту
DПотому что цикл for в многопоточном коде выполняется не все 1 000 000 раз
2. Какой из вариантов НЕ решает проблему потерянных инкрементов в этом примере?
AОбернуть increment() в synchronized
BЗаменить int count на AtomicInteger и использовать incrementAndGet()
CПометить поле count модификатором volatile, оставив count++ как есть
DИспользовать ReentrantLock вокруг count++ с освобождением в finally