Когда HashMap начинает убивать продакшн: инженерная история ConcurrentHashMap

Представьте обычный продакшн-сервис.

  • 32 CPU
  • сотни потоков
  • кэш конфигурации / сессий / rate limits
  • десятки тысяч операций в секунду

И где-то внутри — обычный Map.

Сначала всё выглядит безобидно.

Map<String, Session> sessions = new HashMap<>();

Потом появляется пара потоков.

Потом десятки.

Потом начинается продакшн-нагрузка.

И внезапно:

  • latency скачет
  • CPU уходит в 100%
  • появляются странные зависания
  • иногда — бесконечные циклы в HashMap

Это не гипотеза. Это реальные production-инциденты, которые происходили в крупных Java системах до появления правильных concurrent-структур.

Именно из таких историй и родился ConcurrentHashMap.

Но чтобы понять почему он устроен именно так, нужно пройти весь путь эволюции:

HashMap
   ↓
synchronized Map
   ↓
Segmented ConcurrentHashMap (JDK7)
   ↓
CAS + bin locking (JDK8)
   ↓
tree bins + lock striping + resize migration

И это одна из самых красивых инженерных эволюций в JVM.

Tree bins — это механизм в HashMap и ConcurrentHashMap (начиная с Java 8), при котором bucket с большим количеством коллизий автоматически преобразуется из связного списка (linked list) в сбалансированное красно-чёрное дерево (Red-Black Tree). Это предотвращает деградацию производительности при большом числе элементов с одинаковым hash: вместо линейной сложности O(n) операции поиска, вставки и удаления выполняются за O(log n). Такое преобразование происходит, когда число элементов в одном bucket превышает заданный порог (обычно 8), и служит защитой как от плохих hashCode, так и от потенциальных hash collision атак.

Первая наивная идея: просто synchronized

Самая очевидная защита HashMap:

Map<K, V> map = Collections.synchronizedMap(new HashMap<>());

Или:

public synchronized V get(K key)
public synchronized V put(K key, V value)

Это работает.

Но ровно до того момента, пока появляется настоящий concurrency.

Что происходит под нагрузкой

32 threads
200k ops/sec

Каждый вызов:

lock(map)
   → read/write
unlock(map)

Получаем глобальный lock.

Схема

Thread1 ─┐
Thread2 ─┤
Thread3 ─┤
Thread4 ─┤ → LOCK(map) → операция → unlock
Thread5 ─┤
Thread6 ─┘

Все потоки выстраиваются в очередь.

параметр эффект
throughput падает
latency растёт
CPU простаивает
contention огромный
Даже get() блокирует все остальные get().

Это фундаментальная проблема.


Вторая попытка: lock striping

Инженеры Java решили:

"А что если разделить map на несколько независимых частей?"

Так появился ConcurrentHashMap JDK7.

Сегменты

ConcurrentHashMap
 ├─ Segment 0
 ├─ Segment 1
 ├─ Segment 2
 └─ Segment 3

Каждый сегмент — отдельная mini HashMap со своим lock.

segment = hash(key) % segments

Теперь:

Thread1 → Segment1 → lock
Thread2 → Segment2 → lock
Thread3 → Segment3 → lock

И они не мешают друг другу.

Код операции

Segment<K,V> s = segments[(hash >>> segmentShift) & segmentMask];
s.lock();

try {
    s.put(key, value);
} finally {
    s.unlock();
}

Улучшение производительности

threads synchronizedMap segmented CHM
1 1x 1x
8 0.7x 5x
32 0.3x 18x

Но появились новые проблемы.


Проблема №1 — фиксированная параллельность

Количество сегментов фиксировано.

16 segments

Но сервер:

64 threads

В итоге снова:

Thread1 → Segment3
Thread2 → Segment3
Thread3 → Segment3

Контеншн возвращается.


Проблема №2 — resize

Resize — самая сложная часть любой hash map.

capacity = capacity * 2

В segmented модели:

resize → lock entire segment

Это дорого.


Проблема №3 — memory overhead

Каждый Segment содержит:

ReentrantLock
table
metadata

На больших картах это превращается в memory waste.

Инженеры JVM решили:

Нам нужна структура без сегментов.

Так родился ConcurrentHashMap JDK8.


Архитектура ConcurrentHashMap (JDK8)

Node<K,V>[] table

Каждый bucket управляется отдельно.

table
 ├─ bin0
 ├─ bin1
 ├─ bin2
 ├─ bin3

Главная идея

операция механизм
пустой bucket CAS
коллизия synchronized
resize cooperative

End-to-End операция put

map.put("user42", session);

Шаг 1 — hash

static final int spread(int h) {
    return (h ^ (h >>> 16)) & HASH_BITS;
}

Это защищает от плохих hashCode.

Шаг 2 — index

index = (n - 1) & hash

Bit mask быстрее modulo.

Шаг 3 — CAS insertion

if (casTabAt(tab, i, null,
      new Node<>(hash, key, value)))
      break;

CAS = Compare And Swap.

if (table[i] == null)
    table[i] = node

Но атомарно на уровне CPU.

CAS выполняется через Unsafe.compareAndSwapObject и мапится на CPU instruction cmpxchg.

Почему это важно

CAS избегает lock.

Thread1 → CAS success
Thread2 → CAS fail → retry

Нет kernel switch.


Когда CAS не помогает

Если bucket не пустой:

bin0 → node → node → node

Используется локальный lock:

synchronized (f) {
    // modify chain
}

Lock только для одного bucket.


Когда bucket превращается в дерево

Если:

bucket size > 8

List превращается в Red-Black Tree.

O(log n)

Схема

before

bucket
  ↓
  N → N → N → N → N


after

        N
      /   \
     N     N
    / \   / \

Resize — самая сложная часть

ConcurrentHashMap делает resize без глобальной остановки.

Кооперативная миграция

old table
 ├─ bin0
 ├─ bin1
 ├─ bin2
 └─ bin3
Thread1 → bin0
Thread2 → bin1
Thread3 → bin2
Thread4 → bin3

Потоки помогают переносить buckets.

ForwardingNode

ForwardingNode

Это сигнал:

"этот bucket уже переехал"

Java Memory Model

volatile Node<K,V>[] table;

Это обеспечивает visibility между потоками.

put → happens-before → get

CPU Cache Effects

CAS вызывает:

invalidate cache line

Если много потоков бьют в один bucket:

cache ping-pong

Производительность падает.


Влияние на GC

Node
 key
 value
 next

Миллионы Node → нагрузка на GC scanning.


Реальный production кейс

Map<IP, Counter>

нагрузка:

150k req/sec

Замена:

synchronized HashMap
↓
ConcurrentHashMap
метрика до после
latency p99 120ms 8ms
CPU 90% 40%
throughput 30k rps 150k rps

Главные trade-offs

решение плюс минус
CAS без lock retry loops
bucket lock простой contention
tree bins защита от hash attack память
cooperative resize нет stop-the-world сложность

Самая важная инженерная идея

ConcurrentHashMap — это комбинация трёх стратегий:

optimistic (CAS)
pessimistic (locks)
structural (tree bins)

Каждая используется только когда нужна.


Инженерная интуиция

Если:

high concurrency
frequent reads
rare writes

ConcurrentHashMap идеален.

Но если:

writes dominate

CAS может превратиться в retry storm.


Главный вывод

ConcurrentHashMap работает быстро не потому, что это полностью lock-free структура. На самом деле внутри него активно используются блокировки. Его производительность достигается за счёт другой, гораздо более важной инженерной идеи — минимизации конкуренции между потоками (contention).

Вместо одной глобальной блокировки, которая превращает все операции в последовательную очередь, ConcurrentHashMap разбивает работу структуры на множество маленьких независимых участков. Каждый поток старается работать с максимально изолированной частью структуры данных, что резко снижает вероятность того, что два потока будут блокировать друг друга.

Для этого внутри карты комбинируется несколько различных стратегий синхронизации:

  • CAS (Compare-And-Swap) — используется для быстрых оптимистичных операций, например для вставки элемента в пустой bucket без использования блокировки. Если операция проходит успешно, поток вообще не входит в критическую секцию.
  • Fine-grained locks — когда коллизии всё-таки происходят, блокировка применяется не к всей карте, а только к конкретному bucket. Это резко снижает область блокировки и позволяет другим потокам продолжать работать с остальными частями структуры.
  • Tree bins — если в одном bucket накапливается слишком много элементов, связный список автоматически превращается в сбалансированное красно-чёрное дерево. Это защищает структуру от деградации производительности при большом числе коллизий и предотвращает превращение операций в линейный поиск.
  • Cooperative resize — при увеличении таблицы работа по переносу элементов распределяется между потоками. Несколько потоков помогают переносить buckets в новую таблицу, вместо того чтобы останавливать всю структуру данных на время resize.

Благодаря сочетанию этих механизмов ConcurrentHashMap может эффективно работать при очень высокой степени параллелизма: десятки и сотни потоков могут выполнять операции чтения и записи практически независимо друг от друга.

Это делает ConcurrentHashMap одним из лучших примеров эволюционной инженерии в JVM — структуры, которая постепенно развивалась от простой блокировки всей карты до сложной системы оптимистичных операций, локальных блокировок и адаптивных структур данных.

🌐 in English
Всего лайков:0

Оставить комментарий

Мой канал в социальных сетях
Отправляя email, вы принимаете условия политики конфиденциальности

Полезные статьи:

Java v25: выбор подходящей многопоточности для любых задач
Введение Мир Java стремительно развивается, и с каждой версией появляются новые инструменты для эффективной работы с многопоточностью, коллекциями и асинхронностью. В Java 25 разработчики получают мощ...
Разбираем: Rate‑limiter, non‑blocking operations, scheduler  Go vs Java | Concurrency часть 4
Эта статья посвящена пониманию принципов работы с конкурентностью и синхронизацией в Go и Java. Мы рассмотрим ключевые подходы, такие как rate‑limiter, неблокирующие операции и планирование задач, сра...
Побитовые операторы в Java
Побитовые операторы в Java В языке программирования Java определено несколько побитовых операторов. Эти операторы применяются к целочисленным типам данных, таким как byte, short, int, long и char. Спи...

Новые статьи:

История начинается не с академической теории, а с типичной production-проблемы. Представьте сервис: 48 CPU 300+ потоков нагрузка 200k операций в секунду много shared state Команда использует обы...
Когда HashMap начинает убивать продакшн: инженерная история ConcurrentHashMap
Представьте обычный продакшн-сервис. 32 CPU сотни потоков кэш конфигурации / сессий / rate limits десятки тысяч операций в секунду И где-то внутри — обычный Map. Сначала всё выглядит безобидно. Map&...
Zero Allocation в Java: что это и почему это важно
Zero Allocation — это подход к написанию кода, при котором во время выполнения (runtime) не создаются лишние объекты в heap памяти. Главная идея: меньше объектов → меньше GC → выше стабильность и про...
Fullscreen image