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

Как получить synchronized коллекцию

В Java стандартным способом получения потокобезопасной коллекции на базе обычной является использование статических фабричных методов вспомогательного утилитного класса java.uti...


🟢 Junior Level

1. Способы создания синхронизированных коллекций

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

// Синхронизированный список:
List<String> syncList = Collections.synchronizedList(new ArrayList<>());

// Синхронизированное множество:
Set<String> syncSet = Collections.synchronizedSet(new HashSet<>());

// Синхронизированная мапа:
Map<String, Integer> syncMap = Collections.synchronizedMap(new HashMap<>());

Полный перечень фабричных методов Collections:

  • synchronizedCollection(Collection<T> c)
  • synchronizedList(List<T> list)
  • synchronizedSet(Set<T> s)
  • synchronizedSortedSet(SortedSet<T> s)
  • synchronizedNavigableSet(NavigableSet<T> s)
  • synchronizedMap(Map<K, V> m)
  • synchronizedSortedMap(SortedMap<K, V> m)
  • synchronizedNavigableMap(NavigableMap<K, V> m)

2. Главное правило безопасности: изоляция исходного объекта

[!CAUTION] Никогда не сохраняйте ссылку на исходную коллекцию во внешней переменной! Всегда создавайте исходную коллекцию «на лету» прямо внутри аргумента фабричного метода.

// ❌ ОПАСНО (утечка ссылки):
List<String> rawList = new ArrayList<>();
List<String> syncList = Collections.synchronizedList(rawList);
// Любой поток, вызвавший rawList.add("bad"), разрушит целостность без синхронизации!

// ✅ БЕЗОПАСНО (исходный список недостижим извне):
List<String> syncList = Collections.synchronizedList(new ArrayList<>());

🟡 Middle Level

1. Внутреннее устройство: паттерн Декоратор (Decorator)

Фабричные методы Collections.synchronized* не меняют сам переданный объект. Они возвращают обертку (декоратор), которая реализует соответствующий интерфейс и делегирует все вызовы базовой коллекции внутри блока synchronized (mutex):

static class SynchronizedList<E> extends SynchronizedCollection<E> implements List<E> {
    final List<E> list; // Базовая коллекция (Backing collection)

    SynchronizedList(List<E> list) {
        super(list);
        this.list = list;
    }

    public E get(int index) {
        synchronized (mutex) { // Синхронизация каждого метода
            return list.get(index);
        }
    }

    public void add(int index, E element) {
        synchronized (mutex) {
            list.add(index, element);
        }
    }
}

В базовом классе SynchronizedCollection поле mutex по умолчанию указывает на сам экземпляр обертки:

final Object mutex;
SynchronizedCollection(Collection<E> c) {
    this.c = Objects.requireNonNull(c);
    this.mutex = this; // Синхронизация на самом объекте-обертке
}

2. Почему iterator() не синхронизирован автоматически?

Метод iterator() в SynchronizedCollection возвращает обычный итератор базовой коллекции без удерживания блокировки:

public Iterator<E> iterator() {
    return c.iterator(); // Возвращает итератор, монитор НЕ удерживается!
}

Причина такого архитектурного решения: Итерация — это протяженный во времени процесс, состоящий из сотен или тысяч вызовов hasNext() и next(). Если бы итератор автоматически захватывал замок mutex при вызове iterator(), возникал бы огромный риск Deadlock или зависания замка навсегда, если бы внутри цикла произошел непредвиденный break, исключение или зависание потока.

Поэтому спецификация JDK возлагает захват монитора на разработчика:

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

// ОБЯЗАТЕЛЬНЫЙ ШАБЛОН ОБХОДА:
synchronized (syncList) {
    for (String item : syncList) {
        System.out.println(item);
    }
}

🔴 Senior Level

1. Потокобезопасность Stream API и Spliterator

Частая скрытая ошибка в Java 8+ — запуск стримов по синхронизированной коллекции без внешней блокировки:

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

// ❌ ОШИБКА: вызовет ConcurrentModificationException в параллельной среде!
syncList.stream()
        .filter(s -> s.startsWith("A"))
        .forEach(System.out::println);

Метод stream() создает Spliterator базовой коллекции. Как и iterator(), Spliterator производит чтение элементов поэтапно. Если в процессе работы стрима параллельный поток добавит или удалит элемент, операция завершится с ConcurrentModificationException.

Корректное использование Stream API:

synchronized (syncList) {
    syncList.stream()
            .filter(s -> s.startsWith("A"))
            .forEach(System.out::println);
}

2. Пакетно-приватный конструктор с внешним mutex

В исходном коде Collections.SynchronizedCollection присутствует перегруженный конструктор:

SynchronizedCollection(Collection<E> c, Object mutex) {
    this.c = Objects.requireNonNull(c);
    this.mutex = Objects.requireNonNull(mutex);
}

Он имеет модификатор package-private и используется внутри пакета java.util для координации блокировок связанных коллекций:

  • Например, методы syncList.subList(from, to) или syncMap.keySet() возвращают новые обертки, передавая в них тот же самый объект mutex, что и у родительской коллекции.
  • Благодаря этому операции над срезом (subList) и над основным списком конкурируют за один и тот же монитор, исключая повреждение данных.

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

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

«Синхронизированную коллекцию получают с помощью фабричных методов Collections.synchronizedList/Set/Map(...). Они реализуют паттерн «Декоратор», оборачивая каждый вызов метода базовой коллекции в блок synchronized(mutex). Чтобы избежать нарушения потокобезопасности, ссылку на исходную коллекцию нельзя сохранять снаружи. Любая итерация (через Iterator, for-each или Stream API) обязана вручную оборачиваться во внешний блок synchronized(syncCollection), иначе гарантирован выброс ConcurrentModificationException».


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

1. Что произойдет, если вызвать syncList.subList(0, 5) и параллельно модифицировать исходный syncList? Будет ли операция потокобезопасна?

Ответ: Да, потокобезопасность сохранится. Метод subList() возвращает новый экземпляр SynchronizedList, в конструктор которого передается ссылка на тот же самый объект mutex, что и у родительского списка. Любая операция над subList захватывает тот же замок, что и операция над исходным списком.

2. Зачем разработчики JDK оставили метод iterator() без внутренней синхронизации?

Ответ: Итератор выполняет множество раздельных вызовов hasNext() и next(). Автоматический захват монитора при вызове iterator() потребовал бы освобождения монитора только в самом конце обхода (например, в деструкторе или финализаторе, которых в Java нет), что приводило бы к зависанию блокировок при выходе из цикла через break или непроверенное исключение. Поэтому управление временем удержания блокировки возложено на вызывающий код.

3. В чём фундаментальная разница между Collections.synchronizedMap и ConcurrentHashMap при выполнении операции size()?

Ответ: В Collections.synchronizedMap метод size() захватывает глобальный мьютекс мапы, гарантируя строгую точность (Strong Consistency), но блокируя параллельно все остальные потоки. В ConcurrentHashMap метод size() выполняется без блокировок, суммируя базовый счетчик и массив CounterCell, предоставляя приблизительное слабосогласованное значение (Weakly Consistent), но масштабируясь на любое количество потоков.


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

  • ❌ Утечка ссылки на исходную коллекцию: Сохранение ссылки на new ArrayList<>() и последующая модификация в обход обёртки.
  • ❌ Итерация без блока synchronized(syncList): Неминуемый ConcurrentModificationException при параллельной записи.
  • ❌ Запуск syncCollection.stream() без синхронизации: Падение стрима в многопоточной среде.
  • ❌ Двойная ненужная синхронизация одиночных методов: Написание synchronized(list) { list.add(x); } для одиночной операции создает бессмысленный двойной оверхед.

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