Вопрос 23 · Раздел 10

Что такое ConcurrentHashMap и чем он отличается от HashMap?

В многопоточной среде get(key) = null неоднозначно:

Версии по языкам: English Russian Ukrainian

🟢 Junior Level

ConcurrentHashMap — это потокобезопасная версия HashMap. Несколько потоков могут одновременно читать и записывать данные без проблем.

Главные отличия:

HashMap ConcurrentHashMap
Не потокобезопасна Потокобезопасна
Быстрее в одном потоке Чуть медленнее, но безопасна
Разрешает null ключи/значения Запрещает null
Fail-fast итератор Weakly Consistent итератор (не бросает CME)

Пример:

// HashMap — упадёт в многопоточной среде
Map<String, Integer> unsafe = new HashMap<>();

// ConcurrentHashMap — безопасно
ConcurrentMap<String, Integer> safe = new ConcurrentHashMap<>();
safe.put("key1", 100);
safe.putIfAbsent("key2", 200); // Атомарная операция

Когда использовать: Когда к карте обращаются несколько потоков одновременно.

🟡 Middle Level

Архитектура ConcurrentHashMap (Java 8+)

Характеристика HashMap ConcurrentHashMap
Потокобезопасность Нет Да
Блокировки Нет Побакетные (synchronized на голове бакета)
Null-ключи/значения Разрешены Запрещены (NPE)
Итератор Fail-fast Weakly Consistent (snapshot на момент создания)
Атомарные операции Нет putIfAbsent, computeIfAbsent, merge

Как работает потокобезопасность

  1. CAS (Compare-And-Swap) — для пустого бакета блокировка не нужна
  2. synchronized на голове бакета — только при коллизиях
  3. volatile поля — гарантии видимости между потоками
// CAS для пустого бакета:
if (casTabAt(tab, i, null, newNode))
    break; // Успешно, без блокировки!

// synchronized при коллизии:
synchronized (f) {
    // Добавление в список/дерево
}

Почему запрещён null?

В многопоточной среде get(key) = null неоднозначно:

  • Ключа нет? Или значение = null?
  • Нельзя атомарно проверить containsKey() после get() — состояние может измениться

Атомарные операции

map.putIfAbsent("key", 100);     // Проверка + вставка = атомарно
map.computeIfAbsent("key", k -> computeExpensive(k)); // Ленивое вычисление
map.merge("key", 1, Integer::sum); // Атомарное обновление

Итерация: Weakly Consistent

for (Map.Entry<String, Integer> e : map.entrySet()) {
    // Не бросит ConcurrentModificationException
    // Может не увидеть изменения, сделанные после создания итератора
}

🔴 Senior Level

Internal Implementation (Java 8+)

transient volatile Node<K,V>[] table;

Ключевые отличия от HashMap:

  • volatile массив — гарантии видимости
  • volatile поля Node (val, next) — happens-before
  • ForwardingNode при resize — сигнализирует о прогрессе

Multi-threaded Resize

// При resize несколько потоков могут помогать:
private transient volatile int transferIndex;

Потоки координируются через transferIndex, каждый обрабатывает свой диапазон бакетов. Это ускоряет resize в высоконагруженных системах.

Lock Striping vs Node-level Locking

Подход Java 7 (Segment) Java 8+ (Node)
Гранулярность 16 сегментов Каждый бакет
Конкуренция До 16 потоков До capacity потоков
Overhead Меньше Минимальный

Memory Ordering

ConcurrentHashMap использует full memory barrier через volatile:

  • Запись в val видна всем потокам
  • Happens-before между put и get из разных потоков
  • Никаких stale reads

Why No Null?

Liveness Problem в многопоточности:

// Потенциальный сценарий без запрета null:
if (map.get(key) == null) {      // null или нет ключа?
    if (!map.containsKey(key)) { // Ключа нет... или он удалён между вызовами?
        map.put(key, value);     // TOCTOU race!
    }
}

Benchmark

Метрика HashMap ConcurrentHashMap
get (1 thread) 5 ns 8 ns
get (8 threads) N/A (race) 60 ns
put (1 thread) 8 ns 15 ns
put (8 threads) N/A (race) 120 ns

Overhead ~60% на single-thread, но на 8 threads — единственный корректный вариант.

Production Best Practices

  1. Default choice для многопоточности — ConcurrentHashMap
  2. Атомарные операцииcompute, merge вместо check-then-act
  3. size() в Java 8+ использует систему CounterCell и работает за O(1) в обычном случае. Результат точный, но может устареть к моменту использования — не используйте в hot path.
  4. Bulk operationsforEach, search, reduce (Java 8+) с parallelism threshold

🎯 Шпаргалка для интервью

Обязательно знать:

  • ConcurrentHashMap — потокобезопасна через CAS + synchronized на голове бакета
  • Запрещает null-ключи/значения — устраняет ambiguity в check-then-act
  • Weakly Consistent итератор — snapshot на момент создания, не бросает CME
  • Атомарные операции: putIfAbsent, computeIfAbsent, merge — заменяют check-then-act
  • Java 8+: node-level locking (ранее Java 7: segment-level, 16 сегментов)
  • size() через CounterCell: O(1), точный но может устареть
  • Multi-threaded resize: потоки помогают через transferIndex

Частые уточняющие вопросы:

  • Почему запрещён null? — get=null неоднозначно (нет ключа или значение=null?), ломает putIfAbsent
  • Чем Weakly Consistent отличается от Fail-safe? — Weakly Consistent: видит изменения после создания, но не гарантирует полноту
  • Почему не synchronizedMap? — один мьютекс на всё; CHM = конкурентный доступ к разным бакетам
  • Overhead vs HashMap? — ~60% на single-thread, но единственный корректный вариант для multi-thread

Красные флаги (НЕ говорить):

  • «ConcurrentHashMap использует volatile поля Node» — нет, CAS + memory barriers
  • «Fail-safe итератор» — правильно: Weakly Consistent
  • «size() = O(n)» — нет, в Java 8+ через CounterCell = O(1)

Связанные темы:

  • [[22. Как работает HashMap в многопоточной среде]]
  • [[25. Можно ли хранить null значение в HashMap]]
  • [[26. В чём разница между HashMap и Hashtable]]