Разберите случай гонки (race condition) в многопоточной программе на Java, где два потока инкрементируют общий счётчик без синхронизации; предложите корректные варианты исправления и оцените их накладные расходы
Коротко — что происходит - При инкременте общего счётчика `counter++` выполняется последовательность: чтение, +1, запись. Это не атомарно, поэтому при 222 потоках возможна гонка: оба читают одно и то же значение и оба записывают одинаковый результат — потеря инкремента. Правильные варианты исправления (с примерами и назначением) 1) synchronized (монитор) - Код: public synchronized void increment() { counter++; } - Когда использовать: простая корректность, когда критическая секция небольшая или нужно защищать несколько операций одновременно. - Накладные расходы: при отсутствии конкуренции — небольшие (время входа/выхода монитора), при конкуренции — блокировка/ожидание и парковка потоков, стоимость растёт сильно. - Приблизительно: без конкуренции ~ 20–50\,20\text{–}5020–50 нс; при сильной конкуренции — от 1 μs1\ \mu s1μs до миллисекунд и выше (зависит от платформы). 2) ReentrantLock - Код: lock.lock(); try { counter++; } finally { lock.unlock(); } - Когда: нужен явный контроль (tryLock, fairness), или когда синхронизация должна быть привязана к объекту отдельно. - Накладные расходы: аналогичны `synchronized` (внутренне сложнее), fair-режим заметно медленнее. - Примерная производительность: близка к `synchronized` в большинстве сценариев. 3) AtomicInteger / AtomicLong (CAS) - Код: AtomicInteger counter = new AtomicInteger(); counter.incrementAndGet(); - Когда: простая атомарная операция инкремента, низкая/умеренная конкуренция, требование lock-free. - Накладные расходы: очень малая при успехе (атомарный CAS-инструкция) — обычно несколько наносекунд; при высокой конкуренции — многократные повторы CAS (рост задержок). - Приблизительно: успешный CAS ~ 5–20\,5\text{–}205–20 нс; при частых retry стоимость растёт линейно с количеством неудачных попыток. 4) LongAdder / LongAccumulator - Код: LongAdder counter = new LongAdder(); counter.increment(); - Когда: большой объём конкурентных инкрементов (много потоков), требуется высокая пропускная способность для обновлений; допустимо, что чтение — чуть дороже. - Накладные расходы: при обновлениях очень низкая конкуренция за каждую «ячейку» — высокая пропускная способность; чтение делает суммирование по всем ячейкам. - Сложности: память для mmm ячеек, чтение — O(m)O(m)O(m) (где mmm = число ячеек, обычно ~число логических CPU). - Подходит, если число обновлений >> число чтений. 5) CAS-цикл вручную (compareAndSet) - Код: loop: int v = a.get(); if (a.compareAndSet(v, v+1)) break; - Когда: если нужна специальная логика при конфликте. Обычно лучше использовать `AtomicInteger.incrementAndGet()`. Чего не делать - volatile не решает проблему инкремента: `volatile int x; x++` всё ещё не атомарно. - Не нужен `synchronized` если достаточно атомарной операции (AtomicInteger/LongAdder предпочтительнее для простого счётчика). Короткая рекомендация - Если нужно просто атомарно инкрементировать и потоков не очень много — используйте `AtomicInteger` / `AtomicLong`. - Если много потоков и интенсивные обновления — `LongAdder`. - Если критическая секция сложнее одного инкремента (несколько операций) или требуется строгая последовательность — `synchronized` или `ReentrantLock`. Если нужно — могу привести микро-бенчмарки для вашей конкретной нагрузки и JVM, чтобы оценить реальные числа.
- При инкременте общего счётчика `counter++` выполняется последовательность: чтение, +1, запись. Это не атомарно, поэтому при 222 потоках возможна гонка: оба читают одно и то же значение и оба записывают одинаковый результат — потеря инкремента.
Правильные варианты исправления (с примерами и назначением)
1) synchronized (монитор)
- Код:
public synchronized void increment() {
counter++;
}
- Когда использовать: простая корректность, когда критическая секция небольшая или нужно защищать несколько операций одновременно.
- Накладные расходы: при отсутствии конкуренции — небольшие (время входа/выхода монитора), при конкуренции — блокировка/ожидание и парковка потоков, стоимость растёт сильно.
- Приблизительно: без конкуренции ~ 20–50\,20\text{–}5020–50 нс; при сильной конкуренции — от 1 μs1\ \mu s1 μs до миллисекунд и выше (зависит от платформы).
2) ReentrantLock
- Код:
lock.lock();
try { counter++; } finally { lock.unlock(); }
- Когда: нужен явный контроль (tryLock, fairness), или когда синхронизация должна быть привязана к объекту отдельно.
- Накладные расходы: аналогичны `synchronized` (внутренне сложнее), fair-режим заметно медленнее.
- Примерная производительность: близка к `synchronized` в большинстве сценариев.
3) AtomicInteger / AtomicLong (CAS)
- Код:
AtomicInteger counter = new AtomicInteger();
counter.incrementAndGet();
- Когда: простая атомарная операция инкремента, низкая/умеренная конкуренция, требование lock-free.
- Накладные расходы: очень малая при успехе (атомарный CAS-инструкция) — обычно несколько наносекунд; при высокой конкуренции — многократные повторы CAS (рост задержок).
- Приблизительно: успешный CAS ~ 5–20\,5\text{–}205–20 нс; при частых retry стоимость растёт линейно с количеством неудачных попыток.
4) LongAdder / LongAccumulator
- Код:
LongAdder counter = new LongAdder();
counter.increment();
- Когда: большой объём конкурентных инкрементов (много потоков), требуется высокая пропускная способность для обновлений; допустимо, что чтение — чуть дороже.
- Накладные расходы: при обновлениях очень низкая конкуренция за каждую «ячейку» — высокая пропускная способность; чтение делает суммирование по всем ячейкам.
- Сложности: память для mmm ячеек, чтение — O(m)O(m)O(m) (где mmm = число ячеек, обычно ~число логических CPU).
- Подходит, если число обновлений >> число чтений.
5) CAS-цикл вручную (compareAndSet)
- Код: loop: int v = a.get(); if (a.compareAndSet(v, v+1)) break;
- Когда: если нужна специальная логика при конфликте. Обычно лучше использовать `AtomicInteger.incrementAndGet()`.
Чего не делать
- volatile не решает проблему инкремента: `volatile int x; x++` всё ещё не атомарно.
- Не нужен `synchronized` если достаточно атомарной операции (AtomicInteger/LongAdder предпочтительнее для простого счётчика).
Короткая рекомендация
- Если нужно просто атомарно инкрементировать и потоков не очень много — используйте `AtomicInteger` / `AtomicLong`.
- Если много потоков и интенсивные обновления — `LongAdder`.
- Если критическая секция сложнее одного инкремента (несколько операций) или требуется строгая последовательность — `synchronized` или `ReentrantLock`.
Если нужно — могу привести микро-бенчмарки для вашей конкретной нагрузки и JVM, чтобы оценить реальные числа.