Стандартные коллекции Java — HashMap, ArrayList, LinkedList — не рассчитаны на работу из нескольких потоков одновременно. Как только несколько потоков начинают читать и писать в одну коллекцию, программа начинает вести себя непредсказуемо. В Java есть готовые альтернативы, которые решают эту проблему.
Что идёт не так с обычными коллекциями
Представьте: два потока одновременно добавляют элементы в ArrayList. Внутри ArrayList хранит массив и счётчик размера. Если оба потока прочитают счётчик одновременно (допустим, он равен 5), оба запишут новый элемент на позицию 5 — и один элемент потеряется. Счётчик при этом станет 6, хотя реально добавилось два элемента.
У HashMap ситуация ещё опаснее. При добавлении элемента карта иногда перестраивает внутреннюю структуру (rehash). Если два потока попадут на rehash одновременно, можно получить бесконечный цикл при обходе или потерю данных — и это в Java 7 и более ранних версиях приводило к зависанию приложений.
Типичные симптомы:
- потеря записей (добавили элемент, но он не появился);
ArrayIndexOutOfBoundsExceptionилиConcurrentModificationExceptionв неожиданных местах;- программа зависает без видимой причины.
ConcurrentHashMap — потокобезопасная карта
ConcurrentHashMap — основная замена HashMap в многопоточном коде. Внутри она делит карту на сегменты (в Java 8+ это реализовано через блокировки на уровне отдельных «корзин»), поэтому несколько потоков могут работать с разными частями карты одновременно — без глобальной блокировки.
ConcurrentHashMap<String, Integer> counter = new ConcurrentHashMap<>();
// Безопасно из нескольких потоков
counter.put("events", 1);
// Атомарное увеличение счётчика
// compute гарантирует, что проверка и запись — одна неделимая операция
counter.compute("events", (key, value) -> value == null ? 1 : value + 1);
// merge — удобнее для счётчиков
counter.merge("events", 1, Integer::sum);
Ключевое преимущество — методы compute, merge, computeIfAbsent выполняются атомарно: никакой другой поток не вклинится между чтением старого значения и записью нового. Это избавляет от классической ошибки «прочитали, пока не заблокировали»:
// ОПАСНО даже с ConcurrentHashMap:
int old = counter.getOrDefault("x", 0);
counter.put("x", old + 1); // между get и put другой поток уже изменил значение
// ПРАВИЛЬНО:
counter.merge("x", 1, Integer::sum);
null в качестве ключа или значения ConcurrentHashMap не допускает — в отличие от HashMap.
CopyOnWriteArrayList — список для редких записей
CopyOnWriteArrayList решает проблему иначе: при каждой модификации (добавлении, удалении) она создаёт полную копию внутреннего массива. Читающие потоки работают со старой копией и никогда не блокируются.
CopyOnWriteArrayList<String> listeners = new CopyOnWriteArrayList<>();
// Добавление — создаёт копию массива, медленно
listeners.add("listener-1");
listeners.add("listener-2");
// Итерация — абсолютно безопасна, работает со снимком на момент начала обхода
for (String listener : listeners) {
System.out.println(listener);
}
Когда это уместно:
- список читается очень часто, а записи редки (список обработчиков событий, конфигурация);
- важна простота кода, а не максимальная скорость записи.
Когда не уместно — при частых добавлениях/удалениях: каждая запись копирует весь массив, что дорого.
Collections.synchronizedX — обёртки и их ограничения
В Java есть вспомогательные методы Collections.synchronizedList, Collections.synchronizedMap и аналоги. Они оборачивают обычную коллекцию и добавляют synchronized на каждый метод.
List<String> syncList = Collections.synchronizedList(new ArrayList<>());
syncList.add("a"); // защищено
syncList.add("b"); // защищено
Проблема в том, что итерация не защищена. Если один поток обходит список, а другой удаляет элемент — получим ConcurrentModificationException. Придётся вручную блокировать:
synchronized (syncList) { // явная блокировка на весь обход
for (String s : syncList) {
System.out.println(s);
}
}
Это неудобно и легко забыть. Поэтому ConcurrentHashMap и CopyOnWriteArrayList предпочтительнее в новом коде — они проектировались для конкуренции, а не адаптировались.
BlockingQueue — мост между производителем и потребителем
BlockingQueue — особый вид очереди, который блокирует поток, если операция невозможна прямо сейчас:
putблокирует производителя, если очередь заполнена;takeблокирует потребителя, если очередь пуста.
Это готовый примитив для классической схемы производитель — потребитель:
BlockingQueue<String> queue = new ArrayBlockingQueue<>(100); // ёмкость 100 элементов
// Поток-производитель
Runnable producer = () -> {
try {
for (int i = 0; i < 10; i++) {
queue.put("задача-" + i); // ждёт, если очередь полна
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
};
// Поток-потребитель
Runnable consumer = () -> {
try {
while (true) {
String task = queue.take(); // ждёт, если очередь пуста
System.out.println("Обрабатываю: " + task);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
};
new Thread(producer).start();
new Thread(consumer).start();
ArrayBlockingQueue — очередь с фиксированной ёмкостью. Есть и другие реализации: LinkedBlockingQueue (опционально ограниченная), PriorityBlockingQueue (с приоритетом), SynchronousQueue (передаёт элемент напрямую без буфера — производитель ждёт потребителя).
BlockingQueue лежит в основе ExecutorService — именно через неё задачи передаются потокам пула.
Как выбрать нужную коллекцию
| Ситуация | Выбор |
|---|---|
| Конкурентная карта с частыми обновлениями | ConcurrentHashMap |
| Список, который читают часто, а пишут редко | CopyOnWriteArrayList |
| Передача задач между потоками (producer-consumer) | BlockingQueue / ArrayBlockingQueue |
| Быстро обернуть существующую коллекцию (немного конкуренции) | Collections.synchronizedX |
Коротко
- Обычные
HashMapиArrayListне защищены от конкурентного доступа: возможны потеря данных, исключения и зависания. ConcurrentHashMapобеспечивает потокобезопасность без глобальной блокировки; методыcomputeиmergeпозволяют обновлять значения атомарно.CopyOnWriteArrayListкопирует массив при каждой записи — безопасна для чтения, дорога для частых изменений.Collections.synchronizedXзащищает отдельные вызовы, но итерацию нужно синхронизировать вручную.BlockingQueue— готовая основа для схемы производитель — потребитель: блокирует поток вместо того, чтобы падать с ошибкой.
Что почитать дальше
- Атомарные операции и классы
java.util.concurrent.atomic— как избежать блокировок при работе с одиночными значениями. - ExecutorService и пулы потоков — как
BlockingQueueиспользуется внутри пулов потоков. - Состояние гонки — откуда берутся ошибки конкуренции и как их находить.