Питання 22 · Розділ 10

Як HashMap працює в багатопотоковому середовищі?

В Java 7 при паралельному resize елементи списку розгорталися (head insertion). Два потоки могли створити циклічне посилання → нескінченний цикл при get() → 100% CPU.

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

🟢 Junior Level

HashMap не є потокобезпечною. Якщо кілька потоків одночасно звертаються до HashMap, можуть виникнути проблеми:

  1. Втрата даних — один потік може перезаписати результат іншого
  2. Непередбачувана поведінкаget() може повернути не те значення
  3. 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!

Типові помилки

  1. Думати, що fail-fast = безпека — це only best-effort detection
  2. Використовувати HashMap в Spring Singleton — один інстанс на всі запити
  3. Перевірка-потім-дія без атомарності — TOCTOU race condition

🔴 Senior Level

Internal Race Conditions

// putVal — спрощений код:
if ((p = tab[i = (n - 1) & hash]) == null)
    tab[i] = newNode(hash, key, value, null);

Два потоки одночасно:

  1. Обидва бачать tab[i] == null
  2. Обидва створюють newNode
  3. Обидва записують в 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)
  • Три безпечних варіанти: 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]]