📦 Раздел 4 · Вопрос #21

Когда использовать synchronized collections

Также к этой категории относятся устаревшие классы из Java 1.0 — Vector и Hashtable, в которых ключевое слово synchronized добавлено прямо в сигнатуры методов.


🟢 Junior Level

1. Что такое синхронизированные коллекции

Синхронизированные коллекции (Synchronized Collections) — это обёртки вокруг стандартных коллекций (ArrayList, HashSet, HashMap), в которых каждый метод защищен грубой блокировкой на внутреннем мьютексе (synchronized (mutex)):

List<String> syncList = Collections.synchronizedList(new ArrayList<>());
Map<String, Integer> syncMap = Collections.synchronizedMap(new HashMap<>());
Set<String> syncSet = Collections.synchronizedSet(new HashSet<>());

Также к этой категории относятся устаревшие классы из Java 1.0 — Vector и Hashtable, в которых ключевое слово synchronized добавлено прямо в сигнатуры методов.


2. Главная проблема: сериализация доступа

В синхронизированных коллекциях используется один общий монитор на абсолютно все операции:

  • Если Поток 1 выполняет чтение get(0), Поток 2 не может даже прочитать get(1) и встает в очередь ожидания.
  • На современных многоядерных процессорах это полностью уничтожает параллелизм (пропускная способность падает до скорости одного ядра).

В современном коде Java для многопоточной разработки по умолчанию используются структуры из пакета java.util.concurrent:

// ❌ УСТАРЕЛО (грубая блокировка на чтение и запись):
Map<String, User> map = Collections.synchronizedMap(new HashMap<>());

// ✅ СОВРЕМЕННЫЙ СТАНДАРТ (Lock-Free чтение + гранулярные блокировки):
Map<String, User> map = new ConcurrentHashMap<>();

🟡 Middle Level

1. Ловушка составных операций (Compound Actions)

Потокобезопасность отдельных методов не гарантирует потокобезопасности их комбинации:

// ❌ КРИТИЧЕСКАЯ ОШИБКА: Race Condition при Check-Then-Act!
if (!syncList.contains(user)) {
    // В этот момент поток вытесняется планировщиком!
    // Другой поток успевает проверить и добавить user.
    syncList.add(user); // Дубликат добавлен!
}

// ✅ Единственно верное исправление: внешний блок синхронизации
synchronized (syncList) {
    if (!syncList.contains(user)) {
        syncList.add(user);
    }
}

Методы contains() и add() по отдельности потокобезопасны, но замок освобождается между их вызовами.


2. Обязательная ручная синхронизация при итерации

Метод syncList.iterator() возвращает обычный итератор базовой коллекции. Так как обход коллекции состоит из последовательности вызовов hasNext() и next(), блокировка на время всей итерации автоматически не удерживается:

// ❌ ПРИВЕДЕТ К ConcurrentModificationException В ПРОДЕ:
for (String item : syncList) {
    process(item);
}

// ✅ ТРЕБОВАНИЕ СПЕЦИФИКАЦИИ JDK:
synchronized (syncList) {
    for (String item : syncList) {
        process(item);
    }
}

Если другой поток вызовет syncList.add() во время итерации без внешнего synchronized-блока, первый поток упадет с ошибкой ConcurrentModificationException.


🔴 Senior Level

1. Когда синхронизированные коллекции ВСЁ ЕЩЕ оправданы

Несмотря на доминирование java.util.concurrent, существуют конкретные инженерные сценарии, когда использование Collections.synchronized* является оптимальным выбором:

  1. Необходимость монопольной грубой блокировки всей структуры (Coarse-Grained Atomicity): Если приложению необходимо атомарно выполнить серию сложных преобразований над всем списком (например, очистить, заполнить новыми данными и отсортировать), наличие одного разделяемого мьютекса упрощает архитектуру:
    synchronized (syncList) {
        syncList.clear();
        syncList.addAll(newBatch);
        Collections.sort(syncList);
    }
    

    В ConcurrentHashMap или CopyOnWriteArrayList атомарно заблокировать всю структуру «снаружи» для пакетного обновления невозможно без введения дополнительных внешних замков.

  2. Отсутствие аналогов в java.util.concurrent: В java.util.concurrent нет прямого аналога масштабируемого связанного списка с произвольным доступом по индексу (ConcurrentArrayList). Если в приложении преобладают частые операции записи и чтения по индексу, CopyOnWriteArrayList неприменим ($O(N)$ на запись), а Collections.synchronizedList(new ArrayList<>()) остается единственным стандартным решением из JDK.

  3. Интеграция с устаревшими API (Legacy Libraries): Библиотеки, ожидающие классический List или Map, где требуется гарантировать взаимное исключение потоков без изменения контрактов интерфейсов.

2. Скрытая утечка ссылки (Aliasing / Reference Leak)

Фатальная ошибка при создании синхронизированной коллекции — сохранение и использование ссылки на исходный объект:

List<String> rawList = new ArrayList<>();
List<String> syncList = Collections.synchronizedList(rawList);

// Поток 1 работает через обертку:
syncList.add("one");

// Поток 2 случайно получил ссылку на исходный rawList:
rawList.add("two"); // КАТАСТРОФА: модификация в обход блокировок!

Прямая модификация rawList разрушит внутреннее состояние коллекции и приведет к гонкам данных (Data Races).

[!IMPORTANT] При создании синхронизированных обёрток никогда не сохраняйте ссылку на исходный экземпляр: List<String> list = Collections.synchronizedList(new ArrayList<>());


3. Сравнение архитектурных альтернатив

Сценарий применения Рекомендуемая коллекция Почему не synchronized*?
Ассоциативный массив (Map) ConcurrentHashMap Lock-Free чтение, в сотни раз выше throughput
Множество (Set) ConcurrentHashMap.newKeySet() Масштабируемость без единого мьютекса
Список с частым чтением (Listeners) CopyOnWriteArrayList Чтение без блокировок, snapshot-итераторы
Очередь задач (Producer-Consumer) BlockingQueue / ConcurrentLinkedQueue Встроенное ожидание, отсутствие взаимных блокировок
Список с частыми записями и чтениями Collections.synchronizedList COWAL дает $O(N)$ на вставку, альтернатив в JDK нет

🎯 Шпаргалка для интервью

30-секундный ответ (Elevator Pitch)

«Синхронизированные коллекции (Collections.synchronizedList/Map/Set) оборачивают базовые коллекции, синхронизируя каждый метод через единый мьютекс. Они практически вытеснены классами пакета java.util.concurrent (ConcurrentHashMap, CopyOnWriteArrayList), поскольку создают общее бутылочное горлышко даже для параллельных чтений. Основные сферы применения сегодня: редкие сценарии со списком с частой записью по индексу (где COWAL неприменим из-за копирования), пакетные атомарные операции над всей коллекцией под одним замком и совместимость со старыми библиотеками».


3 каверзных вопроса на интервью с ответами

1. Почему вызов for (String s : Collections.synchronizedList(list)) выбрасывает ConcurrentModificationException, если список синхронизирован?

Ответ: Цикл for-each транслируется компилятором в вызовы Iterator.hasNext() и Iterator.next(). Метод syncList.iterator() потокобезопасно возвращает итератор, но сам монитор освобождается сразу после создания итератора. Если между шагами цикла параллельный поток изменит список, внутренний счетчик модификаций modCount не совпадет с expectedModCount, и итератор выбросит ConcurrentModificationException. Обход синхронизированной коллекции обязан оборачиваться во внешний блок synchronized(syncList).

2. В чём разница между Vector и Collections.synchronizedList(new ArrayList<>())?

Ответ:

  1. Vector синхронизирует методы на собственном экземпляре (this), в то время как synchronizedList синхронизируется на выделенном объекте mutex (позволяя передавать собственный мьютекс в конструкторе).
  2. Vector увеличивает массив при переполнении в 2 раза (или на величину capacityIncrement), а ArrayList внутри обёртки увеличивается в 1.5 раза.
  3. synchronizedList реализует паттерн «Адаптер/Декоратор» над любой существующей реализацией List (например, LinkedList).

3. Атомарна ли операция инкремента счетчика в Collections.synchronizedMap: map.put(key, map.get(key) + 1)?

Ответ: Категорически нет. Это три независимых действия: чтение (get), инкремент числа в регистре процессора и запись (put). Замок мапы захватывается и освобождается дважды, между ними любой поток может изменить значение по этому ключу. Для обеспечения атомарности требуется внешний блок synchronized (map) { ... } либо переход на ConcurrentHashMap.merge() / compute().


Типичные ошибки и Red Flags

  • ❌ Использование Collections.synchronizedMap в новых высоконагруженных проектах: Создает жесткий Lock Contention, необходимо использовать ConcurrentHashMap.
  • ❌ Итерация по синхронизированной коллекции без блока synchronized(collection): Гарантированный источник ConcurrentModificationException в многопоточной среде.
  • ❌ Уверенность в атомарности проверок if (!list.contains(e)) list.add(e): Ошибка проектирования Check-Then-Act.
  • ❌ Утечка ссылки на обернутую коллекцию: Модификация через прямую ссылку ломает потокобезопасность обертки.

Связанные темы