📦 Розділ 4 · Питання #18

Що таке ConcurrentHashMap

До появи ConcurrentHashMap потокобезпечність словників досягалася двома шляхами:


🟢 Junior Level

1. Що таке ConcurrentHashMap

ConcurrentHashMap — це високопродуктивна потокобезпечна реалізація інтерфейсу ConcurrentMap (що розширює Map), спроєктована спеціально для багатопотокових середовищ із високим рівнем конкуренції (High Concurrency).

До появи ConcurrentHashMap потокобезпечність словників досягалася двома шляхами:

  1. Застарілим класом Hashtable (Java 1.0).
  2. Обгорткою 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: Втрата потокобезпечності через неатомарну складену дію.

Пов’язані теми