Що таке ConcurrentHashMap
До появи ConcurrentHashMap потокобезпечність словників досягалася двома шляхами:
🟢 Junior Level
1. Що таке ConcurrentHashMap
ConcurrentHashMap — це високопродуктивна потокобезпечна реалізація інтерфейсу ConcurrentMap (що розширює Map), спроєктована спеціально для багатопотокових середовищ із високим рівнем конкуренції (High Concurrency).
До появи ConcurrentHashMap потокобезпечність словників досягалася двома шляхами:
- Застарілим класом
Hashtable(Java 1.0). - Обгорткою
Collections.synchronizedMap(new HashMap<>())(Java 1.2).
Обидва ці підходи використовують грубе глобальне блокування (Coarse-Grained Lock) на рівні всього об’єкта. У результаті будь-який потік, що виконує читання get(), блокує всіх інших читачів та письменників. Це створює вузьке місце та катастрофічно знижує пропускну здатність багатоядерних систем.
ConcurrentHashMap вирішує цю проблему:
- Операції читання (
get) виконуються повністю без блокувань (Lock-Free). - Операції запису (
put,remove) блокують лише один конкретний кошик (бакет), не заважаючи потокам паралельно модифікувати інші бакети.
// Ініціалізація потокобезпечної мапи:
Map<String, Integer> concurrentMap = new ConcurrentHashMap<>();
// Багато потоків можуть одночасно читати та писати без зовнішніх synchronized-блоків:
concurrentMap.put("alpha", 1);
concurrentMap.computeIfAbsent("beta", k -> 2);
2. Порівняльна таблиця реалізацій
| Критерій | HashMap |
Collections.synchronizedMap |
ConcurrentHashMap |
|---|---|---|---|
| Потокобезпечність | ❌ Ні | ✅ Так (один монітор) | ✅ Так (дрібногранулярна) |
| Блокування читання | Немає | Блокує всю мапу | Lock-Free (без блокувань) |
| Блокування запису | Немає | Блокує всю мапу | Блокує лише бакет |
Підтримка null |
Key: ✅, Val: ✅ | Key: ✅, Val: ✅ | Key: ❌, Val: ❌ (NPE) |
| Ітератори | Fail-Fast (CME) |
Fail-Fast (вимагає sync) |
Weakly Consistent (немає CME) |
🟡 Middle Level
1. Чому ConcurrentHashMap категорично забороняє null?
Спроба передати null як ключ або значення в ConcurrentHashMap негайно викидає NullPointerException:
map.put(null, 100); // NullPointerException!
map.put("key", null); // NullPointerException!
Архітектурне обґрунтування автора класу (Даг Лі / Doug Lea):
В однопотоковій HashMap результат map.get(key) == null можна однозначно перевірити через map.containsKey(key): якщо containsKey повернув true, отже значення дійсно дорівнює null.
У багатопотоковому середовищі такий підхід призводить до критичного стану гонитви (Race Condition):
// У багатопотоковому коді цей патерн не має сенсу:
if (map.get(key) == null) {
// У цей момент інший потік міг встигнути вставити ключ або видалити його!
if (map.containsKey(key)) {
// Ми не можемо довіряти цій перевірці
}
}
Щоб повністю усунути цю невизначеність («значення відсутнє» чи «значення дорівнює null»), Даг Лі ухвалив фундаментальне рішення: null як ключ або значення в конкурентних колекціях суворо заборонено.
2. Атомарні складені операції (Compound Actions)
Головна небезпека при роботі з потокобезпечними колекціями — ілюзія безпеки складених дій (Check-Then-Act):
// ❌ АНТИПАТЕРН: стан гонитви між двома викликами!
if (!map.containsKey("counter")) {
map.put("counter", 1); // Інший потік міг втрутитися між перевіркою та вставкою!
}
// ✅ ПРАВИЛЬНО: атомарні методи ConcurrentMap
map.putIfAbsent("counter", 1);
// ✅ Атомарне оновлення через лямбду (Java 8+):
map.compute("counter", (k, v) -> (v == null) ? 1 : v + 1);
// ✅ Ідеальний потокобезпечний лічильник через merge:
map.merge("counter", 1, Integer::sum);
3. Слабоузгоджені ітератори (Weakly Consistent Iterators)
Ітератори ConcurrentHashMap ніколи не викидають ConcurrentModificationException:
- Вони називаються Weakly Consistent (слабоузгодженими).
- Вони відображають стан колекції на момент створення ітератора та гарантовано проходять за всіма елементами, що існували на той момент.
- Вони можуть (але не зобов’язані) відображати зміни, внесені іншими потоками після створення ітератора.
- Вони ніколи не повертають елементи двічі й не завершуються аварійно при паралельних модифікаціях.
🔴 Senior Level
1. Архітектура підрахунку розміру: CounterCell та mappingCount()
У високонавантаженій системі звичайне поле volatile int size стало б непереборною перешкодою для масштабування: кожне оновлення будь-якого бакета вимагало б атомарної модифікації однієї спільної змінної, породжуючи постійну інвалідацію кеш-ліній процесора (Cache Coherence Traffic).
Натомість ConcurrentHashMap використовує підхід, аналогічний LongAdder:
- Базовий лічильник
baseCount. - Масив страйпованих комірок
CounterCell[] counterCells. - Якщо потік стикається з конкуренцією при CAS-інкременті
baseCount, він перемикається на власну комірку вcounterCellsза системним хешем потоку. - Метод
size()підсумовуєbaseCountта значення всіх активних комірок. Результат є оціночним зрізом на момент виклику.
// Для колекцій, де кількість записів може перевищити Integer.MAX_VALUE (2 млрд):
long totalMappings = map.mappingCount(); // Рекомендовано замість size()
2. Пастки методу computeIfAbsent(): взаємні блокування (Deadlocks)
Хоча метод computeIfAbsent() надзвичайно зручний, його необережне використання у highload може повністю заморозити застосунок:
// ❌ ПАСТКА 1: Блокувальні виклики всередині лямбди
map.computeIfAbsent("token", k -> {
return httpClient.fetchRemoteToken(); // НЕБЕЗПЕЧНО! Утримує блокування на бакеті секундами!
});
Під час виконання переданої лямбди бакет заблоковано монітором його головного вузла. Усі інші потоки, що намагаються звернутися до будь-якого ключа з цього самого бакета, зависають у стані BLOCKED.
// ❌ ПАСТКА 2: Взаємне блокування (Deadlock)
Map<Integer, Integer> map = new ConcurrentHashMap<>();
map.computeIfAbsent(1, k1 -> {
// Спроба обчислити інший ключ, що потрапляє в той самий або сусідній бакет:
return map.computeIfAbsent(2, k2 -> 42); // ГАРАНТОВАНИЙ DEADLOCK!
});
🎯 Шпаргалка для інтерв’ю
30-секундна відповідь (Elevator Pitch)
«
ConcurrentHashMap— це промислова потокобезпечна хеш-таблиця, оптимізована для паралельної роботи багатьох потоків. На відміну відHashtableтаCollections.synchronizedMap, які використовують блокування всього об’єкта,ConcurrentHashMapреалізує читання без блокувань (Lock-Free черезvolatile) і блокує лише заголовок конкретного бакета при запису (synchronized(head)у Java 8+). Колекція категорично забороняєnullдля ключів і значень заради усунення неоднозначності в багатопотоковості, використовує слабоузгоджені ітератори безConcurrentModificationExceptionта масштабований лічильник розміру на базіCounterCell».
3 каверзних питання на інтерв’ю з відповідями
1. Чому ConcurrentHashMap.size() не повертає абсолютно точне значення в кожен момент часу?
Відповідь: У розподіленому паралельному середовищі зафіксувати миттєвий розмір структури без блокування всіх потоків-письменників фізично неможливо. Підрахунок розміру підсумовує базове поле baseCount та комірки CounterCell[]. Поки процесор додає значення комірок, паралельні потоки продовжують додавати та видаляти елементи. Тому значення size() є оціночним зрізом стану (Weakly Consistent), достатнім для моніторингу, проте не призначеним для строгої транзакційної логіки.
2. Навіщо розробники заборонили null у ConcurrentHashMap, якщо звичайний HashMap чудово працює з null?
Відповідь: За контрактом Map.get(k) повертає null, якщо ключ відсутній. Якби значення null було дозволене, результат get(k) == null мав би два трактування: «ключа немає» або «ключ є, але його значення null». В однопотоковому коді це перевіряється викликом containsKey(k). У конкурентному середовищі між викликами get() та containsKey() інший потік може видалити ключ, перетворивши результат перевірки на хибний (Race Condition). Заборона null усуває цей клас помилок.
3. У чому різниця між архітектурою ConcurrentHashMap у Java 7 та Java 8+?
Відповідь:
- У Java 7 використовувалося сегментування (Segment-Level Locking): мапа ділилася на фіксовану кількість сегментів (за замовчуванням 16), кожен із яких успадковував
ReentrantLock. Потоки могли паралельно модифікувати різні сегменти, але всередині одного сегмента блокувалися. - У Java 8+ від сегментів повністю відмовилися на користь блокувань на рівні окремих бакетів (Node-Level Locking). Якщо бакет порожній, вузол вставляється через атомарний CAS без блокувань. Якщо бакет зайнятий, захоплюється монітор лише першого елемента бакета (
synchronized(node)). Це знизило оверхед пам’яті й підняло рівень паралелізму до кількості непорожніх бакетів.
Типові помилки та Red Flags
- ❌ Стверджувати, що
ConcurrentHashMapблокує сегменти в сучасних версіях: Сегменти залишилися в минулому з виходом Java 8 у 2014 році. - ❌ Виконання важких блокувальних операцій (I/O, мережеві запити) всередині
computeIfAbsent: Призводить до блокування бакета й вичерпання пулу потоків. - ❌ Спроба зберегти
null: Завершиться негайнимNullPointerException. - ❌ Використання конструкції
if (!map.containsKey) map.putзамістьputIfAbsent: Втрата потокобезпечності через неатомарну складену дію.