ConcurrentHashMap: как устроена потокобезопасная мапа
Почему не хватает synchronizedMap
Самый простой способ сделать мапу потокобезопасной — обернуть HashMap в Collections.synchronizedMap или взять древний Hashtable. Оба варианта лочат всю мапу целиком на каждую операцию, даже на чтение. При высокой конкуренции это превращается в бутылочное горлышко: десять потоков читают из разных бакетов, но все стоят в одной очереди на монитор.
ConcurrentHashMap (CHM) решает это через мелкогранулярную блокировку и активное использование CAS-операций, чтобы читатели вообще не блокировались, а писатели мешали друг другу только если попадают в один и тот же бакет.
Эволюция: Java 7 vs Java 8+
В Java 7 CHM был реализован через массив сегментов (Segment), каждый — по сути отдельный маленький Hashtable со своим ReentrantLock. Число сегментов задавалось при создании (concurrencyLevel) и не менялось. Это давало параллелизм записи, но фиксированное число сегментов плохо масштабировалось при большом количестве потоков.
Начиная с Java 8 сегменты убрали. Теперь блокировка происходит на уровне отдельного бакета (узла в table[i]), а не сегмента. Это увеличивает гранулярность блокировки почти до предела — конфликтуют только потоки, которые попадают в один и тот же индекс массива.
Как работает вставка put
Упрощённый алгоритм put в Java 8+:
1. Вычислить хеш ключа, найти индекс бакета i = hash & (n-1) 2. Если table[i] == null — вставить новый узел через CAS (casTabAt), без блокировок вообще 3. Если бакет занят — взять synchronized на первом узле бакета и добавить/обновить элемент внутри (список или дерево) 4. Если во время вставки идёт resize — помочь с переносом бакетов 5. Обновить счётчик размера через CounterCell (аналог LongAdder)
Ключевой момент: пустой бакет заполняется без единой блокировки — только атомарный CAS. Блокировка (synchronized) берётся только тогда, когда в бакете уже есть хотя бы один узел, и то — только на этот бакет, а не на всю таблицу.
Почему чтение без блокировок безопасно
Ссылки на узлы (Node.next, значения val) объявлены как volatile. Это значит, что запись в бакет становится видимой другим потокам сразу после публикации ссылки — то есть работает та же гарантия happens-before через volatile, что и в других частях JMM. Поэтому get() вообще не берёт блокировок: он просто читает volatile-поля и идёт по цепочке узлов.
Отсюда следует важное свойство: get() может увидеть промежуточное состояние во время резайза (переноса бакетов), но никогда не увидит «сломанную» структуру — только либо старое, либо уже перенесённое согласованное состояние.
Счётчик размера без единой точки конкуренции
Наивный volatile-counter, инкрементируемый CAS-ом на каждый put, стал бы точкой конкуренции при высокой параллельности записи. Поэтому size считается через механизм, похожий на LongAdder: массив CounterCell, куда потоки пишут в «свою» ячейку по хешу потока, а итоговый size() суммирует все ячейки. Это разносит конкуренцию по нескольким счётчикам вместо одного.
compute, merge и атомарность составных операций
Методы вроде computeIfAbsent, compute, merge выполняются атомарно относительно конкретного ключа — они берут ту же блокировку на бакет, что и put. Это удобно для паттерна «прочитать-изменить-записать» без внешней синхронизации, но есть подвох: лямбда внутри compute не должна сама лезть в ту же мапу — можно получить deadlock, так как блокировка уже держится на этом бакете.