Как решить проблему race condition и какие механизмы синхронизации использовать в Java
Что проверяют
Знаете ли вы весь набор средств и понимаете ли, что лучший способ это убрать разделяемое состояние.
Как отвечать
Порядок решений от лучшего к более дорогому.
1. Убрать разделяемое изменяемое состояние. Локальные переменные не разделяются никогда. Неизменяемый объект потокобезопасен по построению: создали и больше не меняем, а изменение возвращает новый экземпляр. Это самое надёжное решение, потому что его нельзя случайно сломать при следующей правке.
2. Изоляция. ThreadLocal даёт каждому потоку свой экземпляр. Полезно для непотокобезопасных объектов вроде форматтеров дат. С осторожностью в пулах потоков: значение переживает задачу и его надо чистить.
3. volatile. Даёт видимость и запрет переупорядочивания, но не атомарность. Годится для флага остановки, не годится для счётчика.
4. Атомарные классы. AtomicInteger и родственники используют операцию сравнения с обменом и решают задачу счётчика без блокировок. При очень высокой конкуренции лучше LongAdder, который разносит значение по ячейкам.
5. Блокировки. synchronized для составных операций: он даёт и взаимное исключение, и видимость. ReentrantLock там, где нужны попытка захвата без ожидания, прерываемое ожидание или несколько условий. ReadWriteLock при преобладании чтений.
6. Конкурентные коллекции. ConcurrentHashMap вместо карты под блокировкой, BlockingQueue для схемы «производитель и потребитель», CopyOnWriteArrayList для редко меняющихся списков подписчиков.
7. Синхронизаторы: CountDownLatch, Semaphore, CyclicBarrier для координации потоков.
Главное, что надо подчеркнуть: потокобезопасность отдельных операций не делает безопасной составную. Последовательность «проверил и положил» это гонка даже в конкурентной карте. Для таких случаев есть атомарные методы вроде вычисления при отсутствии ключа.
Под всем этим лежит модель памяти и отношение «происходит раньше»: выход из синхронизированного блока и запись в изменчивое поле образуют барьер, гарантирующий видимость.
Где спотыкаются
Используют volatile для счётчика.
Синхронизируют каждый метод и всё равно получают гонку на составной операции.
Не чистят ThreadLocal в пуле потоков.
Пишут двойную проверку при ленивой инициализации без volatile.