Records, sealed и switch в Java
Замер, который показывает, зачем всё это. 20 000 задач, каждая ждёт 50 миллисекунд. На виртуальных потоках прогон занял 0,51 секунды. На пуле из 200 обычных потоков - 5,08 секунды, ровно вдесятеро дольше. Машина одна и та же, две ядра, код задачи идентичный.
Вопросы про версии с 14 по 21 проверяют одно: живёшь ли ты в актуальном языке или пишешь код восьмёрки в двадцать первой. Компании сидят на долгих версиях 17 и 21, и от кандидата ждут свободного владения record, sealed и выражениями switch.
// Формулировки: «чем record отличается от класса?», «зачем sealed?», «virtual threads - когда помогут, а когда нет?»
record: объект-значение без обвязки
record - неизменяемый носитель данных. Объявляешь компоненты и получаешь готовыми канонический конструктор, аксессоры, equals, hashCode и toString по значениям. Я напечатал: new Point(1, 2) выводится как Point[x=1, y=2], а две одинаковые точки равны и дают одинаковый хеш. Поля финальны, наследоваться от record нельзя.
Проверки кладут в компактный конструктор - это тело без списка параметров, которое выполняется до присваивания полей. Замер подтвердил: record Range с проверкой lo > hi ловит некорректный диапазон прямо в момент создания.
// И главный подвох: неизменяемость тут по ссылкам. Я создал record с обычным списком внутри, потом добавил элемент в исходный список снаружи - и содержимое record изменилось. Тот же record с List.copyOf в компактном конструкторе остался прежним. Хочешь честную неизменяемость - копируй коллекции.
record SafeBox(List<String> items) {
SafeBox { items = List.copyOf(items); } // копия, а не ссылка
}- компактный конструктор
- тело без параметров, выполняется до присваивания
- дырявая неизменяемость
- record хранит ссылку, а её содержимое мутируется
sealed и switch: компилятор следит за полнотой
Модификатор sealed закрывает иерархию: список permits перечисляет всех наследников, других не будет. Компилятор знает полный набор вариантов, поэтому switch по такой иерархии не требует ветки default.
И проверяет полноту всерьёз. Я убрал одну ветку из такого switch и получил ошибку компиляции: the switch expression does not cover all possible input values. То есть добавили в домен новый вид платежа - код не соберётся, пока не обработаешь его везде. Это то самое, ради чего конструкция и придумана.
Рядом живут выражения switch со стрелками (возвращают значение, без проваливания между ветками) и сопоставление с образцом: case по типам, разбор record на компоненты, условия when. Связка record плюс sealed плюс switch - идиома моделирования доменных вариантов.
// Сюда же instanceof с привязкой переменной: if (o instanceof String s) проверяет и приводит одной конструкцией. И var - вывод типа локальной переменной. Именно локальной: в поле класса var не поставить, я проверил, компилятор говорит 'var' is not allowed here. Тип при этом остаётся статическим, никакой динамики.
sealed interface Payment permits Card, Sbp {}
return switch (p) { // default не нужен
case Card c -> c.pan();
case Sbp s -> s.phone();
};- sealed и permits
- закрытый список наследников, известный компилятору
- сопоставление с образцом
- проверка типа и структуры с привязкой переменных
Виртуальные потоки: масштаб ожидания, а не вычислений
Вернёмся к замеру из начала. Виртуальные потоки - дешёвые потоки, которые виртуальная машина мультиплексирует поверх немногих потоков операционной системы, их называют несущими. Блокирующий вызов не занимает несущий поток: виртуальный отцепляется, несущий обслуживает других. Отсюда 0,51 секунды против 5,08 на пуле из двухсот - и арифметика сходится, 20 000 задач разделить на 200 потоков умножить на 50 миллисекунд как раз даёт 5 секунд.
Граница применимости жёсткая: это про пропускную способность ОЖИДАНИЯ. Задачам, которые считают, виртуальные потоки не дают ничего - ядер и несущих потоков столько же, вычисления быстрее не станут.
И вторая граница, про которую спрашивают отдельно. Блокировка внутри synchronized прибивает виртуальный поток к несущему. Замерил на 100 виртуальных потоках, каждый спит 50 миллисекунд со своей собственной блокировкой: под synchronized прогон занял 2,51 секунды, под ReentrantLock - 0,05 секунды. Разница в 50 раз, и она равна ровно числу ядер: под synchronized одновременно работали только два.
// Это поведение версии 21, в 24-й его починили. Но код обычно живёт на долгих версиях, поэтому правило пока такое: в местах, где виртуальный поток может заблокироваться, берём ReentrantLock, а не synchronized.
- виртуальный поток
- лёгкий поток для блокирующего ввода-вывода
- прибивание к несущему
- synchronized не даёт виртуальному потоку отцепиться
Как отвечать: «Чем record отличается от обычного класса и когда его берёшь?»
record это неизменяемый носитель данных: компоненты финальны, а канонический конструктор, аксессоры, equals, hashCode и toString генерируются по значениям, наследование запрещено. Беру его для объектов-значений: DTO (data transfer object), ключи мап, результаты методов, доменные значения вроде суммы денег. Валидацию кладу в компактный конструктор - тело без параметров, которое отрабатывает до присваивания полей. Про подвох помню и проверял его специально: неизменяемость у record по ссылкам, поэтому если компонент это коллекция, снаружи её можно изменить и содержимое record поедет следом. Лечится копией через List.copyOf прямо в компактном конструкторе. Не беру record там, где нужна изменяемая сущность с жизненным циклом - например, JPA (Java Persistence API) -сущности: у них контракт на мутабельность, пустой конструктор и прокси, record туда не годится по конструкции.
Сильный ответ: перечислена механика, названы конкретные места применения и показаны два подвоха - дырявая неизменяемость и несовместимость с сущностями. Это уровень «использовал», а не «слышал».
На чём валят
- −Считают record обычным POJO (plain old Java object). Сеттеров и наследования у него нет по замыслу, поля финальны.
- −Считают record с полем-списком неизменяемым. Список мутируется снаружи, спасает List.copyOf в компактном конструкторе.
- −Пишут default в switch по sealed «на всякий случай». Главный бонус потерян: компилятор перестаёт проверять полноту при добавлении наследника.
- −Пробуют var в поле или в сигнатуре. Компилятор отвечает 'var' is not allowed here - это только локальные переменные.
- −Ждут ускорения от виртуальных потоков на вычислениях. Их выигрыш только в ожидании, а synchronized и вовсе сводит его к числу ядер.
Проверьте себя
Пять вопросов из банка по этой подтеме. Всего их 20, остальные разбираются в тренажёре.
- Зачем нужны sealed-классы/интерфейсы (Java 17)?A)Чтобы запретить наследование класса — sealed делает его финальным, как ключевое слово finalB)Чтобы автоматически сгенерировать equals/hashCode для всех наследников иерархии сразуC)Чтобы явно ограничить список наследников (permits) — тогда switch по типу можно проверить на полнотуD)Чтобы разрешить множественное наследование классов, которое обычные классы в Java не позволяют
показать ответ и разбор
+C)Чтобы явно ограничить список наследников (permits) — тогда switch по типу можно проверить на полноту// разбор: sealed-тип явно перечисляет допустимых наследников через permits (а каждый наследник обязан быть final, sealed или non-sealed). Это даёт контролируемую, закрытую иерархию: компилятор знает ВСЕ варианты, поэтому switch по такому типу можно сделать исчерпывающим — если покрыты не все наследники, будет ошибка компиляции (а добавив новый наследник, вы сразу увидите, где switch пора дополнить). Отлично сочетается с record и pattern matching для моделирования «алгебраических» типов.
- Что даёт pattern matching для instanceof: if (o instanceof String s)?A)Проверку типа с одновременной привязкой и приведением к переменной s — без отдельного явного кастаB)Гарантию, что переменная o не окажется равна null: при null-значении instanceof в этой новой форме сразу бросит исключениеC)Сравнение o со строкой s по содержимому, заменяя собой вызов o.equals(s) в условииD)Автоматическое преобразование o в строку через toString(), даже если o другого типа
показать ответ и разбор
+A)Проверку типа с одновременной привязкой и приведением к переменной s — без отдельного явного каста// разбор: Раньше писали в два шага: if (o instanceof String) { String s = (String) o; ... }. Pattern matching совмещает проверку типа, приведение и объявление переменной: if (o instanceof String s) — внутри ветки s уже типа String, каст не нужен. Переменная связывается только там, где проверка истинна (можно использовать даже в условии: o instanceof String s && s.length() > 0). instanceof для null всегда даёт false (не бросает). Это убирает шумный каст и снижает риск ClassCast.
- Чем switch-выражение со стрелкой (case X -> ...) отличается от классического switch?A)Стрелочный switch работает только с enum, а классический — с типами, включая строкиB)Это выражение: возвращает значение, стрелка не проваливается (без break), для enum/sealed требует полнотыC)Стрелочный switch выполняет подряд все ветки ниже совпавшей, пока не встретит yield, — именно в этом и состоит его главное отличиеD)Разница чисто синтаксическая: -> и: компилируются в один и тот же байткод с fall-through
показать ответ и разбор
+B)Это выражение: возвращает значение, стрелка не проваливается (без break), для enum/sealed требует полноты// разбор: switch-выражение (Java 14+) можно использовать как значение: var r = switch (day) { case MON -> 1; ... };. Стрелочная форма case X -> не проваливается на следующую ветку (fall-through нет, break не нужен), а для сложной логики есть блок с yield. Как ВЫРАЖЕНИЕ по enum/sealed оно должно быть исчерпывающим — покрыть все варианты или иметь default, иначе ошибка компиляции. Классический switch с : и break этих гарантий не даёт и легко ловит баги на забытом break.
- Что проверяет компилятор у switch с pattern matching (Java 21) по sealed-типу?A)Что все case-ветки возвращают один и тот же тип значения, иначе switch не скомпилируетсяB)Что покрыты все разрешённые наследники sealed-типа (иначе — ошибка компиляции о неполноте)C)Что каждая ветка помечена break, — при pattern matching fall-through обязателен и контролируетсяD)Ничего дополнительно: switch по типам компилятор проверяет так же, как обычный switch по int
показать ответ и разбор
+B)Что покрыты все разрешённые наследники sealed-типа (иначе — ошибка компиляции о неполноте)// разбор: switch по типам (case Circle c -> ...) в паре с sealed-иерархией даёт проверяемую полноту: раз компилятор знает весь список permits, он требует покрыть ВСЕ подтипы (или добавить default) — иначе ошибка компиляции о неисчерпывающем switch. Огромный плюс: добавили новый наследник в sealed — и все switch, где его забыли, перестают компилироваться, указывая, что дополнить. Плюс привязка переменной (c уже нужного типа) и вложенные record-паттерны. Это делает моделирование вариантных типов безопасным.
- Что делает var (Java 10) при объявлении локальной переменной?A)Делает переменную динамически типизированной: в неё можно потом положить значение типаB)Объявляет переменную-заготовку без типа, тип присваивается позже при первом использованииC)Просит компилятор вывести статический тип из инициализатора; тип фиксируется и не меняетсяD)Создаёт нетипизированную ссылку Object, к которой затем нужно применять явные приведения типов
показать ответ и разбор
+C)Просит компилятор вывести статический тип из инициализатора; тип фиксируется и не меняется// разбор: var — это вывод типа локальной переменной (local variable type inference). Компилятор определяет СТАТИЧЕСКИЙ тип из правой части (var list = new ArrayList<String>(); → тип ArrayList<String>) и жёстко его фиксирует — динамики, как в скриптовых языках, тут нет: положить потом другой тип нельзя. Требуется инициализатор (var x; запрещено), работает только для локальных переменных — не для полей, параметров, возвращаемых типов. Это сахар для читаемости, не изменение системы типов.
дальше
Теорию прочитали. Навык ставится повторением
В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.