Java. Concurrency
Конкурентные
коллекции
Cоздание и управление потоками
Введение в конкурентные коллекции
Представьте, что у вас есть общий документ, с которым одновременно работают несколько человек. Без правильных инструментов это превратится в хаос: одни будут стирать то, что написали другие, возникнут конфликты и путаница. В мире многопоточного программирования происходит то же самое, когда несколько потоков пытаются одновременно работать с общей коллекцией данных.
Конкурентные коллекции — это специальные структуры данных из пакета java.util.concurrent, разработанные для безопасной работы в многопоточной среде. В отличие от обычных коллекций, они предоставляют механизмы синхронизации, которые предотвращают состояние гонки (race condition) и обеспечивают согласованность данных при одновременном доступе из нескольких потоков.
Давайте разберёмся, почему обычные коллекции не подходят для многопоточной среды:
List<String> unsafeList = new ArrayList<>();
// Если несколько потоков одновременно вызывают этот метод, возникнет ConcurrentModificationException
// или данные могут быть повреждены
public void addItem(String item) {
	unsafeList.add(item); // ← Не потокобезопасная операция
}
Обычные коллекции из java.util (ArrayList, HashMap, HashSet и др.) не являются потокобезопасными и могут приводить к непредсказуемым результатам при одновременном доступе из нескольких потоков.
Теперь давайте рассмотрим три ключевые конкурентные коллекции, которые решают эти проблемы.
CopyOnWriteArrayList
Что такое CopyOnWriteArrayList?
Представьте себе доску объявлений, на которой размещена важная информация. Когда нужно добавить новое объявление, вместо того чтобы пытаться вписать его на уже заполненную доску (мешая тем, кто её читает), вы делаете полную копию доски, добавляете объявление на копию, а затем заменяете старую доску новой. Именно по такому принципу работает CopyOnWriteArrayList.
CopyOnWriteArrayList — это потокобезопасная реализация списка, которая использует стратегию "копирование при записи" (copy-on-write). При каждой модификации коллекции (добавление, удаление, изменение элемента) создаётся новая копия внутреннего массива, а все операции чтения работают с неизменяемой версией массива.
Как это работает?
// Создание CopyOnWriteArrayList
CopyOnWriteArrayList<String> list = new CopyOnWriteArrayList<>();
// Поток 1: добавление элементов
list.add("Элемент 1"); // ← Создаётся новый массив с этим элементом
list.add("Элемент 2"); // ← Создаётся ещё один новый массив с обоими элементами

// Поток 2: чтение элементов (не блокируется)
for (String item : list) { // ← Работает с копией массива
System.out.println(item);
}
Преимущества и недостатки
Преимущества:
  •  Операции чтения не блокируются и выполняются очень быстро
  • Итератор не бросает ConcurrentModificationException
  • Гарантирует атомарность операций
Недостатки:
  • Операции записи требуют создания полной копии массива
  • Не подходит для коллекций с частыми модификациями
  • Потребляет больше памяти из-за создания копий
Когда использовать CopyOnWriteArrayList?
CopyOnWriteArrayList идеально подходит для сценариев, где:
  • Чтение происходит значительно чаще, чем запись
  • Количество элементов невелико (обычно менее 1000)
  • Необходима консистентность данных во время итерации
Пример использования:
// Список слушателей событий в GUI-приложении
CopyOnWriteArrayList<EventListener> listeners = new CopyOnWriteArrayList<>();
// Добавление слушателей (редкая операция)
public void addListener(EventListener listener) {
listeners.add(listener); // ← Создаётся новая копия массива
}

// Уведомление слушателей (частая операция)
public void notifyListeners(Event event) {
	for (EventListener listener : listeners) { // ← Безопасная итерация
		listener.onEvent(event); // ← Не бросает ConcurrentModificationException
	}
}
Теперь, если мы заменим UnsafeCounter на SafeCounter в нашем примере, результат всегда будет 2000.
Взаимная блокировка (Deadlock)
Что такое дедлок?
Представьте двух вежливых людей, которые идут навстречу друг другу в очень узком коридоре. Каждый уступает дорогу другому, отступая в сторону. Но оба уступают одновременно, двигаясь влево-вправо, и в итоге ни один не может пройти. Они «застряли» в вежливости.
Взаимная блокировка (Deadlock) — это ситуация, при которой два или более потока находятся в состоянии вечного ожидания ресурсов, захваченных друг другом. Поток A ждёт ресурс, который удерживает поток B, а поток B ждёт ресурс, удерживаемый потоком A.
Условия возникновения дедлока
Чтобы возник дедлок, должны одновременно выполниться четыре условия (условия Коффмана):
  • Взаимное исключение (Mutual Exclusion)
    Ресурс может быть использован только одним потоком одновременно (например, synchronized блок).
  • Удержание и ожидание (Hold and Wait)
    Поток удерживает как минимум один ресурс и в то же время ожидает освобождения другого.
  • Отсутствие принудительного освобождения (No Preemption)
    Ресурс нельзя принудительно отобрать у потока. Он должен быть освобождён добровольно.
  • Циклическое ожидание (Circular Wait)
    Существует циклическая цепь потоков, каждый из которых ждёт ресурс, удерживаемый следующим потоком в цепи.
Практический пример: два потока, два замка
public class DeadlockDemo {
private static final Object lock1 = new Object();
private static final Object lock2 = new Object();

public static void main(String[] args) {
    Thread thread1 = new Thread(() -> {
        synchronized (lock1) {
            System.out.println("Поток 1: Захватил lock1...");
            try { Thread.sleep(50); } catch (InterruptedException e) {}
            System.out.println("Поток 1: Ожидаю lock2...");
            synchronized (lock2) { // ← Блокировка! Ждёт lock2, который удерживает поток 2
                System.out.println("Поток 1: Захватил lock1 и lock2.");
            }
        }
    });

    Thread thread2 = new Thread(() -> {
        synchronized (lock2) {
            System.out.println("Поток 2: Захватил lock2...");
            try { Thread.sleep(50); } catch (InterruptedException e) {}
            System.out.println("Поток 2: Ожидаю lock1...");
            synchronized (lock1) { // ← Блокировка! Ждёт lock1, который удерживает поток 1
                System.out.println("Поток 2: Захватил lock2 и lock1.");
            }
        }
    });

    thread1.start();
    thread2.start();
	}
}
При запуске этой программы вы увидите, что оба потока сообщают о захвате первого замка, а затем «зависают» навсегда, ожидая друг друга.
  ┌───────────────────┐      ┌───────────────────┐
  │     Поток 1       │      │     Поток 2       │
  └─────────┬─────────┘      └─────────┬─────────┘
            │                          │
    Захватил lock1              Захватил lock2
            │                          │
            ▼                          ▼
    Ожидает lock2  ◄──────────────►  Ожидает lock1
Как избежать дедлока?
Самый надёжный способ — нарушить одно из четырёх условий. Чаще всего нарушают условие «Циклическое ожидание». Для этого вводят строгий порядок захвата ресурсов.
Решение: всегда захватывать замки в одном и том же порядке (например, по алфавиту или по хэш-коду).
// Исправленная версия: оба потока захватывают замки в одном порядке
synchronized (lock1) {
System.out.println("Поток 1: Захватил lock1...");
synchronized (lock2) {
System.out.println("Поток 1: Захватил lock1 и lock2.");
	}
}
// Поток 2 теперь тоже сначала захватывает lock1
synchronized (lock1) {
System.out.println("Поток 2: Захватил lock1...");
synchronized (lock2) {
System.out.println("Поток 2: Захватил lock1 и lock2.");
	}
}
Другие проблемы: Livelock и Starvation
Живая блокировка (Livelock)
Это как дедлок, но потоки не «заморожены», а постоянно активны. Они реагируют на действия друг друга, но не могут сделать прогресс.
Аналогия: два человека снова в коридоре. Один шагает влево, другой — вправо, чтобы уступить. Они не сталкиваются, но и не проходят. Они начинают двигаться взад-вперёд, реагируя на движения друг друга, но так и не могут разойтись.
Решение: ввести элемент случайности (backoff). Если поток не может получить ресурс, он должен подождать случайное время перед следующей попыткой.
Голодание (Starvation)
Аналогия: очень вежливый человек в очереди. Постоянно пропускает других вперед и в итоге никогда не достигает прилавка.
Голодание — это ситуация, когда поток не может получить доступ к разделяемому ресурсу в течение длительного времени, потому что другие, более «приоритетные» потоки постоянно его занимают.
Пример: поток с очень низким приоритетом, который никогда не получает процессорное время, или ситуация, когда один поток постоянно успевает захватить synchronized блок, не давая шанса другим.
Решение: использовать «честные» (fair) блокировки, например, ReentrantLock(true), которые предоставляют ресурс потокам в порядке очереди их запросов.
Сравнение проблем многопоточности
Заключение
Поздравляю, вы теперь знаете о главных опасностях, которые поджидают разработчика в мире многопоточности. Эти проблемы — главная причина того, почему отладка многопоточных приложений считается одним из самых сложных заданий.
Ключевые моменты:
  • Состояние гонки 
    Возникает из-за пересекающихся неатомарных операций. Боритесь с ним с помощью синхронизации.
  • Взаимная блокировка
    Это тупиковая ситуация из-за циклического ожидания. Избегайте её, упорядочивая захват ресурсов.
  • Livelock и Starvation
    Более тонкие проблемы, связанные с активностью потоков и справедливостью.
Понимание этих проблем — первый шаг к написанию надёжного и предсказуемого многопоточного кода. Теперь вы вооружены знаниями, чтобы не просто создавать параллельные программы, а делать это правильно и безопасно. В следующих лекциях мы продолжим изучать инструменты из java.util.concurrent, которые помогают избежать этих ловушек «из коробки». Удачи!