JavaRush /Курсы /JAVA 25 SELF /Thread-safe коллекции: ConcurrentHashMap и другие

Thread-safe коллекции: ConcurrentHashMap и другие

JAVA 25 SELF
53 уровень , 2 лекция
Открыта

1. Почему обычные коллекции не подходят для многопоточности

Давайте вспомним, как мы работали с коллекциями в нашем основном приложении (например, чат-комнатой):

List<String> messages = new ArrayList<>();
messages.add("Привет!");
messages.add("Как дела?");

В однопоточной программе всё отлично. Но если несколько потоков одновременно будут добавлять, удалять или читать элементы из одной и той же коллекции — добро пожаловать в мир гонок данных (race conditions), неконсистентного состояния и загадочных багов.

Например, один поток добавляет элемент, другой удаляет, третий итерирует — и внезапно получаем ConcurrentModificationException, а иногда даже ArrayIndexOutOfBoundsException или просто «битую» коллекцию.

Классика жанра:

List<String> list = new ArrayList<>();
Runnable writer = () -> {
    for (int i = 0; i < 1000; i++) {
        list.add("msg-" + i);
    }
};
Runnable reader = () -> {
    for (String msg : list) {
        // ...
    }
};
// Запускаем writer и reader в разных потоках — получите баги!

Вывод: Обычные коллекции (ArrayList, HashMap, HashSet и др.) НЕ thread-safe. Их нельзя использовать из нескольких потоков без дополнительной синхронизации (synchronized, блокировки и т.п.).

2. Какие бывают thread-safe коллекции в Java

Java не бросает вас на произвол судьбы. Для многопоточных задач в пакете java.util.concurrent есть целая коллекция коллекций (простите за тавтологию), которые можно безопасно использовать из нескольких потоков.

Основные thread-safe коллекции:

Коллекция Где использовать Особенности
ConcurrentHashMap
Map, кэш, частый доступ Высокая производительность, нет глобального lock
CopyOnWriteArrayList
List, редко меняется, часто читается Быстрые чтения, медленные изменения
CopyOnWriteArraySet
Set, редко меняется, часто читается Аналогично списку на Copy-On-Write
ConcurrentLinkedQueue
Очередь, FIFO Быстро, неблокирующе, очереди задач
ConcurrentSkipListMap
Map с сортировкой (NavigableMap) Потокобезопасный аналог TreeMap
ConcurrentSkipListSet
Set с сортировкой Потокобезопасный аналог TreeSet
BlockingQueue
Очереди с блокировкой (пулы потоков) Интерфейс, много реализаций

Важно! Старый-добрый Collections.synchronizedList(list) и подобные — это не совсем то же самое, что современные коллекции из java.util.concurrent. Подробнее — чуть ниже.

3. ConcurrentHashMap: ваш друг в мире многопоточности

ConcurrentHashMap<K, V> — это, по сути, тот же HashMap, только прокачанный для многопоточности. Он позволяет нескольким потокам одновременно безопасно читать и записывать данные, не блокируя всю карту целиком.

В обычном HashMap, если хочется сделать доступ потокобезопасным, приходится ставить замок на всю структуру — и она тут же превращается в «бутылочное горлышко»: пока один поток работает, остальные ждут.

ConcurrentHashMap решает эту проблему умнее. В ранних версиях карта делилась на сегменты с отдельными блокировками, в новых реализациях задействуются лёгкие атомарные операции (CAS) на уровне отдельных бакетов. Благодаря этому потоки могут спокойно работать параллельно, если не трогают одни и те же данные.

Пример использования ConcurrentHashMap

import java.util.concurrent.ConcurrentHashMap;

public class ChatStats {
    private final ConcurrentHashMap<String, Integer> userMessageCount = new ConcurrentHashMap<>();

    public void increment(String user) {
        // Атомарно увеличиваем значение
        userMessageCount.merge(user, 1, Integer::sum);
    }

    public int getCount(String user) {
        return userMessageCount.getOrDefault(user, 0);
    }
}

Что здесь важно:

  • Можно вызывать методы из разных потоков — всё будет корректно.
  • Метод merge атомарен: если несколько потоков одновременно увеличивают счётчик, результат будет правильным.
  • Для чтения не нужна дополнительная синхронизация.

Чем ConcurrentHashMap лучше synchronizedMap?

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

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

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

4. CopyOnWriteArrayList и CopyOnWriteArraySet

CopyOnWriteArrayList и CopyOnWriteArraySet — это особые коллекции, которые при каждом изменении (например, при вызове add() или remove()) создают новую копию всего массива. Зато чтение из них происходит без какой-либо синхронизации и полностью безопасно для потоков.

Представьте, что у вас есть список гостей на вечеринке. Каждый раз, когда кто-то приходит или уходит, вы переписываете список заново и раздаёте всем свежие копии. Немного расточительно, но зато никто не запутается, кто сейчас на месте.

Когда это действительно удобно

  • Чтения происходят часто, а изменения — редко.
  • Классический кейс — список слушателей событий: обработчики добавляются нечасто, зато события приходят постоянно.

Пример: слушатели чата

import java.util.concurrent.CopyOnWriteArrayList;

public class ChatRoom {
    private final CopyOnWriteArrayList<ChatListener> listeners = new CopyOnWriteArrayList<>();

    public void addListener(ChatListener listener) {
        listeners.add(listener);
    }

    public void removeListener(ChatListener listener) {
        listeners.remove(listener);
    }

    public void sendMessage(String message) {
        // Безопасно для многопоточности, даже если кто-то подписывается/отписывается прямо сейчас
        for (ChatListener listener : listeners) {
            listener.onMessage(message);
        }
    }
}

Важные особенности:

  • Итерация по CopyOnWriteArrayList никогда не выбросит ConcurrentModificationException.
  • Изменения (add/remove) дорогие по времени и памяти (копируется весь массив!).
  • Не стоит использовать для больших коллекций с частыми изменениями.

5. Другие thread-safe коллекции

ConcurrentLinkedQueue

ConcurrentLinkedQueue — это неблокирующая очередь, работающая по принципу FIFO. Она позволяет нескольким потокам одновременно безопасно добавлять и забирать элементы без использования явных блокировок. Часто применяется для передачи задач между потоками — быстро и без «затыков».

import java.util.concurrent.ConcurrentLinkedQueue;

ConcurrentLinkedQueue<String> queue = new ConcurrentLinkedQueue<>();
queue.add("task1");
String task = queue.poll(); // вернёт null, если очередь пуста

ConcurrentSkipListMap и ConcurrentSkipListSet

  • Потокобезопасные аналоги TreeMap и TreeSet.
  • Элементы всегда отсортированы.
  • Используются, когда важно поддерживать порядок ключей.
import java.util.concurrent.ConcurrentSkipListMap;

ConcurrentSkipListMap<Integer, String> sortedMap = new ConcurrentSkipListMap<>();
sortedMap.put(10, "a");
sortedMap.put(2, "b");
System.out.println(sortedMap.firstEntry()); // 2=b

BlockingQueue и его реализации

  • Интерфейс очереди, поддерживающей операции блокировки (ждать, пока появится/освободится место).
  • Реализации: ArrayBlockingQueue, LinkedBlockingQueue, PriorityBlockingQueue и др.
  • Используются в пулах потоков, для паттерна «продюсер-потребитель».
import java.util.concurrent.ArrayBlockingQueue;

ArrayBlockingQueue<String> blockingQueue = new ArrayBlockingQueue<>(10);
blockingQueue.put("task"); // Блокирует, если очередь заполнена
String t = blockingQueue.take(); // Блокирует, если очередь пуста

6. Примеры: безопасные операции с коллекциями

Пример 1: Потокобезопасный Map для подсчёта сообщений

import java.util.concurrent.ConcurrentHashMap;

ConcurrentHashMap<String, Integer> messageCount = new ConcurrentHashMap<>();

// Поток 1
messageCount.put("Анна", 1);
// Поток 2
messageCount.put("Анна", messageCount.getOrDefault("Анна", 0) + 1); // Не атомарно!

// Правильно (атомарно):
messageCount.merge("Анна", 1, Integer::sum);

Пример 2: Итерация по CopyOnWriteArrayList

import java.util.concurrent.CopyOnWriteArrayList;

CopyOnWriteArrayList<String> users = new CopyOnWriteArrayList<>();
users.add("Антон");
users.add("Мария");

for (String user : users) {
    System.out.println(user);
    users.remove(user); // Не выбросит ConcurrentModificationException!
}
System.out.println(users); // []

Пример 3: Очередь задач между потоками

import java.util.concurrent.ConcurrentLinkedQueue;

ConcurrentLinkedQueue<String> queue = new ConcurrentLinkedQueue<>();

// Поток-производитель
queue.add("task-1");

// Поток-потребитель
String task = queue.poll(); // null, если пусто

7. Полезные нюансы

Когда (и зачем) использовать thread-safe коллекции

Использование thread-safe коллекций оправдано, если:

  • Одна и та же коллекция разделяется между несколькими потоками.
  • Не хочется вручную синхронизировать каждую операцию.
  • Важно избежать гонок данных и ошибок консистентности.

Типичные сценарии:

  • Кэш в многопоточной системе (например, ConcurrentHashMap для хранения сессий пользователей).
  • Очереди задач между потоками (ConcurrentLinkedQueue, BlockingQueue).
  • Списки слушателей событий (CopyOnWriteArrayList).
  • Многопоточная обработка данных (например, MapReduce-стиль).

Ограничения и подводные камни

  • Операции над несколькими элементами не атомарны. Конструкция вида if (!map.containsKey(k)) map.put(k, v) не атомарна. Используйте putIfAbsent, computeIfAbsent, merge.
  • CopyOnWriteArrayList неэффективна при частых изменениях. При больших размерах и частых add/remove накладные расходы растут лавинообразно.
  • Итерация по ConcurrentHashMap «слабая». Обход даёт слабоконсистентный снимок: можно не увидеть часть параллельных изменений.
  • Thread-safe коллекции не решают всех проблем синхронизации. Если логика затрагивает несколько коллекций/переменных сразу, потребуется внешняя синхронизация (synchronized, locks, атомарные классы).

8. Типичные ошибки при работе с thread-safe коллекциями

Ошибка №1: Ожидание магии от thread-safe коллекций. «Раз коллекция thread-safe, можно делать что угодно и не думать о синхронизации». Увы, последовательности из нескольких операций (проверка + добавление) не атомарны. Используйте специализированные методы: putIfAbsent, compute, merge.

Ошибка №2: Использование CopyOnWriteArrayList для больших и часто изменяемых коллекций. Подходит для списков слушателей, но при 10 000+ элементов и частых изменениях получите большие затраты по памяти и времени.

Ошибка №3: ConcurrentModificationException при использовании обычных коллекций. Итерируете по ArrayList или HashMap, а другой поток меняет коллекцию — ловите ConcurrentModificationException. Используйте специализированные коллекции или блокируйте доступ вручную.

Ошибка №4: Забвение про атомарность сложных операций. Если нужно изменить сразу несколько коллекций или выполнить серию связанных действий — thread-safe коллекции не помогут. Применяйте внешнюю синхронизацию или транзакционную логику.

Ошибка №5: Ошибки при итерации по ConcurrentHashMap. Итерация слабоконсистентная: нельзя использовать итератор как «снимок» состояния карты. Для консистентного снимка применяйте копирование данных в отдельную структуру.

1
Задача
JAVA 25 SELF, 53 уровень, 2 лекция
Недоступна
Склад товаров в онлайн-магазине: Создание ConcurrentHashMap 📦
Склад товаров в онлайн-магазине: Создание ConcurrentHashMap 📦
1
Задача
JAVA 25 SELF, 53 уровень, 2 лекция
Недоступна
Список активных игроков: Итерация по CopyOnWriteArrayList 🎮
Список активных игроков: Итерация по CopyOnWriteArrayList 🎮
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ