Неблокирующие структуры данных: ConcurrentHashMap, LongAdder, ConcurrentLinkedQueue
Когда приложение начинает обрабатывать тысячи запросов в секунду, привычные synchronized-коллекции и простые атомарные счётчики перестают справляться. Они либо блокируют всю структуру целиком, либо заставляют потоки соревноваться за одну ячейку памяти, что приводит к падению пропускной способности.
В Java есть три мощные неблокирующие (или почти неблокирующие) структуры, которые помогают выжать максимум из многопоточного кода без перехода к реактивным фреймворкам и распределённым системам.
ConcurrentHashMap — главный кандидат для кэшей и справочников
Hashtable и Collections.synchronizedMap() защищают всю таблицу одной блокировкой. Это просто, но неэффективно при параллельном доступе.
ConcurrentHashMap устроена иначе. Внутри она использует сегментирование (в старых версиях) или CAS-операции и блокировку только на отдельной корзине (в версиях Java 8+). Это означает, что:
- чтение (get) практически никогда не блокируется;
- запись (put) затрагивает только одну корзину, остальные продолжают работать;
- методы вроде size() дают приблизительный результат и не должны использоваться для точной синхронизации.
Такой подход даёт отличную масштабируемость: при росте числа потоков производительность падает незначительно, в отличие от синхронизированных мап.
Когда использовать: для кэширования данных, хранения сессий, конфигураций, метрик — везде, где нужен потокобезопасный доступ к ключам и значениям.
Важная деталь: метод computeIfAbsent может стать узким местом, если функция инициализации выполняется долго. В этом случае лучше использовать putIfAbsent с заранее подготовленным значением или применять ConcurrentHashMap.newKeySet() для множеств.
LongAdder — идеальный счётчик при высокой конкуренции
AtomicLong часто рекомендуют для счётчиков, но он имеет недостаток: все потоки соревнуются за одну и ту же переменную, что приводит к интенсивному обмену данными между кэшами процессоров (cache ping-pong). При 16 и более потоках производительность может упасть в несколько раз.
LongAdder решает эту проблему, разбивая счёт на несколько ячеек. Каждый поток пишет в свою ячейку, и общее значение получается суммированием всех ячеек. Это даёт значительный прирост при высокой конкуренции.
Пример использования:
LongAdder counter = new LongAdder();
// в потоках:
counter.increment();
// получение итога:
long total = counter.sum();
Метод sum() не даёт атомарного снимка — во время суммирования ячейки могут изменяться. Но для мониторинга и статистики такое приближение обычно допустимо. Если нужна строгая точность в каждый момент, стоит выбрать другой подход.
Когда использовать: счётчики запросов, ошибок, времени выполнения, любые накопительные метрики.
ConcurrentLinkedQueue — очередь без блокировок
В классических блокирующих очередях (LinkedBlockingQueue, ArrayBlockingQueue) потоки могут ждать освобождения ресурса. Это полезно в паттернах producer-consumer, но создаёт дополнительные накладные расходы и иногда приводит к задержкам.
ConcurrentLinkedQueue основана на неблокирующем алгоритме Майкла-Скотта и использует CAS для операций вставки и извлечения. Она никогда не блокирует потоки, что даёт более высокую пропускную способность, особенно при большом числе конкурентных производителей или потребителей.
Однако у неё есть особенности:
- нет ограничения ёмкости — очередь может бесконечно расти;
- метод size() требует обхода всей очереди и не рекомендуется;
- нельзя использовать блокирующие методы типа take() — только poll() и peek().
Когда использовать: для буферизации событий, сбора логов, передачи задач в пул потоков, если не требуется ограничивать размер и блокироваться при пустоте.