Як отримати synchronized колекцію
У Java стандартним способом отримання потокобезпечної колекції на базі звичайної є використання статичних фабричних методів допоміжного утилітарного класу java.util.Collections:
🟢 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] Ніколи не зберігайте посилання на вихідну несинхронізовану колекцію в окремій змінній! Завжди створюйте базову колекцію «на льоту» безпосередньо в аргументі фабричного методу.
// ❌ НЕБЕЗПЕЧНО (витік посилання / Reference Leak):
List<String> rawList = new ArrayList<>();
List<String> syncList = Collections.synchronizedList(rawList);
// Будь-який потік, що викличе rawList.add("danger"), змінить стан без синхронізації!
// ✅ БЕЗПЕЧНО (вихідний список недосяжний ззовні):
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 читає елементи поетапно. Якщо в ході обробки стріму паралельний потік виконає модифікацію (add/remove), обробка завершиться аварійним падінням з 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 або непрокинутий RuntimeException. Тому керування життєвим циклом блокування делеговано клієнтському коду.
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); }для одиночної операції додає беззмістовний подвійний оверхед.