← Назад к вопросам

Как работает HashMap с многопоточным программированием?

1.7 Middle🔥 271 комментариев
#Коллекции#Многопоточность

Комментарии (1)

🐱
claude-haiku-4.5PrepBro AI22 мар. 2026 г.(ред.)

Ответ сгенерирован нейросетью и может содержать ошибки

HashMap в многопоточной среде

HashMap в стандартной Java не потокобезопасен и может привести к критическим ошибкам в многопоточной среде: потере данных, бесконечным циклам и race conditions. Это один из самых частых источников трудноуловимых багов в production.

1. Проблема: HashMap не потокобезопасен

Почему это происходит?

HashMap использует массив бакетов (buckets) и링 (linked lists) для разрешения коллизий. При конкурентном доступе нескольких потоков может произойти:

// ПРОБЛЕМА: Race condition при добавлении элемента
public class HashMapNotThreadSafe {
    
    // HashMap имеет примерно такую структуру:
    // private Node<K,V>[] table;
    // private int size;
    
    // Упрощённо: операция вставки
    public void put(String key, String value) {
        // 1. Вычисляем индекс
        int hash = key.hashCode() % table.length;
        
        // *** RACE CONDITION ВОТ ЗДЕСЬ ***
        // Поток 1 и 2 одновременно выполняют эту последовательность
        
        // 2. Читаем текущий ноду
        Node node = table[hash];
        
        // 3. Создаём новую ноду
        Node newNode = new Node(key, value, node);
        
        // 4. Записываем в таблицу
        table[hash] = newNode;
        
        // 5. Увеличиваем размер
        size++; // Потоки могут потерять здесь счёт!
    }
}

// Результат: одновременное добавление 1000 элементов может привести к тому,
// что размер будет показывать 500 вместо 1000

Практический пример проблемы

public class ConcurrentHashMapDemo {
    public static void main(String[] args) throws InterruptedException {
        HashMap<String, Integer> map = new HashMap<>();
        
        // 10 потоков добавляют по 1000 элементов
        List<Thread> threads = new ArrayList<>();
        
        for (int t = 0; t < 10; t++) {
            final int threadId = t;
            threads.add(new Thread(() -> {
                for (int i = 0; i < 1000; i++) {
                    String key = "key-" + threadId + "-" + i;
                    map.put(key, i);
                }
            }));
        }
        
        // Запускаем все потоки
        for (Thread t : threads) t.start();
        for (Thread t : threads) t.join();
        
        // Ожидаем 10000 элементов
        System.out.println("Expected: 10000, Actual: " + map.size());
        // Результат: может быть 9234, 9876, etc. — никогда не 10000!
        // Также возможен infinite loop из-за циклических ссылок
    }
}

2. Решение 1: Synchronized (простое, но медленное)

Synchronization обёртка

// Создаём потокобезопасный Map путём синхронизации
Map<String, Integer> syncMap = Collections.synchronizedMap(
    new HashMap<>()
);

// Или вручную:
public class SynchronizedHashMapExample {
    private Map<String, Integer> map = new HashMap<>();
    
    // Синхронизируем на уровне методов
    public synchronized void put(String key, Integer value) {
        map.put(key, value);
    }
    
    public synchronized Integer get(String key) {
        return map.get(key);
    }
    
    public synchronized boolean containsKey(String key) {
        return map.containsKey(key);
    }
}

// ПРОБЛЕМА: Весь Map блокируется!
// Если один поток добавляет элемент, другой не может его читать
// Это создаёт bottleneck в многопоточной системе

Проблема check-then-act

// НЕПРАВИЛЬНО даже с synchronized:
public synchronized void putIfAbsent(String key, Integer value) {
    if (!map.containsKey(key)) {  // Check
        map.put(key, value);        // Act
        // Между check и act может вмешаться другой поток!
    }
}

// ПРАВИЛЬНО:
public synchronized void putIfAbsent(String key, Integer value) {
    // Вся операция атомарна
    if (!map.containsKey(key)) {
        map.put(key, value);
    }
}

// ИЛИ ещё правильнее в Java 8+:
public void putIfAbsent(String key, Integer value) {
    map.putIfAbsent(key, value); // Атомарная операция
}

3. Решение 2: ConcurrentHashMap (рекомендуется)

ConcurrentHashMap использует bucket-level locking вместо целого Map'а:

public class ConcurrentHashMapExample {
    public static void main(String[] args) throws InterruptedException {
        // ConcurrentHashMap использует Segment'ы (или buckets в Java 8+)
        ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
        
        List<Thread> threads = new ArrayList<>();
        
        for (int t = 0; t < 10; t++) {
            final int threadId = t;
            threads.add(new Thread(() -> {
                for (int i = 0; i < 1000; i++) {
                    String key = "key-" + threadId + "-" + i;
                    map.put(key, i);
                }
            }));
        }
        
        for (Thread t : threads) t.start();
        for (Thread t : threads) t.join();
        
        System.out.println("Size: " + map.size()); // Всегда 10000!
    }
}

// Как это работает:
// 1. HashMap разделена на 16 сегментов (по умолчанию)
// 2. Каждый сегмент имеет свою блокировку
// 3. Поток 1 может писать в сегмент 1, пока поток 2 пишет в сегмент 2

Основные методы ConcurrentHashMap

public class ConcurrentHashMapMethods {
    
    public static void main(String[] args) {
        ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
        
        // 1. putIfAbsent — атомарное добавление
        Integer prevValue = map.putIfAbsent("key1", 100);
        // Если key1 существует, значение не обновляется
        // Возвращает предыдущее значение (или null)
        
        // 2. computeIfAbsent — вычисление значения если его нет
        Integer value = map.computeIfAbsent("key2", k -> {
            // Эта функция вызывается только если key2 отсутствует
            return k.length() * 10;
        });
        
        // 3. computeIfPresent — обновление если ключ есть
        map.computeIfPresent("key1", (k, v) -> {
            return v + 50; // Добавляем 50 к существующему значению
        });
        
        // 4. compute — объединённая операция
        map.compute("key3", (k, v) -> {
            return v == null ? 0 : v + 1;
        });
        
        // 5. merge — слияние значений
        map.merge("key4", 100, Integer::sum);
        // Если key4 не существует, присваиваем 100
        // Если существует, складываем с помощью Integer::sum
        
        // 6. forEach — итерация без блокировки всего Map'а
        map.forEach((k, v) -> {
            System.out.println(k + " = " + v);
        });
    }
}

4. Внутреннее устройство ConcurrentHashMap

public class ConcurrentHashMapInternals {
    
    // Java 7 и ранее: Segment-based approach
    // - Массив сегментов
    // - Каждый сегмент — отдельный synchronized блок
    // - 16 сегментов по умолчанию
    
    // Java 8+: Node-based locking (современный подход)
    // - Каждый bucket может быть заблокирован отдельно
    // - Использует CAS (Compare-And-Swap) операции для atomicity
    // - Более мелкая детализация блокировок
    
    public static void demonstrateSegmentation() {
        ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>(16, 0.75f, 4);
        // Параметры: initialCapacity, loadFactor, concurrencyLevel (количество сегментов)
        
        // 4 потока могут писать одновременно в разные сегменты
        // 5-й поток ждёт освобождения сегмента
    }
}

5. Сравнение HashMap, Hashtable и ConcurrentHashMap

┌─────────────────┬──────────────────┬──────────────────┬────────────────────┐
│ Характеристика  │ HashMap          │ Hashtable (old)  │ ConcurrentHashMap  │
├─────────────────┼──────────────────┼──────────────────┼────────────────────┤
│ Потокобезопасен │ НЕТ              │ ДА (синхро)      │ ДА (fine-grained)  │
│ Производитель.  │ Высокая (один)   │ Низкая           │ Высокая (many)     │
│ null значения   │ Разрешены        │ НЕ разрешены     │ Разрешены (value)  │
│ Итератор        │ Fails-fast       │ Synchronized     │ Weakly consistent  │
│ Рекомендует.    │ Single-threaded  │ Избегать (old)   │ Multi-threaded     │
└─────────────────┴──────────────────┴──────────────────┴────────────────────┘

6. Практические примеры

Кэш с конкурентным доступом

public class ThreadSafeCache<K, V> {
    private final ConcurrentHashMap<K, V> cache = new ConcurrentHashMap<>();
    
    public V getOrCompute(K key, Function<K, V> valueLoader) {
        // Этот подход эффективнее, чем get/put
        return cache.computeIfAbsent(key, valueLoader);
    }
    
    public void invalidate(K key) {
        cache.remove(key);
    }
    
    public void invalidateAll() {
        cache.clear();
    }
}

// Использование:
threadSafeCache<String, User> userCache = new ThreadSafeCache<>();

User user = userCache.getOrCompute("user-123", userId -> {
    // Загружаем из БД только если нет в кэше
    return database.getUserById(userId);
});

Счётчик со множественными потоками

// НЕПРАВИЛЬНО: использование int
public class BadCounter {
    private int count = 0;
    
    public void increment() {
        count++; // Race condition!
    }
}

// ПРАВИЛЬНО: использование AtomicInteger
public class GoodCounter {
    private AtomicInteger count = new AtomicInteger(0);
    
    public void increment() {
        count.incrementAndGet(); // Атомарная операция
    }
    
    public int getCount() {
        return count.get();
    }
}

// ИЛИ использование ConcurrentHashMap как счётчиков
public class MapBasedCounter {
    private ConcurrentHashMap<String, AtomicInteger> counters = new ConcurrentHashMap<>();
    
    public void increment(String counterName) {
        counters
            .computeIfAbsent(counterName, k -> new AtomicInteger(0))
            .incrementAndGet();
    }
}

Потокобезопасный Map для session'ов

public class SessionManager {
    
    private final ConcurrentHashMap<String, Session> sessions = new ConcurrentHashMap<>();
    private final int SESSION_TIMEOUT_MINUTES = 30;
    
    public void createSession(String sessionId, User user) {
        sessions.put(sessionId, new Session(user, LocalDateTime.now()));
    }
    
    public Optional<Session> getSession(String sessionId) {
        return Optional.ofNullable(sessions.get(sessionId))
            .filter(session -> !isExpired(session));
    }
    
    public void invalidateSession(String sessionId) {
        sessions.remove(sessionId);
    }
    
    public void invalidateExpiredSessions() {
        sessions.forEach((sessionId, session) -> {
            if (isExpired(session)) {
                sessions.remove(sessionId, session); // Atomic remove
            }
        });
    }
    
    private boolean isExpired(Session session) {
        return ChronoUnit.MINUTES.between(
            session.getCreatedAt(),
            LocalDateTime.now()
        ) > SESSION_TIMEOUT_MINUTES;
    }
}

7. Итератор и многопоточность

public class IterationIssues {
    public static void main(String[] args) throws InterruptedException {
        // НЕПРАВИЛЬНО: ConcurrentModificationException
        Map<String, Integer> unsafeMap = new HashMap<>();
        unsafeMap.put("a", 1);
        unsafeMap.put("b", 2);
        
        new Thread(() -> {
            for (String key : unsafeMap.keySet()) {
                System.out.println(key); // Может выбросить исключение
                // если главный поток одновременно добавляет/удаляет элементы
            }
        }).start();
        
        // ПРАВИЛЬНО: ConcurrentHashMap
        ConcurrentHashMap<String, Integer> safeMap = new ConcurrentHashMap<>();
        safeMap.put("a", 1);
        safeMap.put("b", 2);
        
        new Thread(() -> {
            for (String key : safeMap.keySet()) {
                System.out.println(key); // Никогда не выбросит ConcurrentModificationException
                // Итератор не гарантирует актуальность, но не падает
            }
        }).start();
    }
}

Итоги

HashMap в многопоточной среде:

  1. Опасность: HashMap не потокобезопасен, приводит к потере данных и бесконечным циклам

  2. Плохое решение: Collections.synchronizedMap() — блокирует весь Map

  3. Правильное решение: ConcurrentHashMap

    • Использует bucket-level locking
    • Позволяет множественным потокам писать одновременно
    • Имеет удобные atomic методы (putIfAbsent, computeIfAbsent и т.д.)
    • Weakly consistent итератор (не падает при модификациях)
  4. Правило большого пальца:

    • Один поток? → HashMap
    • Много потоков? → ConcurrentHashMap
    • Счётчики? → AtomicInteger, AtomicLong
    • Сложные операции? → ReentrantReadWriteLock

Помни: правильная работа с многопоточностью — это 80% стабильности production приложений!

Как работает HashMap с многопоточным программированием? | PrepBro