← назад к разделу

Стандартные коллекции 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 используется внутри пулов потоков.
  • Состояние гонки — откуда берутся ошибки конкуренции и как их находить.