Как работает HashMap в многопоточной среде?
В Java 7 при параллельном resize элементы списка разворачивались (head insertion). Два потока могли создать циклическую ссылку → бесконечный цикл при get() → 100% CPU.
🟢 Junior Level
HashMap не является потокобезопасной. Если несколько потоков одновременно обращаются к HashMap, могут возникнуть проблемы:
- Потеря данных — один поток может перезаписать результат другого
- Непредсказуемое поведение —
get()может вернуть не то значение - ConcurrentModificationException — при итерации и одновременной модификации
Пример проблемы:
Map<String, Integer> map = new HashMap<>();
// Основной риск — corruption при resize, не при обычной вставке
// Если ключи разные и бакеты разные — потеря маловероятна
// Но при конкурентном resize возможна потеря элементов!
// Хуже: если ключи ОДИНАКОВЫ:
// Поток 1: put("key", 100)
// Поток 2: put("key", 200) → перезаписывает!
Решение: Используйте ConcurrentHashMap для многопоточного доступа.
🟡 Middle Level
Основные риски
| Риск | Описание | Последствия |
|---|---|---|
| Race Condition | Два потока пишут в один бакет | Потеря данных |
| Visibility | Изменения не видны другим потокам | Устаревшие данные |
| Resize | Два потока одновременно расширяют | Потеря данных, corruption |
| Fail-Fast Iterator | Модификация во время итерации | ConcurrentModificationException |
Проблема Java 7: Infinite Loop
В Java 7 при параллельном resize элементы списка разворачивались (head insertion). Два потока могли создать циклическую ссылку → бесконечный цикл при get() → 100% CPU.
В Java 8+ это исправлено через tail insertion, но потеря данных при resize всё ещё возможна.
Как работать безопасно?
| Способ | Механизм | Производительность |
|---|---|---|
ConcurrentHashMap |
CAS + блокировка на уровне бакета | Высокая |
Collections.synchronizedMap() |
Один мьютекс на всё | Низкая |
Hashtable |
Синхронизированные методы | Низкая (устарело) |
Когда HashMap можно использовать в многопоточности?
Только если она заполнена до запуска потоков и используется только для чтения. Важно: нужна safe publication — без volatile/final другой поток может не увидеть записанные данные.
Map<String, Integer> map = new HashMap<>();
map.put("config", 42);
// Запускаем потоки — только read!
Типичные ошибки
- Думать, что fail-fast = безопасность — это only best-effort detection
- Использовать HashMap в Spring Singleton — один инстанс на все запросы
- Проверка-потом-действие без атомарности — TOCTOU race condition
🔴 Senior Level
Internal Race Conditions
// putVal — упрощённый код:
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
Два потока одновременно:
- Оба видят
tab[i] == null - Оба создают
newNode - Оба записывают в
tab[i]— один теряет элемент
Без синхронизации это data race по Java Memory Model.
Visibility Problem
Поля HashMap не volatile. Даже если поток A записал элемент, поток B может не увидеть его в своём CPU cache (L1/L2). Нужен memory barrier.
Resize Race Condition
// Два потока одновременно вызывают resize():
// 1. Оба создают newTab
// 2. Оба копируют элементы
// 3. Оба записывают `table = newTab`
// Результат: один массив теряет элементы
Fail-Fast Mechanism
transient int modCount; // Увеличивается при каждой модификации
// В итераторе:
int expectedModCount = modCount;
public V next() {
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
}
Это не механизм синхронизации, а only debugging aid.
Safe Publication Pattern
HashMap безопасна для чтения после safe publication:
// Через final поле:
private final Map<K, V> map = createAndFillMap();
// Через volatile:
private volatile Map<K, V> map;
// Через AtomicReference:
private final AtomicReference<Map<K, V>> mapRef;
ConcurrentHashMap: Internal Architecture
Java 7: Segment[] (16 сегментов, каждый со своей блокировкой)
Java 8+: Node-level locking (synchronized на голове бакета) + CAS
В Java 8+:
- CAS для пустого бакета (без блокировки)
synchronizedна голове бакета для коллизий- Видимость обеспечивается через Unsafe.compareAndSwap (CAS-операции) и memory barriers, а не через volatile-декларации полей Node.
Production Experience
В Spring-приложениях HashMap как поле @Service — частый баг:
@Service
public class CacheService {
private Map<String, Data> cache = new HashMap<>(); // НЕБЕЗОПАСНО!
}
// Множество HTTP-запросов = множество потоков
Решение: ConcurrentHashMap или Collections.synchronizedMap().
🎯 Шпаргалка для интервью
Обязательно знать:
- HashMap НЕ потокобезопасна: data race, потеря данных, corruption при resize
- Safe publication: заполненная до запуска потоков Map нужна volatile/final для видимости
- Fail-fast iterator (modCount) — это debugging aid, НЕ механизм синхронизации
- Java 7: infinite loop при параллельном resize (head insertion); Java 8+: исправлено (tail insertion)
- Три safe варианта: ConcurrentHashMap (CAS + блокировка бакета), synchronizedMap, Hashtable
- ConcurrentModificationException не гарантирует обнаружение всех гонок
Частые уточняющие вопросы:
- Можно ли читать HashMap из нескольких потоков? — да, если safe publication (final/volatile) и никто не пишет
- Почему Spring @Service с HashMap — баг? — один инстанс на все HTTP-запросы (много потоков)
- Что такое TOCTOU race? — Time-Of-Check-Time-Of-Use: проверка и действие не атомарны
- Видимость без volatile? — другой поток может не увидеть записанные данные (CPU cache)
Красные флаги (НЕ говорить):
- «Fail-fast = потокобезопасность» — нет, только detection
- «HashMap безопасна если ключи разные» — нет, corruption при resize возможна
- «synchronizedMap = быстрое решение» — нет, один мьютекс на всё, низкая производительность
Связанные темы:
- [[21. Когда временная сложность может стать O(n)]]
- [[23. Что такое ConcurrentHashMap и чем он отличается от HashMap]]
- [[19. Что происходит при rehashing]]