Хочу рассказать о своем первом 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); }