Серия подготовки: к обзору тем Java-собеседования.
Java Core — основы языка Java: как программа хранит данные, передаёт их в методы, сравнивает объекты и выбирает нужный метод. Знать написание команды недостаточно: на собеседовании могут попросить объяснить, почему она даёт именно такой результат. Здесь разберём восемнадцать вопросов, начиная с различия между переменной и объектом.
Материал рассчитан на того, кто уже видел переменные, классы и методы, но ещё не разбирался в их устройстве. Класс описывает, какие данные и действия есть у объекта; объект — отдельный экземпляр этого класса. Компилятор проверяет программу и переводит её в форму для выполнения. Ошибка компиляции возникает до запуска, а исключение сообщает о проблеме во время работы. Это различие понадобится в примерах ниже.
Основные примеры подходят для Java 17 и 21; отличия версий обозначены отдельно. Данные учебные. Сначала попробуйте предсказать результат каждого фрагмента, затем прочитайте объяснение. Вопрос с подвохом здесь означает, что одно изменённое условие требует уточнить знакомое правило.
- Переменные
Какой тип указан у каждой переменной: число, строка или другой объект?
- Действия
Какая команда сравнивает данные, вызывает метод или присваивает значение?
- Изменения
Меняется содержимое объекта или только то, на какой объект указывает переменная?
- Проверка
Что произойдёт, если данных нет или у переменной указан другой тип?
Значения, ссылки и равенство
1. Чем переменная примитивного типа отличается от ссылочной?
Переменная примитивного типа хранит само простое значение: например, int count = 3 хранит число 3. Ссылочная переменная хранит ссылку — значение, по которому программа получает доступ к объекту. Сам объект существует отдельно от переменной. Значение null означает, что переменная сейчас не указывает ни на один объект.
Если присвоить число одной переменной другой, скопируется число: изменение второй переменной не затронет первую. Если присвоить ссылку, обе переменные будут указывать на один объект. Представьте список покупок и два имени для него. Добавление товара через любое имя изменит общий список. Присваивание одному имени другого списка поменяет только эту переменную.
В примерах List<String> означает список строк: тип элемента указан в угловых скобках. ArrayList — один из классов, реализующих такой список. Ссылку удобно рисовать стрелкой от переменной к объекту. Считать её доступным Java-коду числовым адресом не нужно: размещением объектов занимается виртуальная машина, то есть среда выполнения Java.
2. Java передаёт объекты по ссылке или по значению?
Всегда по значению: метод получает копию переданного значения. Аргумент — то, что указано при вызове метода; параметр — переменная внутри метода, которая получает это значение. Для ссылочной переменной копируется ссылка. Поэтому параметр и переменная снаружи сначала дают доступ к одному объекту, но остаются разными переменными.
В примере вызывающий код создаёт пустой список и передаёт его в update. Метод add добавляет строку, а new ArrayList<>() создаёт отдельный пустой список. Пустые угловые скобки позволяют компилятору вывести тип элемента из окружающего кода.
static void update(List<String> names) {
names.add("Mira");
names = new ArrayList<>();
names.add("Oleg");
}
List<String> names = new ArrayList<>();
update(names);
System.out.println(names); // [Mira]- Перед вызовом внешний
namesуказывает на пустой список. - Параметр
namesполучает копию этой ссылки. Через негоadd("Mira")меняет общий список. - Присваивание
names = new ArrayList<>()направляет только параметр на второй список. В него добавляется Oleg. - После возврата внешний
namesвсё ещё указывает на первый список, поэтому печатается[Mira].
По этой же причине метод, который просто меняет местами два своих параметра, не переставит переменные вызывающего кода. Чтобы вызывающий код получил другой объект, метод может вернуть его, а вызывающая сторона — явно присвоить результат своей переменной.
3. Что сравнивает оператор ==?
Для примитивных значений == проверяет равенство значений с нужными преобразованиями типов: например, равны ли два числа. Для двух ссылок вопрос другой: это один и тот же объект? Результат также равен true, если обе ссылки — null. Два разных объекта с одинаковыми данными не обязаны быть равными по ==.
Например, две отдельно созданные карточки клиента могут содержать один номер, но оставаться двумя объектами. Для сравнения их содержания нужен метод, который знает, какие данные считать значимыми. Его роль обычно выполняет equals, разобранный далее.
Есть дополнительное условие: Integer — объект для хранения целого числа, а int — примитив. При сравнении Integer с int Java извлекает число из объекта. Извлечь число из null нельзя: возникнет NullPointerException, исключение об обращении к отсутствующему объекту. Сравнение двух Integer между собой обычно остаётся сравнением ссылок. Поэтому перед ответом назовите типы обеих сторон.
4. Что обещает equals и почему одной проверки полей мало?
equals отвечает, равны ли объекты по выбранному смыслу. Например, для карточек клиента этим смыслом может быть одинаковый номер клиента. Поле — переменная, принадлежащая объекту; метод сравнивает нужные поля. Но общий класс Object, от которого наследуются классы Java, по умолчанию сравнивает только идентичность объектов. Сравнение по содержимому нужно определить в своём классе.
У такого определения есть договорённости, или контракт. Объект равен себе — это рефлексивность. Если A равен B, то B равен A — симметричность. Если A равен B, а B равен C, то A равен C — транзитивность. При неизменных значимых данных повторная проверка должна давать прежний ответ; ненулевой объект при сравнении с null должен возвращать false.
Чтобы заменить унаследованное поведение, нужен метод equals(Object other). Запись equals(User other) создаёт дополнительный метод с другим типом параметра: это перегрузка, а не замена общего метода. Коллекция может обратиться к версии с Object. Пометка @Override просит компилятор проверить, что вы действительно заменяете существующий метод. При наследовании, когда один класс расширяет другой, отдельно решите, можно ли считать равными объекты родителя и потомка с дополнительными полями.
5. Как связаны equals и hashCode?
Если объекты равны по equals, их hashCode должен совпадать. Хеш, или хеш-код, — целое число, которое помогает быстро сузить поиск объекта. Метод hashCode() вычисляет это число. Оно не обязано быть уникальным: у разных объектов может получиться одинаковый хеш. Такое совпадение называют коллизией; после него равенство всё равно нужно проверить через equals.
Представьте поиск карточки клиента. Сначала номер хеша помогает выбрать небольшую группу карточек, затем сравнение данных находит нужную внутри группы. Если две равные карточки получат разные хеши, поиск может отправиться не в ту группу. Поэтому при изменении equals нужно согласованно определить hashCode. Подробный путь поиска разобран в статье о коллекциях и generics.
Постоянный хеш, например всегда 1, сам по себе не нарушает правило «равным — одинаковый». Но он отправляет множество разных ключей в одну область поиска и может замедлить операции. Хеш также не служит постоянным номером клиента: между запусками программы он может меняться. Внутри одного запуска он должен оставаться прежним, пока не меняются данные объекта, используемые в проверке равенства.
Строки, упаковка и неизменяемость
6. Почему String называют immutable?
Immutable означает «неизменяемый»: после создания содержимое конкретного объекта String нельзя переписать. Метод обработки строки возвращает результат, а исходная строка сохраняется. Переменной при этом разрешено присвоить ссылку на другой строковый объект. Это снова различие между изменением объекта и изменением переменной.
У учебного кода заказа есть пробелы по краям. trim() возвращает строку без таких пробелов, а length() сообщает длину. Посмотрим, что меняет присваивание результата.
String code = " ab-7 ";
code.trim();
System.out.println(code.length()); // 6
code = code.trim();
System.out.println(code.length()); // 4Сначала в строке шесть символов: пробел, четыре символа ab-7 и ещё пробел. Первый вызов trim() вычисляет укороченную строку, но код её не сохраняет. Переменная продолжает указывать на исходную, поэтому печатается 6. Во втором вызове результат присваивается code; теперь длина доступной через неё строки — 4.
Из этого не следует, что любой метод строки обязательно создаёт новый объект. Если менять ничего не нужно, метод может вернуть ту же строку. Гарантия другая: исходное строковое значение не изменяется. Это удобно, например, когда одну строку используют несколько частей программы или она служит ключом поиска.
7. Что такое пул строк и почему конкатенация меняет результат ==?
Пул строк — механизм совместного использования одинаковых строк. Строковый литерал — текст прямо в кавычках в программе. Для одинаковых литералов и строковых константных выражений Java использует одну общую строку с таким содержимым. Получение этой общей строки называют интернированием.
Конкатенация означает соединение строк через +. Компилятор может заранее соединить два литерала. Но обычная переменная в выражении меняет условие: соединение происходит во время работы программы. В примере текст результата одинаков, а объекты отличаются.
String first = "dev" + "eloper";
String part = "dev";
String second = part + "eloper";
System.out.println(first == "developer"); // true
System.out.println(first == second); // false
System.out.println(first.equals(second)); // trueВ первой строке обе части известны компилятору, поэтому first получает общую строку "developer". Проверка с таким же литералом даёт true. Переменная part не объявлена константой: second получается при соединении во время работы. Это другой объект, поэтому first == second даёт false. Метод equals сравнивает текст и возвращает true.
Если объявить part через final и подходящее константное значение, соединение сможет стать константным выражением. Метод intern() позволяет явно получить общую строку. Однако при проверке текста обычной программы используйте equals: нужное вам условие — совпадение символов, а не устройство пула.
8. Что происходит при boxing и unboxing?
Boxing, или упаковка, превращает примитивное значение в объект-оболочку: например, int в Integer. Unboxing, распаковка, извлекает значение обратно. Java часто делает это автоматически, поэтому переход незаметен в записи арифметики. Но у оболочки может быть значение null, а у примитивного числа — нет.
В настройке ниже количество повторов не указано. По условию задачи отсутствие означает одну попытку. Знак ? в условном выражении выбирает значение после него, если условие истинно; значение после : выбирается иначе. Выполняется только выбранная ветвь.
Integer retries = null;
// int next = retries + 1; // NullPointerException
int next = retries == null ? 1 : retries + 1;
System.out.println(next); // 1Закомментированная строка с retries + 1 не выполняется. Если её включить, Java попробует распаковать null и получит NullPointerException. Рабочая строка сначала проверяет retries == null. Условие истинно, поэтому выбирается 1; опасное сложение из второй ветви не выполняется. Вывод — 1.
Число 1 здесь задано требованиями учебного примера. В другой задаче отсутствие может означать ошибку настройки или запрет повторов. Распаковка не выбирает за вас смысл отсутствующего числа и не превращает его автоматически в ноль.
9. Можно ли полагаться на кэш Integer?
Кэш — повторное использование уже подготовленных объектов. При упаковке некоторых небольших константных чисел Java гарантирует общую оболочку; для целых чисел важен диапазон от −128 до 127 включительно. Поэтому ссылочное сравнение двух подходящих упакованных констант может неожиданно дать true.
Это не правило числового равенства. За пределами гарантированного диапазона среда выполнения может повторно использовать больше оболочек, но вы не должны на это рассчитывать. Важен и способ получения объекта. Утверждение «два Integer со значением 1000 всегда разные» тоже неверно: отсутствие гарантии совпадения не является гарантией различия.
Когда нужно проверить одинаковые числа в двух оболочках, удобен Objects.equals(a, b): он учитывает возможный null и сравнивает значения ненулевых объектов. Вызов a.equals(b) через отсутствующий a сам закончится исключением. Выбирайте проверку по смыслу задачи, а не по тому, на каких числах случайно сработало ==.
10. Делает ли final объект неизменяемым?
final у переменной означает, что после допустимого первого присваивания ей нельзя присвоить другое значение. Для ссылки это запрещает направить переменную на другой объект. Содержимое самого объекта такое ограничение не защищает: в список через final List<String> по-прежнему можно добавлять строки, если этот список поддерживает добавление.
Смысл слова зависит от места применения. final у класса запрещает создавать его потомков; у метода — заменять его реализацию в потомке; у переменной — повторно присваивать значение. Ни один из этих случаев сам по себе не доказывает неизменяемость всех данных объекта.
Например, private final-поле со списком закрыто от прямого доступа, но метод может вернуть ссылку на этот список. Получивший её код сможет вызвать add. Поле не переназначалось, однако содержимое объекта изменилось. Для неизменяемости нужно закрыть и такой путь.
11. Как сделать собственный объект immutable?
Нужно сделать так, чтобы после создания никто не мог изменить видимые данные объекта. Для этого заполняют поля при создании, не предоставляют изменяющих методов и не отдают наружу ссылки, через которые можно переписать внутренние данные. Если потомок способен нарушить эти правила, нужно также ограничить наследование.
Предположим, маршрут получает список остановок от вызывающего кода. Простое сохранение ссылки оставляет список общим: вызывающий код сможет добавить остановку позднее. Защитная копия — отдельный состав данных, созданный именно для устранения такого доступа. Вызов List.copyOf(stops) сохраняет состав списка на этот момент и возвращает список, который нельзя менять через его методы.
Но копируются ссылки на остановки, а не сами остановки со всеми их полями. Это поверхностное копирование. Если остановка — изменяемый объект и другой код поменяет её адрес, изменение будет видно и через новый список. Тогда потребуются неизменяемые остановки или отдельное копирование их данных. Проверяйте каждый путь изменения, а не только ключевые слова у полей.
Наследование, методы и доступ
12. Чем перегрузка отличается от переопределения?
При перегрузке есть несколько методов с одним именем и разными параметрами. Компилятор выбирает подходящий вариант по типам, известным ему в месте вызова. При переопределении класс-потомок заменяет унаследованный экземплярный метод — метод, вызываемый для конкретного объекта. Когда выбран такой метод, его реализация зависит от фактического класса объекта во время работы.
Сигнатура метода включает имя и типы параметров: здесь это describe(Object) и describe(String). В примере две версии describe принимают разные типы. Object — общий родительский тип: в его переменной можно хранить ссылку на строку, но компилятор видит именно объявление Object.
static String describe(Object value) { return "object"; }
static String describe(String value) { return "string"; }
Object value = "parcel";
System.out.println(describe(value)); // objectСтрока "parcel" записана в переменную value типа Object. При выборе перегрузки компилятор не обещает, что в этой переменной всегда строка. Поэтому он выбирает describe(Object), и результат — object. Слово static означает принадлежность методов классу; подробнее оно разобрано далее.
Перегрузка и переопределение могут участвовать в одном вызове, но на разных шагах: сначала компилятор выбирает сигнатуру, затем для экземплярного метода определяется реализация. Отдельно проверьте null: если две перегрузки принимают несвязанные ссылочные типы, например String и Integer, компилятор может не выбрать единственный подходящий метод. Тогда программа не запустится.
13. Можно ли переопределить static-метод?
Нет: static-метод принадлежит классу, а не отдельному объекту. У потомка может быть свой статический метод с тем же именем и подходящими параметрами; это называют скрытием метода родителя. Выбор здесь связан с типом, через который написан вызов, а не с фактическим объектом за ссылкой.
Представьте классы Parent и Child. Если переменная типа Parent указывает на Child, обычный переопределённый экземплярный метод способен вызвать реализацию Child. Статический метод через ту же переменную выбирается по Parent. Поэтому обращаться к статическим методам лучше через имя класса: так сразу видно, что отдельный объект для выбора не нужен.
Поля тоже не переопределяются как экземплярные методы. Если одинаковое имя поля есть у родителя и потомка, значение выбирается по типу выражения слева от точки. В задаче, где смешаны поля, static и обычные методы, разбирайте каждое обращение по его собственному правилу.
14. Чем интерфейс отличается от абстрактного класса?
Интерфейс описывает, какие действия должен поддерживать тип. Например, «отправитель уведомлений» обещает действие отправки, а конкретные классы выполняют его разными способами. Один класс может реализовать несколько интерфейсов. Абстрактный класс — заготовка, от которой создают потомков: в ней можно объединить общее состояние и часть реализации. Напрямую создать экземпляр абстрактного класса нельзя.
У интерфейсов бывают default-методы: это методы с готовым телом, доступным реализующим классам. Поэтому фраза «в интерфейсе нет реализации» неверна. Но обычные поля состояния отдельного объекта, как в базовом классе, интерфейс не предоставляет. Если класс получает конфликтующие default-реализации, иногда нужно явно выбрать или написать нужную реализацию.
Выбор делайте по устройству задачи. Для разных способов отправки достаточно общего обещания «умеет отправлять». Для родственных объектов с общими данными может подойти базовый класс. Совпадение нескольких строк кода ещё не означает, что объекты должны стать родителем и потомком: наследование связывает и правила их использования.
15. Почему опасен вызов переопределяемого метода из конструктора?
Конструктор подготавливает новый объект. Пока выполняется конструктор родителя, поля потомка могут ещё не получить значения, предусмотренные его кодом. При этом вызов экземплярного метода способен сразу перейти в переопределённую реализацию потомка. Она начнёт работать с ещё не подготовленными данными.
Например, конструктор общего отчёта вызывает title(). В потомке этот метод возвращает поле с заголовком. Инициализация заголовка потомка ещё не выполнилась, поэтому метод может увидеть null. Если он сразу вызовет у заголовка строковый метод, возникнет исключение. То, что поле обязательно заполнят позднее, текущий вызов не спасает.
Безопаснее передать нужные конструктору данные явно и не вызывать из него поведение, которое потомок может заменить и связать со своими полями. Действия, требующие полностью готового объекта, нужно выполнять после завершения его создания.
16. Означает ли protected доступ из любого места для потомка?
Нет. protected — правило доступа к полю или методу. Пакет — именованная группа классов. В том же пакете защищённый член доступен по пакетному правилу. Но когда потомок находится в другом пакете, появляются дополнительные условия для доступа к экземплярным полям и методам родителя.
Пусть Parent объявляет защищённое поле, а Sub наследует Parent в другом пакете. Из кода Sub можно обращаться к этому полю через свой объект и через выражение типа Sub или его подтипа. Но произвольная переменная типа Parent такого права не даёт. Даже если фактически она хранит ссылку на Sub, компилятор проверяет объявленный тип выражения.
Для ответа проверьте три вещи: где объявлен член, в каком пакете написан доступ и какой тип находится слева от точки. При переопределении метода нельзя уменьшать его доступность: например, сделать публичный метод закрытым. В проектах с модулями — отдельными единицами сборки с собственными границами доступа — дополнительно учитывают экспорт пакетов. Одного public недостаточно, чтобы отменить эти границы.
Records и sealed: что меняется в модели
17. Record — это полностью immutable-объект?
record — компактное объявление класса для хранения данных. Указанные в его заголовке части называются компонентами. Компилятор предоставляет поля, конструктор для этих компонентов, методы чтения и стандартные equals, hashCode, toString. Поля компонентов — final, но объект, на который указывает компонент, может оставаться изменяемым. Records стали стандартной возможностью в Java 16 и доступны в Java 17/21.
В профиле ниже компонент skills хранит список навыков. Короткая запись Profile { ... } задаёт дополнительную подготовку данных конструктора. List.copyOf сохраняет отдельный состав списка до присваивания полю. Слово var просит компилятор вывести тип локальной переменной из значения справа; оно не отменяет проверку типов.
record Profile(List<String> skills) {
Profile {
skills = List.copyOf(skills);
}
}
var source = new ArrayList<>(List.of("Java"));
var profile = new Profile(source);
source.add("SQL");
System.out.println(profile.skills()); // [Java]sourceполучает изменяемый список с одной строкой Java.- При создании Profile конструктор получает этот список и подготавливает результат
List.copyOf. В компонент сохраняется список с текущим составом. source.add("SQL")меняет исходный список. Состав сохранённого в профиле списка от этого не меняется.profile.skills()читает компонент, поэтому печатается[Java].
Строки внутри списка неизменяемы, поэтому для этого примера поверхностной копии достаточно. Со списком изменяемых объектов нужно отдельно защитить их данные. Массив тоже требует внимания: стандартное равенство record с компонентом-массивом не начинает автоматически сравнивать элементы массива по отдельности.
18. Зачем нужны sealed-классы и чего они не делают?
sealed означает, что автор класса ограничивает список допустимых прямых наследников. Например, результат операции можно представить двумя заранее известными видами: успехом и отказом. Другой класс не сможет самовольно стать третьим прямым потомком. Возможность стала стандартной в Java 17.
Разрешённый потомок продолжает иерархию по одному из предусмотренных правил: final прекращает дальнейшее наследование, sealed снова задаёт ограниченный список, non-sealed открывает ветвь для дальнейших потомков. Таким образом, ограничение прямых наследников ещё не означает, что все будущие классы во всех ветвях известны.
Это ограничение набора видов объектов, а не изменения их полей: sealed-класс может содержать изменяемые данные. Не путайте также разные версии возможностей. Сопоставление по типам в switch означает выбор ветви по виду объекта; в стандартном виде оно появилось в Java 21. Наличие sealed в Java 17 не даёт автоматически весь синтаксис такого switch без дополнительных условий.
| Слишком широкая фраза | Точная граница |
|---|---|
| Java передаёт объект по ссылке. | Копируется значение ссылки; общий объект можно изменить, переменную вызывающего кода — нет. |
| Одинаковый hashCode означает равенство. | Равенство требует одинакового хеша, но коллизии допустимы. |
| final делает данные immutable. | final запрещает переназначение; вложенное состояние проверяется отдельно. |
| Record глубоко неизменяем. | Финальные компоненты могут ссылаться на изменяемые объекты. |
Как проверить понимание перед встречей
Вернитесь к четырём примерам: передаче списка, сравнению строк, выбору перегрузки и профилю record. Для каждого назовите переменные, объекты и действия по порядку. Затем измените одно условие: добавьте null, поменяйте объявленный тип переменной или мысленно уберите защитную копию. Объясните, на каком шаге изменится результат.
Если приходится угадывать, запишите конкретный пробел: копирование ссылки, правило равенства, выбор метода или возможность изменить вложенные данные. Повторите именно его на другом небольшом примере. Проверяйте отдельно, допустим ли код при компиляции и что он сделает после запуска: это два разных вопроса.
