Один и тот же вопрос, три глубины ответа. По ней интервьюер и считывает грейд.
Последние полгода я почти каждую неделю смотрю чужие расшифровки собеседований — присылают в личку, что-то лежит в открытых базах. Сначала ради своего канала, потом просто втянулся. И вот что в какой-то момент стало резать глаз.
Вопросы на собесах в банках от грейда к грейду почти не меняются.
«Что такое индекс» спрашивают и у стажёра, и у сеньора с десятью годами. Про equals и hashCode — тоже у всех. Про @Transactional — вообще в каждом втором интервью, кого ни возьми. Меняется не сам вопрос. Меняется глубина, на которую от тебя ждут ответ. И именно по этой глубине интервьюер за полминуты понимает твой настоящий уровень — задолго до того, как вы дойдёте до зарплаты.
Сразу оговорюсь, потому что меня за прошлую статью за это справедливо пинали: я не считал, сколько процентов народу спотыкается на каждом вопросе. Нет у меня таких цифр, и придумывать их я не стану. Зато есть сами расшифровки, где видно, как один и тот же вопрос звучит на джуне, на мидле и на сеньоре — и насколько по-разному его ждут.
Сразу про одну вещь, в которой я сам долго путался, когда раскладывал эти ответы. Граница «мидл / сеньор» проходит не там, где кажется. «Знать, как HashMap превращает список в дерево» или «как AtomicInteger работает на CAS» — это не сеньор, это крепкий мидл. Сеньор отличается не тем, что помнит больше внутренностей JDK, а тем, что видел, как это всё ломается в проде, и думает про цену решения. Дальше будет видно по каждому вопросу.
Я выбрал восемь таких вопросов. По каждому — три ответа подряд: джун, мидл, сеньор. Читайте и прикидывайте, на какой строчке заканчиваетесь лично вы. Это дешевле, чем выяснить то же самое на реальном собесе.
Чтобы было видно, о чём речь, вот тот самый «индексный» вопрос, разложенный по уровням. Дальше будет ещё семь в том же духе.
Где-то ниже я помечаю уровни цветом — 🟢 джун, 🟡 мидл, 🔴 сеньор, — а где история живее прозой, просто называю их по ходу. Уровни наслаиваются: мидл знает всё, что джун, плюс своё; сеньор — всё это плюс то, что приходит только с продом.
1. equals и hashCode
Баян, конечно. Но раз спрашивают везде, давайте по-честному — и без карикатур на джуна.
🟢 Джун обычно отвечает уверенно и правильно: equals — это равенство по значению, == — по ссылке; переопределил equals — переопредели и hashCode, это контракт; равные объекты обязаны вернуть равный hashCode. Генерю через IDE, Lombok или беру record. Нормальный ответ, он правда так знает.
🟡 Мидл показывает, что будет, если контракт нарушить. Переопределили только equals — и два «равных» объекта дадут разный hashCode, улетят в разные бакеты HashMap, а get() по второму ключу вернёт null, хотя элемент в карте лежит. Обратное контрактом не требуется: одинаковый хэш у разных объектов это просто коллизия, и это нормально. Сюда же правило, которое экономит нервы: hashCode нельзя строить на полях, которые потом меняются.
🔴 Сеньор говорит не про новые факты, а про то, как это спроектировать, чтобы не рвануло в реальном приложении. JPA-сущности: id генерит база, до сохранения он null, и наивный equals по id ведёт detached- и managed-объекты по-разному — Set начинает «терять» элементы между сессиями. Лечится бизнес-ключом или фиксированным hashCode. Плюс ловушка с Hibernate: ленивый прокси подменяет класс объекта, поэтому equals через getClass() ломается на прокси, и берут instanceof. Вот эта прод-боль и отличает сеньора, а не знание самого контракта.
Граница тут честная: контракт назубок знают все, а что именно отвалится в ORM — единицы. В расшифровках, кстати, у ТБанка дословно: «контракт equals и hashCode, может ли у разных объектов совпасть hashCode».
2. Как устроен HashMap
Логичное продолжение, поэтому часто идёт сразу за предыдущим.
🟢 Джун опишет верхушку: пары «ключ — значение», ключ уникален, доступ в среднем за O(1), внутри массив. TreeMap держит ключи отсортированными. Для старта достаточно, и это не стыдно.
🟡 Мидл залезает под капот — и вот это весь мидловский слой, тут нет ничего сеньорского. Массив бакетов, индекс считается как hash & (n-1), коллизии складываются в бакет списком. Load factor 0.75: заполнилось больше — resize, удвоение массива и перераскладка. С Java 8 длинный бакет (восемь элементов, когда сам массив уже не меньше 64) превращается в красно-чёрное дерево, чтобы вырожденный случай не падал в O(n); обратно в список — на шести. Перед раскладкой старшие биты хэша подмешиваются в младшие, чтобы кривой hashCode реже коллизировал. И HashMap не потокобезопасен.
🔴 Сеньор не сыплет внутренностями дальше — он выбирает инструмент и считает цену. Высокая конкуренция значит ConcurrentHashMap, и сразу оговорка, что его итератор слабосогласованный, а не fail-fast. Resize на большой мапе — это пауза на перехеширование, поэтому если размер примерно известен, мапу присайзивают через конструктор, чтобы не ловить пилу по latency. А если в ней миллионы записей с примитивами, всплывает оверхед на каждый Node, и иногда честнее специализированная коллекция.
Коллизии — список; при 8+ в одном бакете он становится красно-чёрным деревом. Это уровень Middle.
3. Почему String неизменяемый
Простой на вид вопрос, на котором удобно нащупать границу. И тут честно: у джуна с мидлом ответы заметно перекрываются, грань тонкая.
🟢 Джун скажет: String нельзя изменить после создания, для изменений есть StringBuilder, литералы лежат в пуле, поэтому одинаковые литералы — это один объект. 🟡 Мидл добавит механику: литералы интернируются, new String("x") всегда создаёт объект мимо пула, вернуть в пул можно через intern(); склейка двух литералов схлопывается компилятором в один, а конкатенация переменных в цикле плодит мусор — отсюда вечный совет про StringBuilder. И мидл уже отвечает на «зачем»: потокобезопасность без блокировок и hashCode, посчитанный один раз и закэшированный (поэтому String так хорош как ключ).
А вот где настоящая сеньорская грань — и это надо сказать прямо: здесь она не про новый факт, а про опыт. Сеньор вспомнит безопасность как инвариант: проверенный путь к файлу, host или фрагмент SQL нельзя подменить после валидации, иначе классический TOCTOU, и неизменяемость закрывает это бесплатно. И грабли intern() на масштабе: пул живёт в куче, но если интернировать строки из пользовательского ввода, он пухнет и начинает давить на GC — такое ловили в проде не раз. Плюс Compact Strings с Java 9, latin1 против UTF-16, что реально меняет память на сервисах, где строк миллионы.
То есть граница тут проходит не по знанию определения, а по тому, видел ли человек, как intern() или склейка в горячем цикле выстреливали на продакшене.
4. Optional
Здесь любят ловить на мелочи, которую многие проскакивают. Поэтому начну с самих ловушек, а потом разложу, кто их видит.
Ловушка первая: orElse(x) вычисляет x всегда — даже когда значение есть и x не нужен. Если за x прячется поход в базу, это лишний запрос на ровном месте. Ловушка вторая: Optional в поле сущности или DTO.
🟢 Джун про Optional знает базовое и верное: обёртка, чтобы явно показать, что значения может не быть, и не возвращать null. На первую ловушку, скорее всего, не среагирует, и это ожидаемо.
🟡 Мидл обе ловушки разруливает. orElseGet(() -> x) ленивый, в отличие от orElse; get() без проверки — это тот же NPE, только сбоку; для потенциального null нужен ofNullable, а не of, который сам кинет NPE. Знает map/flatMap/filter и orElseThrow.
🔴 Сеньор смотрит на это как на дизайн API. Optional задуман как тип возвращаемого значения, и только: он не Serializable (поле ломает сериализацию), это лишняя аллокация на горячем пути, а Optional<List<T>> — нонсенс, пустой список и так говорит «ничего нет». Поэтому в полях, параметрах и коллекциях его не держат. Разница между мидлом и сеньором тут не в знании методов, а во вкусе: где этой обёртке место в публичном контракте, а где она только мусорит.
5. counter++ и сто потоков
Самый частый практический вопрос по многопоточке. Дают код, где сто потоков по тысяче раз дёргают один счётчик, и спрашивают, будет ли в конце 100000.
🟢 Джун ответит верно по сути: нет, будет меньше, потоки мешают друг другу, это race condition, надо synchronized. Направление правильное.
🟡 Мидл объяснит, почему меньше, и это ровно его слой. counter++ — не одна операция, а три: прочитать, увеличить, записать; два потока читают одно значение, и один инкремент затирается. Чинится либо synchronized-блоком, либо AtomicInteger.incrementAndGet(). И да, мидл вполне знает, что атомик внутри на CAS: читает текущее, пытается записать новое, проверяет, что за это время никто не влез, влез — повтор по кругу, блокировки нет. Подчеркну: вот это всё ещё разговор уровня Middle, ничего сеньорского в «знаю про CAS» нет.
🔴 Сеньор начинает с того, что переформулирует задачу. Под высокой конкуренцией CAS-спин крутится вхолостую и жжёт CPU — тогда выигрывает LongAdder, который размазывает счётчик по ячейкам и складывает их лениво при чтении. Но ещё раньше сеньор спросит: а нужен ли вообще один общий счётчик? Часто можно считать локально в каждом потоке и свести в конце; иногда это вообще работа БД или Redis с атомарным инкрементом; и нужна ли точность в моменте или достаточно, чтобы значение сошлось чуть позже. «Починить counter++» — часто не та задача, которую стоило решать.
Оба потока прочитали 41, оба записали 42 — один инкремент потерян. Вот почему
counter++не атомарна.
6. volatile и happens-before
Прямое продолжение, обычно идёт сразу следом, чтобы добить тему.
🟢 Джун: volatile нужен, чтобы потоки видели актуальное значение, а не закэшированное у себя в ядре.
🟡 Мидл ставит важную оговорку: volatile даёт видимость и запрещает переупорядочивание вокруг себя, но не делает операцию атомарной — поэтому counter++ он не спасает, там по-прежнему три действия. Нормальный кейс — флаг остановки потока, while (!stopped). И happens-before мидл тоже назовёт: запись в volatile happens-before последующего чтения той же переменной и протаскивает за собой все записи, сделанные потоком до неё. Тут честно стоит сказать — в банковской базе happens-before помечен как Middle+, то есть это верх мидла, а не отдельная сеньорская планка.
🔴 Сеньор уходит в безопасную публикацию целиком, и вот это уже его территория. Почему double-checked locking до Java 5 был сломан: без гарантий модели памяти другой поток мог увидеть наполовину сконструированный объект, и чинится это volatile на поле синглтона. Почему immutable-объект с final-полями можно отдавать между потоками вообще без synchronized — JMM отдельно гарантирует видимость полностью построенного объекта через final. И обратная сторона: где volatile вешают зря — если доступ и так уже под одним замком, он не добавляет ничего, кроме шума. Сеньор отвечает не на «что такое happens-before», а на «как безопасно опубликовать состояние между потоками».
7. Индексы в базе
Тот самый вопрос с картинки в начале. Его удобно показывать именно лесенкой, потому что в одной и той же конторе его реально задают тремя ступенями.
🟢 Джун: индекс ускоряет поиск, как оглавление в книге — по индексированному полю WHERE отрабатывает быстрее, чем перебор всей таблицы. В ТБанке джунам так и формулируют: что такое индекс и почему нельзя навесить его на каждое поле.
🟡 Мидл: чаще всего B-tree — равенство, диапазоны, сортировка; hash — только равенство; GIN/GiST — полнотекст, массивы, гео. На каждый столбец нельзя: индекс занимает место и замедляет запись, потому что его надо обновлять при каждом INSERT/UPDATE/DELETE. Покрывающий (INCLUDE) отвечает на запрос, не заходя в таблицу; частичный (WHERE) индексирует подмножество строк; у составного важен порядок колонок, работает левый префикс. Это ровно мидловские формулировки из тех же интервью.
🔴 Сеньор живёт там, где индекс не срабатывает и где его накатывают на проде. Не сработает не на SARGable предикате — функция над колонкой, LIKE '%x%' с процентом в начале, неявный каст типов — и при низкой селективности: если запрос вернёт большую долю таблицы, планировщик сознательно уйдёт в seq scan, потому что так дешевле, и сеньор это читает по EXPLAIN (ANALYZE, BUFFERS). А на терабайтнике обычный CREATE INDEX лочит запись и встаёт колом, поэтому только CREATE INDEX CONCURRENTLY: дольше, в два прохода, при сбое оставляет INVALID-индекс на пересоздание, и внутри транзакции его не запустить. Эта верхняя ступень в базе и помечена как Middle+: «таблица на несколько терабайт — как накатить индекс, не убив прод».
Три ступени, одна компания. Собственно, с этого наблюдения вся статья и началась.
Слева индекс работает, справа — простаивает. Сеньор знает, почему планировщик выбрал seq scan.
8. @Transactional, который держит соединение
Мой любимый, и в банках его обожают. Формулировка обычно такая: повесили @Transactional на метод, а он внутри ходит синхронным REST-вызовом в соседний сервис. Что не так?
🟢 Джун ответит про основу и не ошибётся: @Transactional открывает транзакцию, при исключении откатывает, нужен, чтобы несколько операций с базой были атомарны — всё или ничего. ACID.
🟡 Мидл знает подводные камни механики, и это именно мидловский слой. По умолчанию откат только на RuntimeException и Error, на checked-исключение транзакция не откатится — нужен rollbackFor, и на этом регулярно горят. И про прокси: @Transactional работает через AOP-прокси, поэтому вызов метода изнутри того же бина (self-invocation) идёт мимо прокси, и транзакция не стартует; по той же причине private- и final-методы не перехватываются. Сюда же propagation и уровни изоляции, если попросят.
🔴 Сеньор видит в вопросе беду подороже и обычно рассказывает её как историю с прода. Синхронный HTTP держит транзакцию открытой ровно столько, сколько длится сетевой таймаут, а вместе с ней удерживает соединение из пула и блокировки на строках. Под нагрузкой пул вычерпывается, и ложится не один метод, а сервис целиком, каскадом — разгребать такое в три ночи незабываемо. Поэтому сеть внутри транзакции это антипаттерн: транзакция должна быть короткой и только про базу. Вызов выносят наружу, а согласованность с внешним миром делают через Outbox или Saga, иначе ловишь классику «сообщение в Kafka ушло, а транзакция откатилась». И ещё: долгая транзакция в PostgreSQL из-за MVCC удерживает старые версии строк и мешает VACUUM, таблицы пухнут. Сеньор тут про границы, ресурсы и согласованность под нагрузкой, а не про то, как оно откатывается.
Это фирменный вопрос ТБанка, в нескольких расшифровках почти слово в слово.
Транзакция держит соединение всё время сетевого таймаута — под нагрузкой пул вычерпывается, и ложится весь сервис.
Та же восьмёрка одним экраном
Если хочется просто пробежать перед собесом — вот всё, что выше, в одной таблице. Граница «мидл/сеньор» специально сдвинута к реальности: внутренности это мидл, прод и трейд-оффы это сеньор.
# | Вопрос | 🟢 Джун | 🟡 Мидл | 🔴 Сеньор |
|---|---|---|---|---|
1 | equals / hashCode | равенство по значению, переопредели оба, равные → равный хэш | только equals → разные бакеты, | стратегия для JPA-сущностей; |
2 | HashMap внутри | пары, O(1) в среднем, массив | бакеты, load factor/resize, treeify ≥8, не потокобезопасен | выбор под нагрузку/память, цена resize в hot path, когда CHM или иная структура |
3 | String immutable | нельзя менять, пул, литерал = один объект | интернирование, | TOCTOU-безопасность, грабли |
4 | Optional | обёртка вместо null |
| только возвращаемый тип: не поле, не |
5 | counter++ | race condition, | три операции, |
|
6 | volatile | видеть актуальное значение | видимость без атомарности; happens-before (это Middle+) | safe publication, |
7 | Индексы | ускоряют поиск | типы, покрывающий/частичный, цена записи | SARGability, селективность и seq scan, |
8 | откат при ошибке, ACID |
| держит соединение → вычерпывает пул → каскад; Outbox/Saga; MVCC и VACUUM |
Никакой магии в «определении грейда» нет. Берёте любой вопрос и говорите вслух, пока есть что сказать.
Закончились на зелёном — это честный джун, и на старте абсолютно нормально; дальше растёт не список тем, а глубина по каждой. Дошли до жёлтого, можете объяснить, как оно устроено внутри и где ломается, — это мидл, и, к слову, большинство офферов закрывается именно тут. А красное — это уже не «знаю ещё больше внутренностей». Это когда вы сами, без наводящих, выходите на прод: что будет под нагрузкой, чем платим, когда это решение вообще не надо применять. Вот это и пишут в фидбэке словом «Senior».
Грейд — это не сколько фактов вы помните. Это где вы перестаёте говорить про код и начинаете про его последствия.
Та самая мысль одной картинкой: внутренности — это Middle, прод и трейд-оффы — Senior.
Это уже третий разбор про реальные банковские собесы. Senior System Design я по-прежнему должен, помню, он будет — просто это большой кусок, и наспех не хочется.
А пока проверим формат на вас. Возьмите в комментариях любой вопрос из восьми и допишите красную строку своими словами — как бы вы закрыли его на сеньорском уровне, через прод и трейд-оффы, а не через «ещё внутренности». Самое толковое утащу в отдельный разбор в канал, с указанием авторства. Не согласны со мной, где проходит граница мидла и сеньора, — тем интереснее, спорьте по делу, я тут не истина в последней инстанции.
И отдельное спасибо тем, кто честно признается, на какой строчке встал. Сказать себе «я пока на жёлтой» — это уже полпути до красной.
Пишу про разбор java-собесов и реальные вопросы, которые задают, без курсов и рекламы — канал @Java_Jub.





















