Потоки в Java: основы
Два потока, каждый делает 200 000 инкрементов одной переменной. Ожидаемо 400 000. Запустил три раза подряд: 395 754, потом 399 659, потом ровно 400 000. Тот же код, та же машина - три разных ответа, и один из них правильный по чистой случайности.
Многопоточность - главный фильтр Java-собесов. Джун знает, как создать поток, мидл понимает, почему код с потоками ломается. Проверяют, видишь ли ты гонку в трёх строках и знаешь ли, что атомарность и видимость - разные проблемы.
// Формулировки: «что будет, если вызвать run() вместо start()?», «почему count++ из двух потоков теряет значения?», «как остановить поток?»
Жизненный цикл и запуск
Начнём с самого частого вопроса «что напечатает». Я запустил одну и ту же задачу двумя способами: через run() она сообщила, что выполняется в потоке main, через start() - в новом потоке. То есть прямой вызов run() это обычный вызов метода, никакой многопоточности там нет.
Состояния потока - язык, на котором читают дампы. Проверил два из них живьём: поток, стоящий в wait(), показывает WAITING, поток в sleep() - TIMED_WAITING. Есть ещё BLOCKED - это ожидание монитора. Сотня потоков в BLOCKED на одном мониторе в дампе означает, что диагноз зависания уже поставлен.
// В проде потоки руками не плодят, для этого есть пулы. И отдельно про фоновые потоки: виртуальная машина завершается, когда закончились все обычные, а фоновые умирают вместе с ней, ничего не доделав и не убрав.
new Thread(task).run(); // выполнилось в main
new Thread(task).start(); // выполнилось в новом потоке- WAITING и TIMED_WAITING
- ждёт сигнала / ждёт с таймаутом, видно в дампе
- фоновый поток
- не мешает виртуальной машине завершиться
Три разные гарантии, а не одна
Инкремент выглядит как одно действие, а внутри их три: прочитать, увеличить, записать. Два потока читают одно значение, оба пишут одинаковый результат - один инкремент пропал. Это атомарность, и замер из начала темы про неё.
Видимость - отдельная проблема, и её я проверил особо наглядно. Поток крутит цикл while по обычному булеву флагу, другой через 300 миллисекунд ставит флаг в true. Цикл НЕ вышел: через две секунды поток всё ещё жив. Тот же код с volatile-флагом вышел, отработав 452 миллиона итераций. Без синхронизации поток может не увидеть чужую запись никогда.
Третья гарантия - упорядоченность: компилятор и процессор вправе переставлять операции, если это не видно изнутри потока. Ключевое слово synchronized даёт все три гарантии. volatile даёт видимость и порядок, но НЕ атомарность.
// Отсюда прямое следствие, тоже из замера: volatile-счётчик в моих прогонах терял значения ровно так же, как обычный (399 962 и 399 660 вместо 400 000). А AtomicInteger давал ровно 400 000 в каждом прогоне.
- атомарность
- операция целиком или никак, без промежутка
- видимость
- чужая запись действительно доходит до твоего потока
Прерывание, ThreadLocal и её утечка
Остановить чужой поток нельзя, можно попросить. Метод interrupt() ставит флаг, а блокирующие методы (sleep, wait, take) на него реагируют исключением. Важная деталь, которую я проверил: при выбросе InterruptedException флаг прерывания СБРАСЫВАЕТСЯ - внутри catch он оказался false. Поэтому корректная реакция это либо завершиться, либо восстановить флаг вызовом Thread.currentThread().interrupt().
ThreadLocal даёт каждому потоку свою копию значения - контекст запроса, несовместимый с многопоточностью форматтер. Механика простая, а грабли живут в пулах: поток переживает задачу.
// Проверил на пуле из одного потока. Первая задача положила в ThreadLocal «данные юзера А», вторая задача - уже другой запрос - прочитала оттуда ровно эти данные. После вызова remove() второй запрос получает null. В пулах remove в finally обязателен, это классика инцидентов с утечкой чужих данных.
- сброс флага
- InterruptedException гасит признак прерывания
- ThreadLocal
- своё значение на поток, в пуле требует remove
Как отвечать: «Два потока делают count++. Что пойдёт не так?»
Инкремент это составная операция: прочитать, увеличить, записать. Два потока читают одно значение и записывают одинаковый результат, часть инкрементов теряется. Я это специально гонял: два потока по двести тысяч инкрементов давали 395 754, потом 399 659, а в третий раз ровно 400 000 - то есть гонка ещё и воспроизводится не каждый раз, что делает её опаснее. Отдельно отмечу, что volatile тут не спасает: он даёт видимость и запрет переупорядочения, но не атомарность, и в моих прогонах volatile-счётчик терял ровно так же. Рабочие варианты три. AtomicInteger с incrementAndGet - это сравнение с обменом без блокировок, у меня он давал точные 400 000 всегда. Ключевое слово synchronized - если инкремент часть большей связки, которая должна быть атомарной целиком. И LongAdder - под очень высокой конкуренцией, он расщепляет значение по ячейкам и не страдает от борьбы за одну ячейку.
Сильный ответ: механика потери названа точно, развеян миф про volatile, и даны три инструмента с критериями выбора. Замечание про то, что гонка воспроизводится не всегда, показывает человека, который её ловил, а не читал про неё.
На чём валят
- −Зовут run() вместо start(). Код выполняется в текущем потоке, и это спрашивают почти на каждом собеседовании.
- −Считают инкремент атомарным «потому что он маленький». Это три операции, и обновления теряются.
- −Пишут volatile на счётчик. Видимость появилась, гонка осталась - у меня терялись значения и с ним.
- −Глушат InterruptedException пустым catch. Флаг при этом уже сброшен, и поток становится неостановимым.
- −Оставляют ThreadLocal без remove в пуле. Второй запрос в том же потоке читает данные первого.
Проверьте себя
Пять вопросов из банка по этой подтеме. Всего их 16, остальные разбираются в тренажёре.
- В каком состоянии находится поток, вызвавший wait() внутри synchronized?A)RUNNABLE — поток продолжает активно занимать процессор, периодически проверяя условие в циклеB)WAITING (или TIMED_WAITING) — ждёт notify, освободив мониторC)BLOCKED — он удерживает монитор объекта и не отдаёт его, блокируя всех остальных до пробужденияD)TERMINATED — вызов wait() завершает поток, и продолжить его выполнение после этого уже не получится
показать ответ и разбор
+B)WAITING (или TIMED_WAITING) — ждёт notify, освободив монитор// разбор: wait() переводит поток в WAITING (или TIMED_WAITING с таймаутом) и при этом ОСВОБОЖДАЕТ монитор объекта — иначе никто не смог бы войти в synchronized и вызвать notify. Потом notify/notifyAll будят один/всех ожидающих, и разбуженный, повторно захватив монитор, продолжает. Состояния потока: NEW, RUNNABLE, BLOCKED (ждёт монитор), WAITING/TIMED_WAITING, TERMINATED.
- Что предпочесть для задачи потока: реализовать Runnable или наследовать Thread?A)Thread — наследование даёт прямой доступ к внутренностям планировщика и потому работает быстрееB)Разницы нет: Runnable и Thread взаимозаменяемы, компилятор превращает их в одинаковый байткодC)Runnable — задача отделена от механизма потока, класс свободен для наследованияD)Thread — только он позволяет запускать задачу повторно много раз, а Runnable рассчитан на один запуск
показать ответ и разбор
+C)Runnable — задача отделена от механизма потока, класс свободен для наследования// разбор: Runnable (или Callable) отделяет ЗАДАЧУ от механизма исполнения: тот же Runnable можно отдать в пул потоков, обернуть, переиспользовать, а класс остаётся свободным для наследования (в Java оно одиночное). Наследование Thread жёстко связывает логику с конкретным потоком и расходует единственное наследование зря. Поэтому почти всегда пишут Runnable/Callable и отдают их ExecutorService.
- Чем поток-демон (daemon) отличается от обычного?A)Демон имеет наивысший приоритет планировщика и вытесняет обычные потоки при конкуренции за CPUB)Демон — это поток, который выполняется в отдельном процессе ОС и не разделяет память с приложениемC)Демон обязательно должен завершиться раньше главного потока, иначе JVM бросит исключение при выходеD)JVM завершается, не дожидаясь демонов, когда живы только они
показать ответ и разбор
+D)JVM завершается, не дожидаясь демонов, когда живы только они// разбор: Поток-демон — фоновый: JVM завершает работу, как только не осталось ни одного НЕ-демон-потока, обрывая демонов на месте (их finally может не выполниться!). Обычные (user) потоки, наоборот, держат JVM живой до своего конца. Демонами делают вспомогательные задачи (сборка статистики, heartbeat), которые не должны мешать выходу. setDaemon(true) вызывают до start().
- Чем sleep() принципиально отличается от wait()?A)sleep() не отпускает монитор и не требует synchronized; wait() отпускает и требуетB)Sleep() освобождает монитор объекта на время паузы, а wait() удерживает его, блокируя другие потокиC)Оба метода эквивалентны, разница лишь в том, что sleep() задаётся в секундах, а wait() — в мсD)Sleep() можно прервать только методом notify(), а wait() пробуждается автоматически по истечении времени
показать ответ и разбор
+A)sleep() не отпускает монитор и не требует synchronized; wait() отпускает и требует// разбор: sleep() — статический метод Thread: просто усыпляет ТЕКУЩИЙ поток на заданное время, НЕ освобождая удерживаемые мониторы, и не требует synchronized. wait() — метод Object: освобождает монитор объекта и ждёт notify/notifyAll (или таймаут), поэтому вызывается только внутри synchronized по этому объекту. sleep — про паузу, wait — про координацию по условию. Оба реагируют на interrupt.
- Что делает t.join() в вызывающем потоке?A)Немедленно прерывает поток t и заставляет его закончиться, откуда бы он ни был вызван в кодеB)Блокирует текущий поток, пока t не завершитсяC)Объединяет два потока в один, после чего они продолжают выполнять оставшийся код совместноD)Повышает приоритет потока t, чтобы планировщик завершил его раньше остальных активных потоков
показать ответ и разбор
+B)Блокирует текущий поток, пока t не завершится// разбор: join() заставляет ВЫЗЫВАЮЩИЙ поток ждать, пока поток t не завершится (можно с таймаутом). Это простой способ дождаться результата фоновой работы перед продолжением. Внутри реализован через wait на объекте потока. Часто заменяется на Future.get()/CompletableFuture в связке с ExecutorService, где ожидание результата встроено.
дальше
Теорию прочитали. Навык ставится повторением
В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.