Питання 19 · Розділ 4

Як ConcurrentHashMap забезпечує thread-safety?

LongAdder — стратегія: замість однієї volatile-змінної (contention при конкурентному записі), масив лічильників. Кожен потік пише у свою комірку, результат = сума всіх комірок.

Мовні версії: English Russian Ukrainian

🟢 Junior Level

ConcurrentHashMap безпечний для потоків завдяки трьом механізмам:

  1. CAS — для вставки в пусту корзину (без блокувань)
  2. Volatile — щоб усі потоки бачили останні дані
  3. Synchronized — тільки для колізій (блокує одну корзину)

Як вони працюють разом:

  • CAS = вставка нового елемента без блокувань
  • Volatile = видимість змін між потоками (кожен потік одразу бачить нові елементи)
  • Synchronized = захист при колізіях (коли два потоки одночасно пишуть в одну корзину)
// Безпечно з різних потоків:
ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();

// Потік 1:
map.put("A", 1);

// Потік 2 (одночасно):
map.put("B", 2);

// Обидва безпечно!

🟡 Middle Level

Три кити безпеки

1. CAS (Compare-And-Swap):

// Для пустої корзини:
if (CAS(table[i], null, newNode)) {
    // Успіх! Вставили без блокувань
} else {
    // Інший потік вставив → retry
}

2. Volatile:

// Усі поля volatile:
transient volatile Node[] table;
volatile V val;
volatile Node next;

// → Запис одного потоку видна всім
// → get() без блокувань!

3. Synchronized на корзину:

// Якщо колізія:
synchronized (firstNode) {  // Тільки ця корзина!
    // Вставка/видалення
}
// → Решта корзин працюють паралельно!

Атомарні методи

map.putIfAbsent(key, value);      // Атомарно
map.computeIfAbsent(key, func);   // Атомарно
map.merge(key, value, func);      // Атомарно

// Без цих методів:
// ❌ Race condition!
// Race condition покроково:
// Потік 1: containsKey = false (ключа немає)
// Потік 2: containsKey = false (ключа все ще немає)
// Потік 1: put(key, value1)  ← вставив
// Потік 2: put(key, value2)  ← ПЕРЕЗАПИСАВ! Втрата оновлення.
if (!map.containsKey(key)) {
    map.put(key, value);  // Інший потік може вклинитися!
}

🔴 Senior Level

Коли НЕ використовувати ConcurrentHashMap

  1. Однопоточний доступ — оверхед CAS/volatile без причини
  2. Read-only мапа після публікації — достатньо HashMap (після safe publication)
  3. Потрібні null значення — CHM забороняє null ключі і значення

ForwardingNode і паралельний Resizing

// При ресайзі:
// Стара корзина → ForwardingNode

// Потік-писар бачить ForwardingNode:
if (node instanceof ForwardingNode) {
    // Допомагає ресайзити!
    table = helpTransfer(table, forwardingNode);
}

 Ресайз розподілений, не блокує всі потоки!

CounterCells (LongAdder патерн)

LongAdder — стратегія: замість однієї volatile-змінної (contention при конкурентному записі), масив лічильників. Кожен потік пише у свою комірку, результат = сума всіх комірок.

// Замість одного volatile size:
transient volatile CounterCell[] counterCells;

// Кожен потік:
counterCells[hash(threadId) & mask].value++;

// size() = sum(counterCells)

 Мінімальний contention!

Treeification захист

> 8 елементів в корзині → червоно-чорне дерево
→ Навіть при Hash DoS атаці: O(log n)

// Node → TreeNode заміна
// Синхронізація все ще на HEAD

ReservationNode

ReservationNode = placeholder-вузол, що блокує корзину поки обчислюється значення в computeIfAbsent. Запобігає дублікатам від інших потоків.

// computeIfAbsent для пустої корзини:
// Вставляє ReservationNode → синхронізується на ньому
// → Запобігає дублікатам поки функція обчислюється

Contention діагностика

// Якщо CHM гальмує:
// 1. async-profiler → blocked стан
// 2. Багато потоків на CHM.put
// 3. → Всі в одну корзину!

// Причина: поганий hashCode()
// → Усі ключі хешуються в один bucket
// → Synchronized на одну корзину
// → 0 паралелізму!

// Рішення: переписати hashCode()

Java 7 vs Java 8+

Java 7: Segment[] segments (16 фіксовано)
  → Блокування на сегмент
  → size() = блокування ВСІХ сегментів

Java 8+: Node-level locking
  → Блокування на корзину
  → CAS для пустих корзин
  → size() = сума CounterCells
  → Parallel resizing

Best Practices

  1. CAS = lock-free для нових ключів
  2. Volatile = видимість без блокувань
  3. Synchronized = тільки на корзину
  4. helpTransfer = паралельний ресайз
  5. CounterCells = масштабований size()
  6. Bad hashCode = головний ворог
  7. compute = не блокуйте надовго

Резюме для Senior

  • CAS для пустих корзин, Synchronized для колізій
  • Volatile = lock-free читання
  • ForwardingNode = паралельний ресайз
  • CounterCells = LongAdder патерн для size
  • Treeification = захист від DoS
  • Bad hashCode = contention = 0 паралелізму
  • helpTransfer = потоки допомагають ресайзити

🎯 Шпаргалка для інтерв’ю

Обов’язково знати:

  • Три механізми thread-safety: CAS (lock-free вставка в пусту корзину), volatile (видимість між потоками), synchronized (захист при колізіях на рівні корзини)
  • CAS = атомарна процесорна інструкція: «запиши нове, тільки якщо поточне дорівнює очікуваному». Два потоки пишуть одночасно — один succeeds, інший retry.
  • Volatile поля: table, Node.val, Node.next — get() без блокувань, happens-before гарантія
  • Synchronized тільки на HEAD корзини — решта корзин працюють паралельно
  • Атомарні методи: putIfAbsent, computeIfAbsent, merge — замінюють check-then-act (race condition)
  • ForwardingNode при ресайзі — потоки-писарі бачать його і викликають helpTransfer() (паралельний ресайз)
  • CounterCells = LongAdder патерн: масив лічильників, кожен потік пише у свою комірку, size() = сума
  • Treeification при > 8 елементів в корзині — захист від Hash DoS, O(log n) навіть при атаці
  • ReservationNode в computeIfAbsent — placeholder запобігає дублікатам від інших потоків

Часті уточнюючі запитання:

  • Чому не можна замінити CHM на HashMap у багатопотоковому коді? — Race condition: два потоки одночасно роблять containsKey = false, обидва роблять put — один перезаписує іншого. Втрата оновлень.
  • Що таке race condition в контексті Map? — Потік 1 перевірив «ключа немає», Потік 2 перевірив «ключа немає», обидва вставили — другий перезаписав перший. Вирішується computeIfAbsent (атомарно).
  • Як діагностувати contention в CHM? — async-profiler показує blocked час. Причина: поганий hashCode() → усі ключі в одну корзину → synchronized на одну корзину → 0 паралелізму.
  • Чим Java 8+ CHM відрізняється від Java 7? — Java 7: Segment[] (16 фіксованих, блокування на сегмент). Java 8+: node-level locking + CAS, паралельний ресайз, CounterCells.

Червоні прапорці (НЕ говорити):

  • ❌ «CHM блокує всю мапу при запису» — тільки одну корзину (synchronized на HEAD)
  • ❌ «get() в CHM використовує synchronized» — ні, lock-free читання через volatile
  • ❌ «CHM використовує Segment locking в Java 8+» — це застаріло, тепер node-level locking
  • ❌ «computeIfAbsent можна замінити на containsKey + put» — ні, це не атомарно, race condition

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

  • [[18. Що таке ConcurrentHashMap]]
  • [[12. Як працює HashSet всередині]]
  • [[14. Що таке Map і які реалізації існують]]
  • [[20. Що таке CopyOnWriteArrayList]]