Разберите проблему гонки в параллельном Java-коде: class Counter { private int c=0; void inc(){ c++; } } несколько потоков вызывают inc(). Объясните возможные последствия, какие способы синхронизации доступны в Java, их накладные расходы и альтернативы с неблокирующими структурами (AtomicInteger, CAS), с примерами и сравнением производительности и правильности

20 Окт в 16:39
2 +1
0
Ответы
1
Проблема (суть)
- Операция `c++` не атомарна: она выполняет чтение, инкремент и запись. При параллельном выполнении разные потоки могут читать одно и то же значение и перезаписывать результат — потеря инкрементов (lost updates).
- Также возможны проблемы видимости: без синхронизации изменения могут не сразу стать видимыми другим потокам из-за памяти кэша и reordering.
Пример неправильного варианта
- class Counter { private int c = 000; void inc(){ c++; } }
- при NNN потоках и MMM инкрементах в итоге можно получить значение меньше ожидаемого N⋅MN\cdot MNM.
Возможные последствия
- Неправильные счётчики (меньше ожидаемого).
- Нестабильное поведение алгоритмов, зависимое от планировщика.
- Трудноуловимые баги в продакшне.
Способы синхронизации в Java, корректность и накладные расходы
1) synchronized (монитор)
- Пример:
- class Counter { private int c = 000; synchronized void inc(){ c++; } int get(){ return c; } }
- Корректность: гарантирует атомарность и видимость.
- Накладные расходы: блокировка монитора. JVM оптимизирует (biased locking, thin lock), при отсутствии конкуренции почти бесплатна; при высокой конкуренции — переключения потоков, блокировки, потеря производительности.
- Подходит, когда нужна простота и низкая степень конкуренции.
2) ReentrantLock
- Пример:
- class Counter { private int c = 000; private final ReentrantLock l = new ReentrantLock(); void inc(){ l.lock(); try{ c++; } finally{ l.unlock(); } } }
- Корректность: как synchronized, плюс дополнительные функции (tryLock, fairness).
- Накладные расходы: похожи на synchronized, иногда чуть дороже; даёт гибкость управления блокировкой.
3) volatile
- Пример:
- volatile int c = 000;
- Корректность: обеспечивает видимость обновлений, но не делает `c++` атомарным. Поэтому volatile сам по себе не решит гонку при инкременте.
Неблокирующие структуры и CAS
1) AtomicInteger (в JDK)
- Пример:
- private final AtomicInteger c = new AtomicInteger(000);
void inc(){ c.incrementAndGet(); }
- Корректность: атомарная операция на основе CAS (compare-and-swap).
- Накладные расходы: нет блокировок; используется машинная инструкция CAS — очень дешево при низкой/умеренной конкуренции. При сильной конкуренции операции могут многократно перезапускаться, т.е. повышенная нагрузка на CPU из-за ретраев.
2) Реализация CAS-цикла (псевдокод)
- Пример:
- int cur, next;
do {
cur = c.get(); // atomic read
next = cur + 111;
} while (!c.compareAndSet(cur, next));
- Поведение: lock-free, но может зациклиться при конкуренции.
3) LongAdder / LongAccumulator
- Предназначены для высококонкурентных счётчиков.
- Идея: разбить счётчик на несколько слотов (cells) и инкрементировать локальную ячейку; суммирование складывает все ячейки.
- Пример:
- final LongAdder c = new LongAdder();
void inc(){ c.increment(); }
- Корректность: итоговая сумма корректна; чтение (sum) даёт консистентную сумму, но может быть чуть отстающей при одновременных обновлениях (обычно приемлемо).
- Накладные расходы: значительно лучше пропускной способности при высокой конкуренции; чтение дороже (нужно суммировать ячейки).
Сравнение по производительности и правильности (какие свойства)
- Правильность (атомарность/видимость): synchronized, ReentrantLock, AtomicInteger, LongAdder — все дают корректный итог (LongAdder даёт корректную сумму, возможна небольшая непоследовательность при одновременных обновлениях).
- Пропускная способность при разной конкуренции:
- Низкая/нулевая конкуренция: synchronized/ReentrantLock и AtomicInteger примерно сопоставимы (JVM оптимизации делают synchronized дешёвым).
- Умеренная конкуренция: AtomicInteger обычно быстрее, т.к. нет контекстных переключений.
- Высокая конкуренция: LongAdder часто даёт наилучшую производительность (меньше конфликта CAS), AtomicInteger деградирует (много ретраев), synchronized приводит к блокировкам и переключениям.
- Использование CPU: CAS-based решения могут потреблять больше CPU при сильной конкуренции; блокировки приводят к ожиданию и переключениям, что повышает latency.
Практические рекомендации
- Для простых случаев и низкой конкуренции: synchronized или AtomicInteger — оба подходят; AtomicInteger компактнее и быстрее для простого инкремента.
- Для высокой конкуренции и большого числа инкрементов (например, счётчики метрик) — LongAdder.
- Если нужна возможность прерывания/tryLock/условия — ReentrantLock.
- Не используйте volatile для атомарных изменений типа ++.
- Профилируйте: микробенчмарки (например JMH) для вашего сценария — реальные результаты зависят от аппаратуры и нагрузки.
Короткие примеры
- Unsynchronized: private int c = 000; void inc(){ c++; } // НЕПРАВИЛЬНО
- synchronized: private int c = 000; synchronized void inc(){ c++; } // правильно, простo
- AtomicInteger: private AtomicInteger c = new AtomicInteger(000); void inc(){ c.incrementAndGet(); } // lock-free
- LongAdder: private LongAdder c = new LongAdder(); void inc(){ c.increment(); } // лучше при высокой конкуренции
Заключение
- Проблема — потеря инкрементов и видимость. Решения: блокировки (synchronized/ReentrantLock) — просты, корректны; CAS/Atomic* — неблокирующие, быстрее при умеренной нагрузке; LongAdder — лучший выбор при сильной конкуренции. Выбирайте по профилю нагрузки и профилируйте.
20 Окт в 17:12
Не можешь разобраться в этой теме?
Обратись за помощью к экспертам
Гарантированные бесплатные доработки в течение 1 года
Быстрое выполнение от 2 часов
Проверка работы на плагиат
Поможем написать учебную работу
Прямой эфир