Java. Concurrency
Проблемы многопоточности:
Deadlock, Race Condition и другие
Введение в проблемы многопоточности
Представьте, что вы и ваш друг одновременно готовите блюдо на одной маленькой кухне. Вам обоим нужен один и тот же нож и одна и та же разделочная доска. Вы берёте нож, а друг — разделочную доску. Теперь вы стоите и ждёте, когда он отдаст доску, а он ждёт, когда вы отдадите нож. Никто не может продолжить работу. Блюдо не будет готово никогда.
В мире многопоточного программирования такие ситуации происходят постоянно. Когда несколько потоков работают с общими ресурсами, без должной осторожности можно столкнуться с катастрофическими ошибками, которые трудно воспроизвести и ещё сложнее исправить.
Сегодня мы разберём три главных «монстра» многопоточности:
  • Состояние гонки (Race Condition)
    Когда результат зависит от случайного порядка выполнения потоков.
  • Взаимная блокировка (Deadlock)
    Когда потоки навечно блокируют друг друга.
  • Живая блокировка (Livelock) и голодание (Starvation)
    Менее очевидные, но не менее опасные проблемы.
Состояние гонки (Race Condition)
Что такое состояние гонки?
Вернёмся к нашей кухне. Представьте, что на счётчике лежит 10 граммов соли. Рецепт требует добавить 5 граммов. Вы и ваш друг решаете добавить соль одновременно. Вы оба видите «10 граммов», каждый в уме добавляет свои «5 граммов» и получаете «15 граммов». Вы оба сыплете по 5 граммов. В итоге в блюде оказалось 20 граммов соли, а не 15.
Состояние гонки — это ошибка, которая возникает, когда поведение программы зависит от того, какой из потоков в какой момент выполняет свои операции. Проблема возникает, когда операция не является атомарной (то есть, не может быть прервана).
Операция i++ (инкремент) не является атомарной. Она состоит из трёх шагов:
  1. Прочитать текущее значение i.
  2. Увеличить его на 1.
  3. Записать новое значение обратно в i.
Между этими шагами другой поток может успеть прочитать и записать своё значение.
Практический пример: небезопасный счётчик
Давайте посмотрим на классический пример с несколькими потоками, которые увеличивают один и тот же счётчик.
class UnsafeCounter {
private int count = 0;
// Этот метод не потокобезопасен!
public void increment() {
    count++; // ← Три операции: read, modify, write
}

public int getCount() {
    return count;
}
}

public class RaceConditionDemo {
public static void main(String[] args) throws InterruptedException {
UnsafeCounter counter = new UnsafeCounter();
 // Создаём два потока, каждый увеличивает счётчик 1000 раз
    Thread thread1 = new Thread(() -> {
        for (int i = 0; i < 1000; i++) {
            counter.increment();
        }
    });

    Thread thread2 = new Thread(() -> {
        for (int i = 0; i < 1000; i++) {
            counter.increment();
        }
    });

    thread1.start();
    thread2.start();

    // Ждём завершения обоих потоков
    thread1.join();
    thread2.join();

    // Ожидаемый результат: 2000
    // Реальный результат: почти всегда меньше 2000!
    System.out.println("Финальное значение счётчика: " + counter.getCount());
	}
}
Если вы запустите этот код несколько раз, вы почти никогда не увидите на экране число 2000. Результат будет каждый раз разным: 1987, 1532, 1871 и т. д. Это и есть состояние гонки в действии.
Как решить проблему?
Чтобы сделать операцию атомарной, нужно использовать механизмы синхронизации. Самый простой способ — ключевое слово synchronized или классы из пакета java.util.concurrent.atomic.
import java.util.concurrent.atomic.AtomicInteger;

class SafeCounter {
// AtomicInteger использует атомарные операции на уровне процессора (CAS)
private final AtomicInteger count = new AtomicInteger(0);

public void increment() {
    count.incrementAndGet(); // ← Атомарная операция
}

public int getCount() {
    return count.get();
	}
}
Теперь, если мы заменим 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, которые помогают избежать этих ловушек «из коробки». Удачи!