Что такое ConcurrentHashMap и чем он отличается от HashMap
ConcurrentHashMap — это высокопроизводительная потокобезопасная реализация интерфейса ConcurrentMap (и Map), предназначенная для одновременной работы множества параллельных пото...
🟢 Junior Level
ConcurrentHashMap — это высокопроизводительная потокобезопасная реализация интерфейса ConcurrentMap (и Map), предназначенная для одновременной работы множества параллельных потоков без блокировки всей таблицы целиком.
Главные отличия от HashMap
| Характеристика | HashMap |
ConcurrentHashMap |
|---|---|---|
| Потокобезопасность | ❌ Не потокобезопасна | ✅ Потокобезопасна |
Поддержка null |
✅ Разрешает null-ключ и null-значения |
❌ Выбрасывает NullPointerException |
| Поведение итератора | Fail-fast (выбрасывает ConcurrentModificationException) |
Weakly Consistent (никогда не выбрасывает CME) |
| Производительность в 1 потоке | Максимальная (без накладных расходов) | Незначительно ниже (на доли наносекунд) |
| Многопоточная запись | Приводит к гонкам, потере данных и зависаниям | Безопасная параллельная запись |
Пример базового использования
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
public class ConcurrentMapDemo {
public static void main(String[] args) {
ConcurrentMap<String, Integer> stock = new ConcurrentHashMap<>();
stock.put("MacBook", 10);
// Атомарное обновление без гонок:
stock.merge("MacBook", 1, Integer::sum); // Стало 11
// Попытка передать null вызовет NullPointerException:
// stock.put(null, 5); // ОШИБКА в runtime!
}
}
Аналогия:
HashMap— это примерочная в магазине на одного человека: если ворвутся двое, начнется конфликт.ConcurrentHashMap— это большой гардероб с десятками независимых секций: каждый посетитель обслуживается у своей стойки, они не мешают друг другу и не выстраиваются в одну общую очередь.
🟡 Middle Level
Архитектура многопоточности в Java 8+
В Java 7 ConcurrentHashMap строился на концепции сегментов (Segment[], по умолчанию 16 сегментов), где каждый сегмент представлял собой отдельную блокирующуюся таблицу (ReentrantLock).
Начиная с Java 8, архитектура была радикально переработана на Node-level locking (блокировка на уровне отдельного бакета):
[Bucket 0] -> Node (CAS вставка)
[Bucket 1] -> synchronized (firstNode) { [Node] -> [Node] -> [Node] }
[Bucket 2] -> ForwardingNode (перенос данных при resize)
[Bucket 3] -> TreeBin (блокировка чтения/записи для красно-чёрного дерева)
- Чтение (
get): Работает абсолютно без блокировок (Lock-Free). Поляvalиnextв узлеNodeобъявлены какvolatile, что гарантирует немедленную видимость изменений между ядрами CPU по модели памяти Java (JMM). - Вставка в пустой бакет: Выполняется через неблокирующую атомарную инструкцию процессора CAS (Compare-And-Swap) через
VarHandle/Unsafe:if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value))) break; // Успешно вставили без каких-либо мониторов! - Вставка при коллизии: Блокируется только первый узел (голова) бакета через легковесный монитор
synchronized (f). Другие потоки могут свободно и параллельно писать в любые другие бакеты таблицы.
Почему в ConcurrentHashMap категорически запрещен null?
Создатель класса Даг Ли (Doug Lea) исключил null из-за проблемы устранения неоднозначности (Ambiguity) в многопоточной среде.
Если в обычной HashMap метод map.get("key") вернул null, в одном потоке можно однозначно выяснить причину:
if (map.get(key) == null) {
if (map.containsKey(key)) {
// Значение действительно null
} else {
// Ключа в карте нет
}
}
В конкурентной среде между вызовом map.get(key) и map.containsKey(key) другой поток может успеть добавить, удалить или обновить ключ. Состояние между проверками становится неконсистентным (TOCTOU — Time-of-Check to Time-of-Use). Запрет null гарантирует: если get(k) == null, ключа в таблице нет на 100%.
Атомарные составные операции
В многопоточных программах нельзя использовать классический шаблон if (!map.containsKey(k)) map.put(k, v); — между проверкой и вставкой возникает состояние гонки. ConcurrentHashMap предоставляет атомарные методы:
putIfAbsent(key, value): Вставляет значение только если ключа не было.computeIfAbsent(key, mappingFunction): Атомарно вычисляет и кладет значение, только если ключ отсутствует (идеально для потокобезопасных кэшей).merge(key, value, remappingFunction): Атомарно объединяет старое значение с новым (подходит для счетчиков).
🔴 Senior Level
Механизм совместного ресайза (Cooperative Parallel Resize)
В отличие от HashMap, где один поток берет на себя всю тяжесть ресайза ($O(N)$), в ConcurrentHashMap ресайз является распределенным и многопоточным:
ForwardingNode: Когда бакет успешно перенесен в новый массив, в старый бакет на его место помещается специальный маркировочный узелForwardingNode(с отрицательным хешем-1).- Помощь других потоков (
helpTransfer): Если поток-писатель приходит в бакет, в котором видитForwardingNode, он не блокируется в ожидании. Вместо этого он подключается к помощи в переносе остальных бакетов! - Координатор
transferIndex: Глобальный счетчик атомарно выдает каждому потоку «пачку» бакетов для обработки (шагstride, обычно от 16 до 64 бакетов). - Безопасное чтение: Потоки, вызывающие
get(), натыкаясь наForwardingNode, перенаправляются сразу в новую таблицуnextTableбез задержек.
Архитектура подсчета размера: CounterCell (LongAdder Pattern)
В HashMap размер хранится в поле int size. В конкурентной среде атомарный инкремент одного общего AtomicInteger привел бы к катастрофическому падению скорости из-за Cache Line Bouncing (постоянная инвалидация кэш-линий L1/L2 процессора между ядрами).
В ConcurrentHashMap подсчет размера реализован по принципу java.util.concurrent.atomic.LongAdder:
private transient volatile long baseCount;
private transient volatile CounterCell[] counterCells;
- Поток сначала пытается атомарно обновить
baseCountчерез CAS. - При высокой конкуренции поток выбирает ячейку в массиве
counterCellsпо своему потоковому хешу (ThreadLocalRandom.getProbe()) и инкрементирует значение внутри нее. - Метод
size()/mappingCount()суммируетbaseCountи значения всех ячеекcounterCells. Это дает сложность $O(1)$ и полное отсутствие блокировок при записи.
Итераторы: семантика Weakly Consistent
Итераторы ConcurrentHashMap (iterator(), spliterator()) обладают свойствами:
- Не выбрасывают
ConcurrentModificationException. - Могут безопасно обходить таблицу одновременно с выполняющимися записями.
- Гарантированно обходят элементы, существовавшие на момент создания итератора, и могут (но не обязаны) отразить последующие модификации.
- Создаются без создания полной копии (snapshot) массива, потребляя $O(1)$ памяти.
4 Tricky Questions
1. Почему метод get() в ConcurrentHashMap работает без блокировок, но гарантирует видимость свежих данных?
Ответ:
Метод get() не использует ни synchronized, ни явных локов, обеспечивая минимальный latency благодаря трем факторам модели памяти Java (JMM):
volatileссылки массива: Массив узловtableобъявлен какtransient volatile Node<K,V>[] table;, а доступ к элементам массива осуществляется через volatile-чтение (Unsafe.getObjectVolatile/VarHandle).volatileполя узла: Внутри классаNode<K,V>поля значения и ссылки на следующий элемент объявлены какvolatile:volatile V val; volatile Node<K,V> next;Согласно JMM, запись в
volatile-переменную формирует отношение Happens-Before с последующим чтением этой переменной в другом потоке, гарантируя, что поток-читатель увидит самое последнее значение из оперативной памяти без устаревания в кэше CPU.- Иммутабельность ключа и хеша: Поля
final int hash;иfinal K key;не могут измениться после инициализации узла.
2. Почему код if (!map.containsKey(k)) map.put(k, v); не является потокобезопасным даже с ConcurrentHashMap?
Ответ:
Хотя методы containsKey() и put() по отдельности потокобезопасны и атомарны, их композиция (последовательность вызовов) не является атомарной.
Между выполнением containsKey() (который вернет false) и следующим вызовом put() поток может быть приостановлен планировщиком ОС. В этот микроинтервал времени другой поток успеет вставить свое значение по ключу k. Первый поток проснется и перезапишет данные второго потока. Это классическое состояние гонки Check-Then-Act (TOCTOU).
Решение — использование единого атомарного метода map.putIfAbsent(k, v) или map.computeIfAbsent(k, ...).
3. Что происходит при вызове size() в ConcurrentHashMap, если параллельно идут тысячи записей? Возвращает ли он абсолютно точное значение?
Ответ:
Метод size() (или mappingCount()) возвращает снимок размера таблицы на момент вызова, суммируя baseCount и значения распределенного массива CounterCell[].
Спецификация JDK прямо указывает, что в условиях интенсивной многопоточной записи возвращаемое значение является приблизительной оценкой (estimate). К тому моменту, когда метод закончит суммирование всех ячеек, параллельные потоки уже добавят или удалят новые элементы. Поэтому полагаться на map.size() == 0 для логики управления потоками нельзя (для этого служит map.isEmpty()).
4. Может ли возникнуть Deadlock при использовании метода computeIfAbsent() в ConcurrentHashMap?
Ответ:
Да, может.
Внутри computeIfAbsent поток захватывает монитор бакета synchronized (node). Если внутри переданной лямбда-функции попытаться обратиться к этой же ConcurrentHashMap для обновления ключа из того же или зависимого бакета, возникнет взаимная блокировка или исключение IllegalStateException:
map.computeIfAbsent("key1", k -> {
// Фатальная ошибка: вызов мутирующих методов этой же карты внутри лямбды!
map.put("key2", 42);
return 100;
});
Javadoc ConcurrentHashMap строго запрещает попытки обновления карты внутри функций обратного вызова (mappingFunction).
🎯 Шпаргалка для интервью
- Суть
ConcurrentHashMap: Потокобезопасная хеш-таблица с высокой конкурентностью (Java 8+: CAS для пустых бакетов,synchronizedна первом узле бакета). get()Lock-Free: Чтение выполняется без локов за счетvolatileполейvalиnextвNode.- Запрет
null:nullзапрещен для ключей и значений, чтобы исключить неоднозначность (ambiguity) в многопоточной среде. - Итераторы: Слабосогласованные (Weakly Consistent), не создают полных копий, не выбрасывают
ConcurrentModificationException. - Параллельный Resize: Потоки помогают друг другу переносить бакеты через маркер
ForwardingNodeиtransferIndex. - Подсчет размера: Выполняется через паттерн
LongAdder(CounterCell[]+baseCount), предотвращая Cache Line Bouncing. - Правило работы: Избегайте композиций
containsKey+put; используйте атомарные методыputIfAbsent,computeIfAbsent,merge.