Найдена ошибка: общий список обёрнут в Collections.synchronizedList, но его перебирают без внешней синхрони...

Найдена ошибка: общий список обёрнут в Collections.synchronizedList, но его перебирают без внешней синхронизации. Почему это всё ещё небезопасно?

Проходите собеседования с ИИ помощником Hintsage

Краткий ответ

Collections.synchronizedList синхронизирует отдельные операции списка, но не делает автоматически потокобезопасной составную операцию итерации. Итератор нужно перебирать под синхронизацией на том же объекте-обёртке, иначе другой поток может изменить список во время обхода, что приведёт к ConcurrentModificationException или к наблюдению несогласованного состояния.

Исторический контекст

Синхронизированные коллекции появились как простой адаптер для уже существующих коллекций: вызовы их отдельных методов защищаются общим монитором. Такой подход решал базовую проблему конкурентного доступа к операциям вроде добавления, удаления и получения элемента, не требуя переписывать внутреннюю реализацию коллекции.

Однако итерация состоит из серии вызовов итератора. Сам итератор не получает автоматически тот же уровень защиты на весь период обхода, поэтому границы синхронизации составной операции должен определить вызывающий код.

Постановка проблемы

Проверка наличия элемента с последующей обработкой, перебор коллекции или вычисление размера и содержимого могут включать несколько действий. Если между ними другой поток изменит список, первый поток будет работать не с единым логическим снимком.

Для обычного списка возможны ConcurrentModificationException, пропуск или повторное наблюдение элементов. Отсутствие исключения не означает, что результат обхода был согласованным: fail-fast-поведение итераторов является диагностическим и не является гарантией потокобезопасности.

Подробное решение

Нужно синхронизировать весь участок итерации на объекте, который возвращён как синхронизированная обёртка. Синхронизация на исходном списке или на другом объекте не защищает итерацию, поскольку методы обёртки используют именно свой монитор.

import java.util.*; class Demo { public static void main(String[] args) { List<String> list = Collections.synchronizedList(new ArrayList<>()); list.add("A"); list.add("B"); synchronized (list) { for (String value : list) { System.out.println(value); } } } }

Пока удерживается этот монитор, другие операции через ту же обёртку, которые также используют её монитор, будут ждать. Это даёт согласованный обход относительно таких операций, но увеличивает время удержания блокировки и снижает параллелизм.

Если требуется конкурентная итерация с другими изменениями, стоит рассмотреть CopyOnWriteArrayList или коллекции из java.util.concurrent. CopyOnWriteArrayList предоставляет итераторам снимок, но дорог при частых изменениях и потребляет дополнительную память. Универсального решения нет: выбор зависит от соотношения частоты чтений и записей и от требований к согласованности.

Ситуация из практики

В сервисе список подписчиков часто читается для рассылки событий, а новые подписчики добавляются параллельно. Использование synchronizedList с обходом без внешней синхронизации периодически приводило к ConcurrentModificationException.

Вариант с внешней блокировкой был простым и обеспечивал согласованный обход, но блокировал добавление подписчиков на всё время отправки событий. Переход на CopyOnWriteArrayList устранил блокировки читателей: каждая итерация получила стабильный снимок, а редкие добавления стали приемлемо дорогими.

Если бы подписчики менялись часто, более подходящим решением могла бы стать другая конкурентная структура или явная модель снимков. В данном случае преобладание чтений сделало CopyOnWriteArrayList оправданным, а результатом стал предсказуемый обход без длительной общей блокировки.

Что кандидаты часто упускают

1. Достаточно ли синхронизировать только вызов iterator()?

Нет. Получение итератора — лишь начало обхода. Синхронизация должна охватывать весь период вызовов hasNext() и next(), иначе изменение списка может произойти уже после создания итератора.

2. Можно ли синхронизироваться на исходном ArrayList, переданном в обёртку?

Нет, если доступ выполняется через Collections.synchronizedList. Обёртка синхронизирует свои методы на собственном объекте, поэтому внешний код должен использовать монитор обёртки. Кроме того, прямой доступ к исходному списку вообще нельзя смешивать с доступом через обёртку без отдельной согласованной схемы синхронизации.

3. Гарантирует ли внешняя синхронизация атомарность всей бизнес-операции?

Только если под тем же монитором находятся все связанные чтения, проверки и изменения, которые должны быть неделимыми. Если после завершения обхода данные передаются в другой код или используются после освобождения монитора, другой поток уже может изменить коллекцию. Синхронизация защищает конкретную критическую секцию, но не превращает произвольную последовательность действий программы в транзакцию.