Серия подготовки: к обзору тем Java-собеседования.

Многопоточность Java нужна, когда программа ведёт несколько дел: принимает запрос, читает файл, ждёт ответ другого сервиса. На собеседовании просят объяснить, как эти действия делят данные и почему результат остаётся правильным. В этой статье разберём 22 вопроса: от запуска потока до цепочек задач и виртуальных потоков.

Сначала различим три вещи. Программа — написанные инструкции. Запущенная программа работает в процессе, которому операционная система выделяет память и другие ресурсы. Внутри процесса несколько потоков исполнения могут проходить свои последовательности инструкций, обращаясь к общим объектам. Задача «скачать отчёт» — работа; поток — тот, кто её выполняет.

Несколько потоков не обязательно работают одновременно физически: на одном ядре процессора они могут чередоваться, на нескольких — выполняться параллельно. В обоих случаях действия перемешиваются, поэтому важен порядок доступа к данным. Примеры учебные; основа — Java 17/21. Виртуальные потоки доступны как окончательная возможность с Java 21, изменение работы с synchronized отдельно отмечено для JDK 24.

Потоки и модель памяти

1. Чем start() отличается от run()?

У объекта Thread есть метод run() с работой, которую предстоит выполнить. Если вызвать его напрямую, текущий поток просто зайдёт в этот метод и вернётся после окончания. Нового участника исполнения не появится. Это похоже на ситуацию, когда вы сами выполняете поручение, написанное для коллеги.

start() просит запустить отдельный поток; уже он выполнит run(). Вызывающий поток получает возможность продолжать свою работу. Кто раньше дойдёт до следующей строки, заранее не обещано: это зависит от распределения процессорного времени.

Если из main напрямую вызвать loader.run(), имя текущего потока внутри останется main, даже если объект назван loader. После loader.start() работу выполняет новый поток. Подвох: один объект Thread запускают только один раз. Для новой работы создают новый поток или передают задачу исполнителю, который рассмотрен ниже.

2. RUNNABLE означает, что поток сейчас работает на процессоре?

Представьте несколько готовых поручений и одного свободного исполнителя: готовность ещё не означает, что очередь дошла до вашего поручения. Так и RUNNABLE — состояние Java, в котором поток может исполняться или ждать процессорного времени. Это не точное описание действий операционной системы в эту наносекунду.

BLOCKED имеет более узкий смысл: поток ждёт встроенный замок объекта, называемый монитором, чтобы войти в synchronized. Состояния WAITING и TIMED_WAITING связаны с другими видами ожидания, без срока и с ограничением времени. Ожидание результата задачи или явного замка может выглядеть именно так.

При зависании смотрят ещё и стек вызовов — перечень методов, в которые вошёл поток и из которых пока не вернулся. Он помогает понять, чего поток ждёт и кто способен его разбудить. Бесконечный вычислительный цикл тоже мешает программе, хотя его поток может оставаться RUNNABLE.

3. Чем гонка данных отличается от нарушения бизнес-правила?

Допустим, два потока используют один счётчик. Один записывает новое значение, другой его читает. Им нужно согласовать доступ: какие изменения второй обязан увидеть и после какого действия. Если к одной переменной есть конфликтующие обращения, хотя бы одно из них — запись, а нужной связи порядка нет, возникает гонка данных. Точную связь Java называет happens-before; следующий ответ объясняет её отдельно.

Но даже согласованные отдельные обращения не защищают всё правило. На складе осталась одна коробка. Два покупателя по очереди проверили остаток и оба увидели единицу, а затем каждый оформил покупку. Каждая проверка могла быть защищена правильно; ошибка в том, что проверку и резервирование разделили.

Здесь нужна атомарность: вся выбранная операция должна выглядеть для других участников как неделимое действие. Другой поток не должен вклиниться между проверкой и резервом. На интервью сначала назовите правило «последнюю коробку получает один покупатель», затем границы его защиты. Слова «этот класс потокобезопасный» сами по себе этого не объясняют.

4. Что гарантирует happens-before?

Поток записал результат в поле объекта. Отсюда ещё не следует, что другой поток при произвольном чтении обязан увидеть именно эту запись: Java допускает оптимизации выполнения и обращения к памяти. Видимость отвечает на вопрос «какие изменения читатель обязан учитывать». Порядок — «какое действие передаёт ему результаты предыдущих действий». Эти правила входят в модель памяти Java, сокращённо JMM.

Happens-before — предусмотренная моделью связь, по которой можно доказать такую передачу. Например, первый поток вышел из блока под монитором, второй затем захватил тот же монитор: записи первого до освобождения доступны второму после захвата. Другие связи: запись и последующее чтение одной volatile-переменной; подготовка данных перед start(); действия завершившегося потока после успешного ожидания через join().

Связи соединяются в цепочку: если A связан с B, а B с C, гарантия доходит до C. Это не секундомер: «записали на секунду раньше» без нужного механизма ничего не доказывает. sleep() лишь даёт паузу; случайно удачный запуск и добавление записи в журнал не заменяют согласованный способ передачи данных.

Как рассуждать о данных, доступных нескольким потокам
  1. Кто использует данные

    Назовите читателей и писателей. Возможно, каждому достаточно собственной копии.

  2. Как передаётся изменение

    Найдите конкретную связь: тот же замок, volatile-флаг или получение результата задачи.

  3. Что нельзя разделять

    Объедините проверку и изменение, если между ними никто не должен вмешаться.

  4. Когда заканчивается работа

    Определите, кто ограничивает ожидание, получает ошибку и освобождает ресурсы.

5. Для чего volatile подходит, а для чего нет?

Представьте общую карточку: сначала в ней записывают ответ, затем ставят отметку «готово». Другой поток читает отметку и только после неё берёт ответ. volatile на поле отметки задаёт нужную связь видимости и порядка. Это не запрет остальным потокам работать с карточкой и не замок вокруг всех её полей.

В учебном объекте result хранит число, ready — признак готовности. По умолчанию у полей значения 0 и false. Один поток один раз вызывает publish(), другой — readIfReady() у этого же объекта. Его методы только объявлены в примере: сам класс потоки не запускает.

class ResultBox {
    int result;
    volatile boolean ready;

    void publish() {
        result = 42;
        ready = true;
    }

    int readIfReady() {
        return ready ? result : -1;
    }
}
  1. Писатель присваивает result число 42, затем записывает true в volatile ready.
  2. Читатель сначала проверяет ready. Пока он видит false, метод возвращает -1: результат ещё не принят как готовый.
  3. Если читатель увидел записанное true, связь через флаг передаёт и предыдущую запись результата. Метод возвращает 42.

Условия важны: один писатель, готовность не сбрасывают и число затем не меняют. Подвох: volatile-ссылка на список помогает передать саму ссылку — значение, через которое находят объект. Она не делает все дальнейшие добавления в этот список безопасными. Ссылка и содержимое объекта требуют разных рассуждений.

6. Почему volatile count++ теряет обновления?

Короткая запись count++ скрывает три шага: прочитать число, прибавить единицу и записать результат. Пусть было 5. Первый поток прочитал 5, второй тоже прочитал 5. Оба вычислили 6 и по очереди записали его. Выполнены два увеличения, а итог увеличился только на единицу.

volatile делает отдельные обращения согласованными, но не объединяет три шага. AtomicInteger.incrementAndGet() предоставляет атомарное увеличение одного счётчика. Если правило связывает несколько полей, например «остаток» и «резерв», отдельного атомарного счётчика мало: нужен общий механизм, защищающий всё действие.

LongAdder распределяет обновления между внутренними счётчиками и может быть полезен для статистики при большом числе одновременных записей. Однако sum() во время обновлений не даёт атомарного снимка. Для решения «хватает ли средств на списание» это существенное ограничение. У ошибочного volatile count++ нет одного обязательного неправильного вывода: иногда нужное число получится случайно. Именно гарантии результата здесь отсутствуют.

Публикация, блокировки и ожидание

7. Что блокирует synchronized?

Представьте ключ от комнаты, в которой меняют общие данные. У кого ключ, тот выполняет защищённую работу; желающие взять этот же ключ ждут. В Java таким ключом служит монитор конкретного объекта. synchronized (lock) получает монитор объекта, на который указывает lock, а при выходе освобождает его, в том числе если внутри возникло исключение — сигнал о сбое.

У синхронизированного метода экземпляра замком служит this, то есть сам экземпляр, на котором вызвали метод. У статического синхронизированного метода — объект Class, представляющий соответствующий класс. Поэтому два разных экземпляра с методами synchronized не получают автоматически один общий замок.

Подвох: монитор не запрещает любые обращения к объекту. Несинхронизированный метод по-прежнему может читать его поля. Защита работает, когда все участники придерживаются одного соглашения: перед доступом к выбранным данным берут тот же замок. При ответе называйте и данные, и объект, чей монитор их защищает.

8. Можно ли защищать чтение и запись разными замками?

Два ключа от разных комнат не образуют очередь у одной двери. Если писатель захватывает writeLock, а читатель — независимый readLock, они могут пройти одновременно. Само присутствие synchronized в обоих местах не обеспечивает ни взаимное исключение, ни нужную передачу изменений.

ReadWriteLock устроен иначе: его части чтения и записи координируются одним механизмом. Он разрешает определённые совместные чтения и исключительный доступ для изменения. Это специальный инструмент, а не два произвольных объекта с удачными именами.

Нельзя бездумно менять и объект замка. Если поле lock заменили, один поток может продолжать держать старый монитор, а другой взять новый. Отдельное private final Object lock сохраняет стабильный объект для этого соглашения. И даже один правильный замок нужно удерживать достаточно долго: чтение двух связанных полей по отдельности может дать пару из разных состояний.

9. final делает объект безопасным для совместного использования?

Поле final нельзя заново присвоить после его инициализации. Если в поле лежит ссылка на список, это запрещает заменить ссылку другим списком, но не запрещает добавлять элементы в уже выбранный список. Неизменяемая ссылка и неизменяемый объект — разные свойства.

При создании объекта сначала выполняется конструктор. Если прямо из него передать this другому потоку, тот может получить объект, создание которого ещё не завершено. Передачу ссылки другим участникам называют публикацией. Для корректно сконструированного объекта есть специальные гарантии видимости final-полей; преждевременная передача this нарушает необходимые условия.

Публикация и дальнейшее изменение остальных полей всё равно требуют своего соглашения. В double-checked locking, то есть ленивом создании с проверкой до замка и повторной проверкой внутри, ссылка должна быть volatile. Проверка снаружи экономит вход в замок, внутренняя предотвращает создание второй копии. Для простого общего объекта часто понятнее статический класс-держатель: Java сама координирует его инициализацию. Ни один вариант не делает произвольные последующие изменения безопасными автоматически.

10. Почему wait вызывают в while, а sleep не заменяет wait?

Читателю нужно сообщение, которого ещё нет. Если он будет непрерывно проверять поле, то потратит процессорное время. Если заснёт, удерживая нужный писателю замок, писатель не сможет положить сообщение. wait() решает обе части: временно прекращает работу и освобождает монитор того объекта, у которого вызван.

Ниже lock — стабильный общий объект замка, message — общие данные, consume — их обработка. Писатель тоже захватывает lock, присваивает сообщение и вызывает lock.notifyAll(). Вызов wait() может бросить InterruptedException; окружающий метод должен обработать его или передать выше.

synchronized (lock) {
    while (message == null) {
        lock.wait();
    }
    consume(message);
}
  1. Читатель получает монитор lock и проверяет, есть ли сообщение. Значение null означает его отсутствие.
  2. Если сообщения нет, wait() освобождает этот монитор и переводит читателя в ожидание. Теперь писатель может войти под тот же замок.
  3. После пробуждения читатель заново получает монитор и снова проверяет условие. Лишь когда сообщение есть, вызывается consume(message).

Почему именно while? Другой читатель мог забрать сообщение раньше; допустимо и ложное пробуждение без нужного события. notifyAll() только уведомляет ожидающих: сам замок писатель освободит при выходе из блока. sleep(), напротив, замок не отдаёт. Для обычной очереди работ проще взять BlockingQueue — очередь с готовыми правилами ожидания, чем заново писать этот протокол.

11. Когда нужен ReentrantLock и зачем unlock в finally?

Встроенный монитор подходит, когда достаточно дождаться входа и выполнить блок целиком. ReentrantLock полезен, если нужно попробовать получить замок без бесконечного ожидания, задать срок ожидания или разрешить его прервать. Он также поддерживает Condition — отдельные условия ожидания, например «очередь не пуста» и «в очереди есть место».

За дополнительные возможности отвечает вызывающий код. После успешного lock() работу помещают в try, а unlock() — в finally, блок завершения при выходе из работы. Иначе исключение может оставить замок у потока и заставить остальных ждать. Если tryLock() вернул false, замок не получен и освобождать его нельзя.

Настройка fairness даёт предпочтение дольше ожидающим при соответствующих способах захвата, но не управляет общим расписанием потоков. Обычный tryLock() без времени ожидания этой настройке не подчиняется. Справедливое обслуживание может снизить общую скорость. Выбор объясняют нужной возможностью, а не правилом «этот замок всегда быстрее».

12. Как возникает deadlock и как его отличить от долгого ожидания?

Первый поток держит замок склада и хочет замок оплаты. Второй уже держит оплату и хочет склад. Каждый ждёт того, что освободит другой, а до освобождения дойти не может. Такая взаимная блокировка называется deadlock. При обычном долгом ожидании работа ещё способна закончиться сама; здесь образовался замкнутый круг.

Для этих двух замков помогает общий порядок: все сначала берут склад, затем оплату. Тогда описанный круг не образуется. Иногда лучше убрать вложенный захват и передать работу через очередь. JVM не обязана сама исправлять любой deadlock.

Проверяют ситуацию по thread dump — снимку состояний и стеков потоков, где видны ожидаемые замки и их владельцы. Ограничение ожидания Future.get(timeout) означает только «вызывающий поток больше не ждёт этот результат». Оно само не освобождает чужие замки и не устраняет круг внутри выполняющейся задачи.

13. ThreadLocal изолирует данные запроса навсегда?

Допустим, каждому работнику выдали личный ящик для текущего номера заказа. ThreadLocal похож на такой ящик: одно и то же обращение к нему в разных потоках получает значения, связанные с соответствующим потоком. Но привязка сделана к работнику, а не к заказу.

В пуле потоков работник не уходит после одной задачи: тот же поток берёт следующую. Если номер прошлого заказа не удалён, новая задача может его прочитать, а большое оставленное значение — продолжать занимать память. Для данных одной задачи задают срок жизни и вызывают remove() в finally.

При переходе в другой поток значение не переносится автоматически. InheritableThreadLocal задаёт наследование начального значения при создании дочернего потока, а не при каждой передаче задачи уже существующему. Это также не обещание глубокой копии изменяемого объекта. Если цепочке работ нужен контекст запроса, нужно понимать, кто его передаёт и очищает. Множество виртуальных потоков не делает большие значения в их ThreadLocal бесплатными.

Пулы, отмена и результаты

14. maximumPoolSize ограничивает очередь задач?

Исполнитель, или executor, принимает задачи и организует их запуск. Пул потоков переиспользует работников; очередь хранит ещё не начатые поручения. Число работников и длина очереди — два разных ограничения. Можно иметь двух работников и очередь из миллиона ожидающих задач, каждая из которых удерживает данные в памяти.

У ThreadPoolExecutor сначала создаются работники до corePoolSize. Затем новая задача предлагается очереди. Если очередь её не приняла, возможно увеличение числа работников до maximumPoolSize; после достижения ограничений действует политика отказа. Поэтому неограниченная очередь способна расти, пока максимальное число потоков вообще не используется.

При настройке решают, сколько задач допустимо выполнять и хранить, а также что делать при перегрузке: сообщить об отказе, отбросить работу либо выполнить её в потоке отправителя. Эти исходы имеют разный смысл. Формула «число ядер плюс один» не учитывает ожидание сети, соединения с базой и требования к срокам ответа. Нужны условия нагрузки и измерения.

15. shutdown, shutdownNow и interrupt гарантированно останавливают работу?

shutdown() похож на закрытие приёма поручений: новые задачи больше не принимаются, уже принятые могут закончиться. Он сам не означает, что все они уже завершились. shutdownNow() пытается прервать выполняющиеся задачи и возвращает ещё не начатые. Если требуется дождаться остановки, это отдельный шаг, например через awaitTermination() с подходящим сроком.

Прерывание, interrupt, — просьба потоку прекратить текущую работу, а не безопасное принудительное уничтожение. Задача должна сотрудничать: проверять признак прерывания либо завершаться после InterruptedException из прерываемого ожидания. Если цикл игнорирует просьбу, вызов shutdownNow() не превращает его в остановившийся.

После перехвата InterruptedException метод может передать его выше. Когда контракт этого не позволяет, обычно восстанавливают признак через Thread.currentThread().interrupt() и выходят из текущей работы. Thread.interrupted() читает и очищает признак текущего потока; isInterrupted() проверяет без очистки. Молчаливое продолжение цикла стирает смысл отмены для вызывающей стороны.

16. Чем Future.get отличается от обычного чтения поля результата?

При отправке задачи результат ещё может отсутствовать. Future — объект, представляющий её будущий результат: через него узнают о завершении, получают значение или сведения об ошибке, запрашивают отмену. get() при необходимости ждёт. После успешного получения результата действует и предусмотренная гарантия видимости выполненной работы.

Обычное поле не сообщает, закончилось ли вычисление: ноль может быть и готовым ответом, и начальным значением. У него также нет автоматически заданного порядка передачи изменений. Future объединяет получение результата с установленным контрактом; конкретную ошибку вычисления нужно отличать от прерывания ожидания или отмены.

Но полезный параллелизм всё равно зависит от порядка действий. Если отправить задачу A, сразу ждать её через get() и лишь потом отправить независимую B, работы будут запущены последовательно. Чтобы дать обеим возможность выполняться, сначала отправляют обе, затем получают результаты. Если же B нужны данные A, эта зависимость реальна и исчезнуть от слова «асинхронно» не может.

Цепочки задач и общие данные

17. thenApply, thenCompose и thenCombine решают одну задачу?

CompletableFuture позволяет заранее описать, что делать после появления результата, вместо немедленного ожидания. Такой шаг цепочки называют стадией. thenApply берёт полученное значение и вычисляет обычное новое значение. thenCompose нужен, когда следующий шаг сам возвращает future. thenCombine объединяет два независимых результата.

В учебном примере уже известно количество упаковок: три. Метод completedFuture(3) создаёт сразу завершённый результат, ожидания сети здесь нет. По количеству рассчитывается доставка по условным 40 единиц за упаковку; следующий шаг для демонстрации тоже возвращает future.

CompletableFuture<Integer> packs = CompletableFuture.completedFuture(3);
CompletableFuture<Integer> delivery = packs.thenCompose(count ->
    CompletableFuture.completedFuture(count * 40));
System.out.println(delivery.join()); // 120
  1. packs содержит готовое значение 3. Параметр count в записи count -> ... получит это число.
  2. Следующий шаг вычисляет 3 * 40 и возвращает завершённый future со значением 120.
  3. thenCompose связывает оба шага так, что delivery представляет итоговое число. join() получает его, поэтому выводится 120.

С тем же телом thenApply дал бы future, внутри которого лежит ещё один future: результат функции сам оказался обёрткой. Для двух независимых вычислений подходит thenCombine. Если вместо этого спрятать блокирующий get() внутрь преобразования, рабочий поток может простоять в ожидании, а зависимость станет менее очевидной.

18. thenApplyAsync всегда запускает новый поток?

Нет. Async означает передачу действия выбранному исполнителю. Исполнитель может использовать уже существующий рабочий поток. Получить новый шаг цепочки и создать новый поток — разные действия, как принять поручение и нанять нового работника.

У стандартного CompletableFuture async-методы без явно переданного executor обычно используют ForkJoinPool.commonPool() — общий пул, доступный разным частям приложения. API отдельно оговаривает случай недостаточного параллелизма этого пула и возможность изменить исполнителя в подклассе. Поэтому без условий нельзя обещать конкретное имя потока.

Обычный thenApply может выполнить функцию в потоке, который завершает предыдущую стадию, а при уже готовом результате — прямо при добавлении обработчика. Длительное ожидание внешнего ответа в общем пуле отнимает работника у других задач. Выбирают исполнителя вместе с пределами нагрузки и ожидания, а не просто добавляют Async ко всем вызовам.

19. Где окажется ошибка и отменяет ли cancel(true) вычисление CompletableFuture?

Если задача завершилась сбоем, её future сохраняет этот исход. Для обычной ошибки вычисления get() бросает проверяемый ExecutionException, а join() — непроверяемый CompletionException; исходную причину смотрят внутри. Отмена отдельно сообщается через CancellationException. Это важно: ошибка вычисления, отменённый результат и прерванное ожидание — разные ситуации.

handle получает результат и ошибку и позволяет сформировать новый исход. whenComplete удобно для наблюдения, например записи сведений о завершении; сам по себе он не превращает сбой в успех. Возвращать придуманное успешное значение вместо ошибки можно лишь тогда, когда это соответствует смыслу операции.

У CompletableFuture.cancel(true) параметр не прерывает породившее результат вычисление: этот объект может не управлять выполняющейся задачей напрямую. Отменяется сам результат. Так же и прекращение ожидания по времени не означает прекращения HTTP-запроса или работы базы. Чтобы внешняя операция действительно закончилась, у её клиента должны быть собственные сроки ожидания и предусмотренный способ отмены.

20. Достаточно заменить HashMap на ConcurrentHashMap?

Map хранит значения по ключам, например отчёт по его номеру. ConcurrentHashMap обеспечивает безопасные отдельные операции нескольких потоков. Но между containsKey(k) и put(k, value) другой поток всё ещё может вставить запись. Замена класса не объединяет две строки в одно действие.

Для правила одного ключа есть готовые атомарные операции: putIfAbsent добавит только отсутствующее значение, computeIfAbsent вычислит его при отсутствии, compute и merge позволяют согласованно обновлять. Правило, связывающее несколько ключей, требует отдельного проектирования.

Функция вычисления должна быть короткой и не менять эту же карту рекурсивно. computeIfAbsent не означает «функция вызовется один раз за всю жизнь ключа»: запись могут удалить, функция может вернуть null или бросить исключение. Обход карты при изменениях не даёт замороженного снимка всех записей. И безопасное хранение ссылки на список не делает одновременные изменения самого списка безопасными.

Виртуальные потоки и практическая проверка

21. Когда virtual threads помогают и что меняется с synchronized?

Представьте тысячи задач, каждая из которых недолго считает и долго ждёт ответ сети. Держать для каждой тяжёлый поток операционной системы затратно. Виртуальные потоки Java 21+ позволяют описать такие работы как обычные последовательности действий, а Java распределяет их исполнение между меньшим числом платформенных потоков.

Платформенный поток, на котором сейчас работает виртуальный, называется несущим, или carrier. При поддерживаемом ожидании ввода-вывода виртуальный поток может освободить carrier, и тот выполнит другую задачу. Это помогает обслужить больше ожидающих работ; сам расчёт на одном ядре от этого быстрее не становится. Для долгих чистых вычислений дополнительная польза не появляется автоматически.

В Java 21 блокирование внутри synchronized могло удерживать carrier — это называют pinning, закреплением. В JDK 24 реализация мониторов изменилась и устранила эту причину. Поэтому общее требование заменить все synchronized на ReentrantLock устарело. Вызовы кода вне Java — native-методов и foreign functions — остаются отдельным случаем; документация JDK 25 это отмечает. Оценивают версию, библиотеку и реальные ожидания, а не один устаревший совет.

22. Нужен ли пул виртуальных потоков и куда делось ограничение нагрузки?

Обычные платформенные потоки часто переиспользуют из-за стоимости создания. Виртуальный поток обычно создают на каждую задачу. Небольшой пул переиспользуемых виртуальных потоков вновь ограничил бы число ожидающих работ. Но это не означает, что база выдержит любое количество запросов или память станет бесконечной.

Ограничивают дефицитный ресурс отдельно: пул соединений — число доступных соединений с базой, семафор — число разрешений одновременно проходить в выбранную часть работы. В следующем учебном фрагменте внешних ресурсов нет: две задачи просто возвращают строки. Требуются Java 21+ и обработка либо объявление исключений от get() в окружающем методе.

try (var executor = java.util.concurrent.Executors.newVirtualThreadPerTaskExecutor()) {
    var first = executor.submit(() -> "warehouse");
    var second = executor.submit(() -> "delivery");
    System.out.println(first.get() + ":" + second.get());
}
  1. Создаётся исполнитель, который запускает каждую принятую задачу в новом виртуальном потоке.
  2. Оба submit() передают работу и возвращают future. Вызовы () -> "warehouse" и () -> "delivery" означают задачи без аргументов, возвращающие строки.
  3. Главный поток получает сначала результат first, затем second и соединяет их двоеточием. Вывод — warehouse:delivery, даже если вторая задача закончилась первой.
  4. При выходе из try исполнитель автоматически закрывается и ожидает завершения задач. Зависшая работа способна задержать этот выход.

В настоящей программе к типу потока добавляют сроки ожидания, обработку ошибок и ограничения внешних ресурсов. Порядок напечатанных строк здесь определяется соединением результатов в главном потоке, а не обещанным расписанием двух задач.

Таблица ниже собирает уже разобранные различия. Выбирайте механизм по тому, какое действие должно стать согласованным, а затем отдельно проверяйте оставшиеся ограничения.

Что защищает выбранный механизм
МеханизмОсновная задачаЧто он не обещает
volatileПубликация изменений и порядок обращений к переменнойАтомарность count++ или нескольких полей
synchronized / LockСогласованная работа под одним протоколом блокировкиОтсутствие взаимных блокировок при любом порядке захвата
AtomicIntegerАтомарное изменение одного значенияТранзакцию из нескольких независимых вызовов
Executor / FutureОрганизация выполнения и получение завершённого результатаОграниченную очередь и остановку любой задачи по отмене
Virtual threadsМасштабирование большого числа ожидающих задачБесконечные соединения и ускорение вычислений

Как проверить понимание перед интервью

Начните с одной общей переменной. Назовите поток, который пишет, поток, который читает, и конкретное действие, передающее изменение. Потом уточните, достаточно ли защитить одно чтение или нужно объединить несколько действий. Если объяснение звучит как «поставлю volatile везде», вернитесь к примеру с двумя увеличениями счётчика.

Дальше измените условие: добавьте второго писателя, удаление записи из карты или отмену задачи. Для цепочки future отдельно назовите, кто выполняет каждый шаг, где будет получена ошибка и какая именно операция прекращает ожидание. Удачный одиночный запуск помогает изучить пример, но не доказывает правильность всех возможных исполнений. Убедительный ответ опирается на объяснимый порядок действий и гарантии Java.