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 коллекции:
| Коллекция | Где использовать | Особенности |
|---|---|---|
|
Map, кэш, частый доступ | Высокая производительность, нет глобального lock |
|
List, редко меняется, часто читается | Быстрые чтения, медленные изменения |
|
Set, редко меняется, часто читается | Аналогично списку на Copy-On-Write |
|
Очередь, FIFO | Быстро, неблокирующе, очереди задач |
|
Map с сортировкой (NavigableMap) | Потокобезопасный аналог TreeMap |
|
Set с сортировкой | Потокобезопасный аналог TreeSet |
|
Очереди с блокировкой (пулы потоков) | Интерфейс, много реализаций |
Важно! Старый-добрый 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. Итерация слабоконсистентная: нельзя использовать итератор как «снимок» состояния карты. Для консистентного снимка применяйте копирование данных в отдельную структуру.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ