Java и Spring: вопросы на собеседовании

JVM и память, equals/hashCode, коллекции, исключения, Stream API, многопоточность, дженерики и основы Spring.

#1Основы JVMjunior

Чем отличаются JDK, JRE и JVM? Как исполняется Java-код?

  • JVM (Java Virtual Machine) — виртуальная машина, исполняющая байткод. Своя реализация под каждую платформу — отсюда «написано однажды, работает везде».
  • JRE (Java Runtime Environment) — JVM плюс стандартные библиотеки. Достаточно для запуска.
  • JDK (Java Development Kit) — JRE плюс инструменты разработки: компилятор javac, отладчик, jar, jshell. Начиная с Java 11 отдельный JRE обычно не распространяется — ставят JDK.

Путь кода:

  1. javac компилирует .java в байткод (.class) — промежуточное представление, не машинный код.
  2. Загрузчик классов подгружает классы лениво, по мере обращения.
  3. Верификатор проверяет байткод на корректность.
  4. Байткод сначала интерпретируется, а часто выполняемые («горячие») методы JIT-компилятор (C1, затем C2) переводит в оптимизированный машинный код с учётом реальной статистики выполнения.

Поэтому Java-приложение «разогревается»: первые запросы медленнее, чем последующие. Для быстрого старта (serverless, CLI) есть AOT-компиляция через GraalVM Native Image.

  • Что такое загрузчики классов и их иерархия?
  • Почему Java-сервис медленнее на первых запросах?
#2Объектыjunior

Какой контракт у equals и hashCode? Что будет, если его нарушить?

Контракт:

  1. Если a.equals(b), то a.hashCode() == b.hashCode() — обязательно.
  2. Если хеши равны, объекты не обязаны быть равными (коллизии допустимы).
  3. equals рефлексивен, симметричен, транзитивен, согласован и x.equals(null) == false.
  4. hashCode должен возвращать одно и то же значение, пока не изменились поля, участвующие в equals.

Что сломается при нарушении. Переопределили equals, но забыли hashCode:

Set<User> users = new HashSet<>();
users.add(new User("ann@mail.ru"));
users.contains(new User("ann@mail.ru")); // false!

HashSet сначала ищет корзину по хешу — у двух «равных» объектов хеши разные (унаследованы от Object), до вызова equals дело не доходит. В коллекции появляются дубликаты, поиск не работает.

Вторая ловушка: изменять поля объекта, который уже лежит в HashSet или является ключом HashMap. Хеш изменится, объект «потеряется».

Как писать: генерировать через IDE, использовать Objects.equals и Objects.hash, а ещё лучше — `record` (Java 16+): для него equals, hashCode и toString генерируются автоматически по всем полям.

  • Почему equals должен быть симметричным и как это нарушают при наследовании?
  • Что такое record?
#3Строкиjunior

Почему String неизменяем? Что такое String pool и когда нужен StringBuilder?

Неизменяемость String даёт:

  • безопасность — строку, переданную как имя файла, URL или пароль, нельзя подменить после проверки;
  • потокобезопасность без синхронизации;
  • кеширование хеша — строки идеальные ключи HashMap;
  • возможность пула строк.

String pool — область в куче, где хранятся строковые литералы. Одинаковые литералы указывают на один объект:

String a = "java";
String b = "java";
String c = new String("java");
a == b;        // true — один объект из пула
a == c;        // false — новый объект
a.equals(c);   // true — сравниваем содержимое

Отсюда правило: строки сравниваем только через `equals`.

StringBuilder. Каждая конкатенация + создаёт новый объект. В цикле это даёт O(n²) по времени и нагрузку на сборщик мусора:

StringBuilder sb = new StringBuilder();
for (String part : parts) sb.append(part).append(',');
String result = sb.toString();

Одиночную конкатенацию в выражении компилятор оптимизирует сам — StringBuilder нужен именно в циклах. StringBuffer — синхронизированный старый аналог, в новом коде не нужен. Для склейки через разделитель удобнее String.join или Collectors.joining.

  • Что делает метод intern()?
  • Где хранится String pool в современных JVM?
#4Коллекцииmiddle

Как устроен HashMap? Что изменилось в Java 8?

HashMap — массив корзин (buckets). Для ключа вычисляется hashCode(), биты перемешиваются (h ^ (h >>> 16)), индекс корзины — hash & (capacity - 1); ёмкость всегда степень двойки.

Коллизии. В корзине хранится связный список узлов. При поиске сравниваются хеши, затем equals.

Java 8: если в одной корзине больше 8 элементов и общая ёмкость не меньше 64, список превращается в красно-чёрное дерево — поиск в худшем случае становится O(log n) вместо O(n). Это защита от атак с подбором коллизий. При уменьшении до 6 элементов дерево обратно превращается в список.

Расширение. При превышении capacity × loadFactor (по умолчанию 16 × 0.75 = 12 элементов) массив удваивается, и элементы перераспределяются. Если размер известен заранее — задайте начальную ёмкость, чтобы избежать лишних перехешираний.

Что ещё спросят:

  • ключ null допустим (хранится в корзине 0), в Hashtable и ConcurrentHashMap — нет;
  • HashMap не потокобезопасен — параллельные записи портят структуру. Нужен ConcurrentHashMap;
  • порядок итерации не гарантирован. Нужен порядок вставки — LinkedHashMap, сортировка — TreeMap (O(log n)).
  • Как устроен ConcurrentHashMap?
  • Как на LinkedHashMap сделать LRU-кеш?
#5Исключенияjunior

Опишите иерархию исключений в Java. Чем checked отличаются от unchecked?

Корень — Throwable, от него:

  • `Error` — проблемы JVM, которые приложение обычно не обрабатывает: OutOfMemoryError, StackOverflowError.
  • `Exception`:
  • checked — всё, что наследуется от Exception, кроме RuntimeException: IOException, SQLException. Компилятор обязывает обработать или объявить в throws;
  • unchecked — RuntimeException и потомки: NullPointerException, IllegalArgumentException, IndexOutOfBoundsException. Объявлять не нужно.

Идея: checked — ожидаемые ситуации, из которых можно восстановиться (файла нет, сеть недоступна); unchecked — ошибки программиста (передали null, вышли за границу массива).

На практике checked-исключения загромождают сигнатуры и плохо сочетаются с лямбдами и Stream API, поэтому Spring и большинство современных библиотек оборачивают их в unchecked.

try-with-resources (Java 7+) — автоматическое закрытие ресурсов, реализующих AutoCloseable:

try (var conn = dataSource.getConnection();
     var stmt = conn.prepareStatement(sql)) {
    // ...
} // закроются в обратном порядке, даже при исключении

Исключения при закрытии не теряются — они добавляются как suppressed к основному.

Ловушка: return в блоке finally перетирает и возвращаемое значение, и исключение из try. Так делать нельзя.

  • Можно ли поймать StackOverflowError?
  • Что такое suppressed exceptions?

Ещё 7 вопросов в этом треке

  1. Как устроена память JVM? Как работает сборка мусора?
  2. Как работает Stream API? Чем промежуточные операции отличаются от терминальных?
  3. Что делают synchronized и volatile? Что такое happens-before?
  4. Зачем нужен ExecutorService? Что такое CompletableFuture и виртуальные потоки?
  5. Что такое стирание типов? Объясните ? extends и ? super (PECS).
  6. Что такое IoC и DI в Spring? Какие бывают области видимости бинов?
  7. Как работает @Transactional? Почему транзакция иногда «не срабатывает»?