Хочу рассказать о своем первом production-баге с многопоточностью. Приложение работало идеально на тестовой среде, но в реальных условиях под нагрузкой периодически зависало намертво. Пришлось изучать thread dumps в 3 часа ночи, чтобы понять: два потока взаимно заблокировали друг друга. С тех пор я запомнил главное правило многопоточности: она может работать 99 раз из 100, но этот 1 раз выстрелит в самый неподходящий момент. Сегодня разберём классические проблемы параллельного выполнения кода и научимся их избегать. Обещаю, без занудных теорем — только практика и реальные примеры.

Deadlock: когда потоки застряли навечно

Представь ресторан, где два официанта одновременно пытаются сервировать стол. Первый взял вилки и ждёт ножи. Второй взял ножи и ждёт вилки. Они могут стоять так вечно — это и есть deadlock (взаимная блокировка). В коде выглядит примерно так:
public class DeadlockExample {
    private static final Object resource1 = new Object();
    private static final Object resource2 = new Object();

    public static void main(String[] args) {
        new Thread(() -> {
            synchronized (resource1) {
                System.out.println("Поток 1: захватил ресурс 1");
                try { Thread.sleep(100); } catch (InterruptedException e) {}
                synchronized (resource2) { // Ждёт resource2
                    System.out.println("Поток 1: захватил ресурс 2");
                }
            }
        }).start();

        new Thread(() -> {
            synchronized (resource2) {
                System.out.println("Поток 2: захватил ресурс 2");
                try { Thread.sleep(100); } catch (InterruptedException e) {}
                synchronized (resource1) { // Ждёт resource1
                    System.out.println("Поток 2: захватил ресурс 1");
                }
            }
        }).start();
    }
}
Запусти этот код — с вероятностью 90% он зависнет навечно.

Как избежать deadlock?

Способ 1: Всегда захватывай ресурсы в одном порядке Самый простой и надёжный метод. Если все потоки будут брать ресурсы в одинаковой последовательности, deadlock не возникнет:
// Оба потока захватывают в одном порядке: resource1 → resource2
new Thread(() -> {
    synchronized (resource1) {
        synchronized (resource2) {
            System.out.println("Поток 1: работаю");
        }
    }
}).start();

new Thread(() -> {
    synchronized (resource1) {  // Тот же порядок!
        synchronized (resource2) {
            System.out.println("Поток 2: работаю");
        }
    }
}).start();
Способ 2: Используй таймаут при захвате блокировки Современный подход — использовать ReentrantLock с таймаутом:
ReentrantLock lock1 = new ReentrantLock();
ReentrantLock lock2 = new ReentrantLock();

if (lock1.tryLock(1, TimeUnit.SECONDS)) {
    try {
        if (lock2.tryLock(1, TimeUnit.SECONDS)) {
            try {
                // Делаем работу
            } finally { lock2.unlock(); }
        } else {
            System.out.println("Не смог получить lock2, отступаю");
        }
    } finally { lock1.unlock(); }
}
Если блокировку не удалось получить за секунду — лучше отступить, чем зависнуть навсегда. Способ 3: Используй высокоуровневые инструменты Честно говоря, в 2024 году редко кто пишет synchronized руками. Есть куча готовых решений из java.util.concurrent:
ExecutorService executor = Executors.newFixedThreadPool(10);

Future<String> result = executor.submit(() -> {
    // Твой код здесь
    return "Готово!";
});

try {
    String value = result.get(5, TimeUnit.SECONDS);
    System.out.println(value);
} catch (TimeoutException e) {
    result.cancel(true); // Отменяем зависшую задачу
}

Livelock: мы двигаемся, но никуда не идём

Это забавная штука. Представь двух вежливых людей, которые встретились в узком коридоре. Первый делает шаг влево, второй тоже влево (чтобы пропустить). Потом оба вправо. Потом опять влево. Они активно двигаются, но никуда не продвигаются — это livelock. В коде потоки постоянно меняют своё состояние в ответ на действия других потоков, но реальной работы не происходит. Решение: добавить рандомизацию задержек или использовать асинхронные подходы, чтобы потоки не синхронизировались идеально.

Starvation: когда поток умирает от голода

Это когда один или несколько потоков никогда не получают доступ к ресурсу, потому что другие потоки постоянно его захватывают. Помню проект, где мы использовали приоритеты потоков. Настроили высокий приоритет для "важных" задач. Через неделю заметили, что логи вообще не пишутся — поток логирования с низким приоритетом просто никогда не получал процессорное время. Решение: используй fair locks (справедливые блокировки)
// fair = true означает "справедливая очередь"
ReentrantLock fairLock = new ReentrantLock(true);

Runnable task = () -> {
    fairLock.lock();
    try {
        System.out.println(Thread.currentThread().getName() + " работает");
    } finally {
        fairLock.unlock();
    }
};

// Теперь все потоки получат свою порцию времени
for (int i = 0; i < 5; i++) {
    new Thread(task, "Поток-" + i).start();
}
Справедливая блокировка гарантирует, что потоки получат доступ в порядке очереди.

wait(), notify() и notifyAll(): классика межпотоковой коммуникации

Эти методы — база многопоточности в Java. Они позволяют потокам общаться: "Я подожду, пока ты закончишь" или "Эй, я закончил, можешь продолжать!" Важные правила: 1. Вызывать можно только внутри synchronized блока 2. wait() освобождает блокировку и усыпляет поток 3. notify() будит один случайный ждущий поток 4. notifyAll() будит ВСЕ ждущие потоки (обычно безопаснее) Классический пример — Producer-Consumer:
class ProducerConsumer {
    private final Queue<Integer> queue = new LinkedList<>();
    private final int MAX_SIZE = 5;

    public void produce() throws InterruptedException {
        int value = 0;
        while (true) {
            synchronized (queue) {
                while (queue.size() == MAX_SIZE) {
                    queue.wait(); // Очередь полна, ждём
                }
                queue.add(value++);
                System.out.println("Произвёл: " + value);
                queue.notifyAll(); // Будим потребителей
            }
            Thread.sleep(1000);
        }
    }

    public void consume() throws InterruptedException {
        while (true) {
            synchronized (queue) {
                while (queue.isEmpty()) {
                    queue.wait(); // Очередь пуста, ждём
                }
                int value = queue.poll();
                System.out.println("Потребил: " + value);
                queue.notifyAll(); // Будим производителей
            }
            Thread.sleep(1500);
        }
    }
}

Почему важно использовать while, а не if?

Заметь, что мы проверяем условие в while, а не в if. Это критично! Когда поток просыпается, нужно снова проверить условие, потому что: 1. Могли проснуться несколько потоков одновременно 2. Условие могло измениться, пока мы спали 3. Могло произойти spurious wakeup (ложное пробуждение)

Современные инструменты: java.util.concurrent

В 2025 году писать wait() и notify() руками — это как носить воду из колодца. Для 95% задач есть готовые решения.

BlockingQueue — очередь с блокировкой

Помнишь наш Producer-Consumer? А теперь смотри:
BlockingQueue<Integer> queue = new ArrayBlockingQueue<>(5);

// Producer
new Thread(() -> {
    try {
        int value = 0;
        while (true) {
            queue.put(value++); // Блокируется, если очередь полна
            System.out.println("Произвёл: " + value);
        }
    } catch (InterruptedException e) {}
}).start();

// Consumer
new Thread(() -> {
    try {
        while (true) {
            int value = queue.take(); // Блокируется, если очередь пуста
            System.out.println("Потребил: " + value);
        }
    } catch (InterruptedException e) {}
}).start();
Вместо 40 строк — всего 15! Никакого synchronized, wait(), notify().

CountDownLatch — стартуем все потоки одновременно

CountDownLatch startSignal = new CountDownLatch(1);
CountDownLatch doneSignal = new CountDownLatch(5);

for (int i = 0; i < 5; i++) {
    new Thread(() -> {
        try {
            System.out.println("Готов!");
            startSignal.await(); // Ждём сигнала старта
            System.out.println("Работаю!");
            doneSignal.countDown(); // Сигнализируем о завершении
        } catch (InterruptedException e) {}
    }).start();
}

Thread.sleep(2000);
System.out.println("СТАРТ!");
startSignal.countDown(); // Запускаем всех одновременно
doneSignal.await(); // Ждём, пока все закончат
System.out.println("Все финишировали!");

Semaphore — ограничиваем количество подключений

Semaphore semaphore = new Semaphore(3); // Всего 3 разрешения

Runnable task = () -> {
    try {
        System.out.println("Хочу подключиться");
        semaphore.acquire(); // Берём разрешение
        System.out.println("Подключился к БД");
        Thread.sleep(2000);
    } catch (InterruptedException e) {
    } finally {
        semaphore.release(); // Возвращаем разрешение
        System.out.println("Отключился");
    }
};

// 10 потоков, но работать смогут только 3 одновременно
for (int i = 0; i < 10; i++) {
    new Thread(task).start();
}

CyclicBarrier — синхронизация в точке встречи

Runnable bossAction = () -> System.out.println("БОСС ПОЯВИЛСЯ!");
CyclicBarrier barrier = new CyclicBarrier(4, bossAction);

for (int i = 1; i <= 4; i++) {
    new Thread(() -> {
        try {
            Thread.sleep((long)(Math.random() * 2000));
            System.out.println("Игрок прибыл");
            barrier.await(); // Ждём остальных
            System.out.println("Атакуем босса!");
        } catch (Exception e) {}
    }).start();
}

ThreadLocal — у каждого потока своя копия

Иногда нужно, чтобы у каждого потока была своя собственная версия переменной:
ThreadLocal<SimpleDateFormat> dateFormatter =
    ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));

Runnable task = () -> {
    SimpleDateFormat formatter = dateFormatter.get();
    System.out.println(formatter.format(new Date()));
};

new Thread(task).start();
new Thread(task).start();
Важно! Не забывай очищать ThreadLocal, особенно в веб-приложениях:
try {
    SimpleDateFormat formatter = dateFormatter.get();
    // Работаем
} finally {
    dateFormatter.remove(); // Обязательно!
}

Неизменяемые объекты — лучшая защита

Самое безопасное в многопоточности — когда объект вообще нельзя изменить:
public final class ImmutablePerson {
    private final String name;
    private final int age;

    public ImmutablePerson(String name, int age) {
        this.name = name;
        this.age = age;
    }

    // Вместо изменения — создаём новый объект
    public ImmutablePerson withAge(int newAge) {
        return new ImmutablePerson(this.name, newAge);
    }
}
Records в Java 16+ делают это проще:
public record Person(String name, int age) {
    // Автоматически неизменяемый!
}

Практические советы

1. Избегай блокировок, где возможно
// Вместо synchronized
private int counter = 0;
public synchronized void increment() { counter++; }

// Используй AtomicInteger
private final AtomicInteger counter = new AtomicInteger(0);
public void increment() { counter.incrementAndGet(); }
2. Всегда захватывай блокировки в одном порядке Если нужно несколько блокировок, придумай для них порядок и придерживайся его везде. 3. Держи блокировки минимальное время
// Плохо — долгая работа под блокировкой
synchronized(lock) {
    String data = loadFromDatabase(); // Медленно!
    processData(data);
}

// Хорошо — минимум под блокировкой
String data = loadFromDatabase();
String processed = processData(data);
synchronized(lock) {
    saveResult(processed); // Только быстрая операция
}
4. Используй concurrent коллекции
// Вместо HashMap + synchronized
Map<String, Integer> map = new HashMap<>();
public synchronized void put(String k, Integer v) { map.put(k, v); }

// Используй ConcurrentHashMap
ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
public void put(String k, Integer v) { map.put(k, v); }

FAQ: частые вопросы

Q: Когда использовать synchronized, а когда ReentrantLock? A: Для простых случаев хватит synchronized — он проще и читабельнее. ReentrantLock нужен, когда требуются продвинутые фичи: таймауты (tryLock), справедливость (fair mode), или возможность прервать ожидание. Q: Почему notifyAll() лучше, чем notify()? A: notify() будит один случайный поток. Можешь разбудить "не того", и он снова уснёт. notifyAll() будит всех, каждый проверит своё условие. Чуть менее эффективно, но безопаснее. Q: Что такое spurious wakeup? A: Это когда поток просыпается от wait() без явного вызова notify(). Случается на уровне ОС. Поэтому используй while вместо if при проверке условия. Q: Можно ли использовать ThreadLocal в веб-приложении? A: Можно, но осторожно! В веб-серверах потоки переиспользуются, поэтому обязательно очищай ThreadLocal после использования. Иначе данные одного пользователя могут "утечь" другому. Q: Как отладить deadlock в production? A: Используй jstack для thread dump. Он покажет, какие потоки заблокированы и чего ждут. Многие профайлеры (VisualVM, JProfiler) умеют автоматически находить deadlock. Q: В чём разница между параллелизмом и конкурентностью? A: Конкурентность — несколько задач выполняются в перекрывающиеся периоды, но не обязательно одновременно. Это свойство программы. Параллелизм — задачи выполняются действительно одновременно на разных ядрах. Это свойство выполнения. Q: Стоит ли использовать Virtual Threads из Java 21? A: Если у тебя много блокирующих операций (I/O, сетевые запросы), то да — virtual threads упростят код и улучшат производительность. Для CPU-интенсивных задач обычные потоки или параллельные стримы будут лучше.

Заключение

Многопоточность — это не ракетостроение, но и не прогулка в парке. Главное: 1. Понимай проблемы: deadlock, livelock, starvation 2. Используй правильные инструменты: не пиши synchronized там, где есть готовое решение 3. Предпочитай неизменяемость: immutable объекты = никаких проблем 4. Тестируй под нагрузкой: баги многопоточности проявляются в production И помни: если можешь обойтись без многопоточности — обойдись. Но если нужна — делай правильно с первого раза. Отлаживать thread-related баги в 3 часа ночи — это квест не для слабонервных. Удачи в покорении параллельных вычислений!