Многопоточность спрашивают на любом бэкенд-собесе по Java. Junior должен знать Thread, synchronized и гонки, middle volatile и happens-before, senior объяснить, почему double-checked locking без volatile сломан.
Поток
Thread t = new Thread(() -> System.out.println("hi"));
t.start(); // новый поток выполнит run()
t.join(); // ждём завершения
run() вместо start() выполнит код в текущем потоке. Повторный start() бросит IllegalThreadStateException. Состояния потока: NEW, RUNNABLE, BLOCKED (ждёт монитор), WAITING и TIMED_WAITING (join, wait, sleep, park), TERMINATED.
Исключение в потоке завершает только этот поток, main продолжит работу. Поймать его можно через UncaughtExceptionHandler.
В прикладном коде потоки руками почти не создают: есть пулы (ExecutorService) и виртуальные потоки, о них в следующем модуле.
Гонка и атомарность
private int count = 0;
void inc() { count++; } // не атомарно: прочитать, прибавить, записать
Два потока, по 1000 вызовов каждый, дадут результат меньше 2000: обновления теряются. Это race condition. Лечится synchronized, AtomicInteger или LongAdder.
synchronized
synchronized void inc() { count++; } // монитор this
static synchronized void f() { } // монитор Class
synchronized (lock) { ... } // явный объект
Даёт взаимное исключение и видимость: всё, что поток записал до выхода из блока, увидит следующий поток, вошедший в блок на том же мониторе. Монитор реентерабелен: поток может войти повторно. Синхронизация по разным объектам ничего не защищает.
volatile и видимость
Без синхронизации поток может не увидеть запись другого потока: значение закешировано в регистре, или компилятор и процессор переставили инструкции.
private volatile boolean running = true;
void stop() { running = false; } // другой поток
void loop() { while (running) { work(); } }
volatile гарантирует видимость и запрещает опасные перестановки вокруг переменной, но не атомарность: volatile int count; count++ всё ещё гонка.
Happens-before
Модель памяти Java (JMM) описывает, когда запись одного потока гарантированно видна другому. Основные правила happens-before:
- внутри одного потока по порядку программы;
- отпускание монитора happens-before последующего захвата того же монитора;
- запись в
volatilehappens-before последующего чтения этой переменной; t.start()happens-before любого действия в потокеt;- все действия потока happens-before успешного возврата из
t.join(); - правило транзитивно.
Если два доступа к переменной из разных потоков не связаны happens-before и хотя бы один из них запись, это data race, и результат не определён.
Безопасная публикация и DCL
private static volatile Singleton instance;
static Singleton get() {
Singleton r = instance;
if (r == null) {
synchronized (Singleton.class) {
r = instance;
if (r == null) instance = r = new Singleton();
}
}
return r;
}
Без volatile другой поток может увидеть ссылку на объект раньше, чем записи его полей из конструктора. Проще: holder-класс (private static class Holder { static final Singleton I = new Singleton(); }) или enum, инициализация класса потокобезопасна сама. Поля final, присвоенные в конструкторе, видны всем потокам после публикации, если this не утёк из конструктора.
wait/notify и дедлок
wait() отпускает монитор и ждёт notify(). Вызывать только внутри synchronized по тому же объекту и всегда в цикле while (!condition) wait(); из-за ложных пробуждений. В новом коде вместо них берут java.util.concurrent.
Дедлок: поток 1 держит A и ждёт B, поток 2 держит B и ждёт A. Лечится единым порядком захвата блокировок, tryLock с таймаутом, уменьшением областей блокировки. Найти можно по jstack: он пишет Found one Java-level deadlock.
Что спросят на собесе
- Чем
start()отличается отrun()? Какие состояния у потока? - Что такое race condition, как его исправить?
- Что гарантирует
volatileи чего не гарантирует? - Что такое happens-before? Приведите правила.
- Почему double-checked locking без
volatileсломан? - Что такое дедлок и как его избежать?