Backend · Java

Многопоточность в Java на собеседовании: что проверяют

За вопросами про synchronized и volatile проверяют одно — понимаете ли вы видимость. Разбираем happens-before и то, где заученный ответ разваливается.

Вопросов про многопоточность в подборках десятки: volatile, synchronized, разница между wait и sleep, зачем нужен ConcurrentHashMap, что такое CountDownLatch. Выучить полсотни определений тяжело, а главное — ненадёжно: список по своей форме состоит из независимых пунктов и не показывает, что между ними общего. А общее есть — почти все эти вопросы проверяют одно свойство. Ниже — какое именно и что из него следует.

Всё, что сказано о поведении, относится к Java SE 21.

Что на самом деле проверяют §

Свойство, вокруг которого построена вся тема, называется видимостью: увидит ли один поток то, что записал другой. Ответ спецификации звучит жёстче, чем ожидают: результат записи гарантированно виден чужому чтению только если между ними установлено отношение happens-before. Нет отношения — нет гарантии, и поток вправе читать старое значение сколько угодно долго.

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

Второе следствие важнее первого и звучит неприятно: отсутствие сбоя ничего не доказывает. Спецификация прямо разрешает исполнять действия не в том порядке, в котором они связаны отношением happens-before, — если результат совпадает с каким-то законным исполнением, перестановка законна. Программа с гонкой может годами работать правильно и сломаться от смены процессора или версии JVM. Поэтому «я запустил, всё считается верно» — не аргумент о корректности, а наблюдение об одном запуске.

Правило, из которого всё выводится §

Happens-before — это отношение порядка между действиями, а не свойство отдельной переменной. Формулировка спецификации короткая: если одно действие happens-before другое, то первое видимо второму и упорядочено раньше него.

Базовых источников этого отношения немного, и их стоит помнить списком, потому что дальше всё выводится из них:

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

Дальше работает транзитивность, и это самый полезный пункт: если действие A упорядочено раньше B, а B раньше C, то A упорядочено раньше C. Именно транзитивность объясняет, почему synchronized защищает не только то, что лежит внутри блока: все действия потока до выхода из монитора становятся видны любому потоку, который потом в этот монитор вошёл, — включая записи в обычные, не помеченные ничем поля.

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

Где volatile перестаёт помогать §

Граница проходит между двумя разными свойствами, и заученное определение её не показывает. volatile даёт видимость. Он не даёт атомарности составной операции.

Классический пример — увеличение счётчика. counter++ выглядит как одно действие, а состоит из трёх: прочитать, прибавить, записать. volatile гарантирует, что каждое из трёх работает со свежим значением, и ничего не гарантирует про промежуток между ними. Два потока читают одно и то же, оба прибавляют единицу, оба записывают — счётчик вырос на единицу вместо двух. Отсюда правило: volatile подходит для флага, который один поток пишет, а другие читают, и не подходит для значения, которое вычисляется из самого себя. Для второго случая есть AtomicInteger и synchronized.

Есть и второй разрез, менее известный и потому полезный. Спецификация выделяет 64-битные типы: запись в не-volatile поле типа long или double рассматривается как две отдельные записи по 32 бита. Практическое следствие — поток может увидеть половину значения от одной записи и половину от другой, то есть число, которого никто никогда не записывал. Пометка volatile эту проблему снимает: чтения и записи volatile-полей типа long и double атомарны всегда.

Полезно назвать и границу этого утверждения: на ссылки оно не распространяется — их чтение и запись атомарны независимо от разрядности. Речь именно о значениях, а не об объектах.

Два вопроса, на которых это проверяют на практике §

Разбор двухсот сорока семи собеседований, опубликованный в 2026 году, описывает блок многопоточности как три захода подряд: volatile и happens-before, затем synchronized на статическом и на обычном методе, затем потокобезопасный синглтон. Первое разобрано выше; два оставшихся — это то же правило, приложенное к коду.

На каком объекте берётся монитор. Спецификация отвечает буквально одной строкой: обычный synchronized-метод захватывает монитор объекта, на который ссылается this; статический — монитор объекта Class этого класса. Отсюда следствие, ради которого вопрос и задают: это два разных монитора, и они не мешают друг другу. Статический и обычный синхронизированные методы одного класса могут выполняться одновременно, и общее состояние между ними не защищено ничем.

Потокобезопасный синглтон с двойной проверкой. Здесь сходятся оба свойства сразу, и потому этот пример — хорошая проверка того, что правило усвоено, а не заучено. Поле должно быть volatile, и причина выводится из того, что уже сказано: без него между записью ссылки в поле и её чтением другим потоком нет happens-before. А значит, чужой поток вправе увидеть ссылку раньше, чем записи, сделанные конструктором, — то есть получить ссылку на объект, который ещё не достроен. Проверка if (instance != null) при этом пройдёт успешно, и наружу уйдёт полуинициализированный объект.

Из той же логики следует, почему одной внешней проверки без блокировки мало, а одной блокировки без volatile — недостаточно: блокировка даёт взаимное исключение, volatile даёт видимость, и двойная проверка требует обоих.

Почему java.util.concurrent — то же самое правило §

Пакет java.util.concurrent кажется набором независимых инструментов: очереди, пулы, защёлки, барьеры. На собеседовании полезнее другая рамка, и она взята прямо из его документации: весь пакет описан как расширение happens-before на высокоуровневые конструкции. Не новый механизм, а то же отношение, доведённое до очередей и исполнителей.

Формулировки документации стоит помнить, потому что из них ответы выводятся, а не вспоминаются:

  • действия до помещения объекта в конкурентную коллекцию упорядочены раньше действий после его извлечения другим потоком;
  • действия до передачи задачи исполнителю упорядочены раньше начала её выполнения;
  • действия внутри асинхронного вычисления упорядочены раньше того, что происходит после получения результата через Future.get();
  • действия до Lock.unlock, Semaphore.release, CountDownLatch.countDown упорядочены раньше действий после соответствующих lock, acquire, await.

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

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

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

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

Проверьте себя

Вопросы из разбора одним списком. Если на каждый есть ответ своими словами — тему можно считать закрытой.

  1. Что делает ключевое слово volatile?
  2. Что такое happens-before?
  3. Достаточно ли volatile для счётчика, который увеличивают из нескольких потоков?
  4. Синхронизированный статический метод и синхронизированный обычный метод одного класса — блокируют ли они друг друга?
  5. Зачем нужны конкурентные коллекции, если есть synchronized?
По этой теме на платформе

Готовьтесь не вслепую

Возьмите маршрут по своей роли и отрабатывайте ответы на вопросы с AI-разбором.