сеньорчикОткрыть в Telegram
← вся теориятеория к собесу · Java: ядро языка

Типы, обёртки и String в Java

Типы и строки: простые вещи, на которых сыпятся

Запустил две строки. Integer a = 100, b = 100, сравнение a == b дало true. Те же переменные со значением 200 дали false. Код не менялся, менялось только число - и результат сравнения перевернулся.

Тема выглядит джуновой, а заваливают на ней чаще всего: именно отсюда берутся вопросы «что напечатает этот код». И это не занудство ради занудства - сравнение Integer через равенство ссылок работает на тестовых данных и ломается на боевых, это классика инцидентов.

// Формулировки: «Integer a=100, b=100; a==b?», «почему String неизменяем?», «как хранить деньги?»

Примитивы, обёртки и граница кэша

int - голое значение в памяти. Integer - объект-обёртка вокруг него, а значит может быть null, и оператор == сравнивает ссылки, а не числа. Автобоксинг переводит одно в другое молча: удобно, но в цикле это создание объекта на каждой итерации, а рядом с null - мина.

Границу кэша я нашёл замером: Integer.valueOf(127) == Integer.valueOf(127) даёт true, а для 128 - уже false. Обёртки в диапазоне от минус 128 до 127 берутся из общего пула, поэтому маленькие числа сравниваются «правильно» по чистой случайности. Код, прошедший тесты на сотне, падает на тысяче.

Распаковка null даёт NPE (NullPointerException). Самый коварный вариант - тернарный оператор со смешанными ветками. Я проверил: выражение flag ? 1 : nullInteger роняет NPE, даже когда результат присваивается в Object - тип всего выражения стал int из-за ветки с единицей, поэтому вторая ветка распаковывается. Сделай обе ветки Integer, и NPE исчезает.

Integer a = 100, b = 100, c = 200, d = 200;
a == b   // true  - оба из кэша -128..127
c == d   // false - уже разные объекты
Object o = flag ? 1 : nullInteger; // NPE при flag == false
автобоксинг
неявный перевод примитива в обёртку и обратно
кэш обёрток
от минус 128 до 127 объекты общие, дальше новые

String: неизменяемость, пул и методы-обманки

Проверил на живом коде: строка «hello», вызов s.replace без присваивания результата - и печать снова выдаёт «hello». String неизменяем, любая «модификация» возвращает новый объект, а старый остаётся как был.

Второе следствие неизменяемости дороже. Замерил сборку строки из 40 000 символов: через оператор += вышло 141,9 мс, через StringBuilder - 5,1 мс. Разница в 28 раз, и она растёт с длиной, потому что каждая конкатенация копирует всю строку целиком.

Пул строк объясняет, почему == со строками «иногда работает»: одинаковые литералы интернируются и указывают на один объект, поэтому сравнение даёт true. А new String("hi") создаёт отдельный объект, и то же сравнение даёт false. Вывод один - строки сравниваем только через equals.

// Метод-обманка: split принимает регулярное выражение. Я замерил, "a.b.c".split(".") возвращает массив из НУЛЯ элементов, потому что точка в регулярке значит «любой символ». Нужно split("\\.") - тогда три.

пул строк
общее хранилище интернированных литералов
StringBuilder
изменяемая сборка строки без копирования на каждом шаге

Пробелы, дробные числа и молчаливые переполнения

Про пробелы отдельно, потому что тут все ошибаются. Замерил четыре случая на строке вида «пробел-икс-пробел»: обычный пробел и табуляцию убирают и trim, и strip. Длинный пробел U+2003 убирает только strip - вот ради этого он и появился. А неразрывный пробел U+00A0 не убирает НИ ОДИН из двух, потому что Character.isWhitespace считает его непробельным. Именно он приезжает из копипаста, и именно он переживает любую чистку.

Дробные числа: 0.1 + 0.2 в Java равно 0.30000000000000004, сравнение с 0.3 даёт false. Двоичная плавающая точка не представляет десятичные дроби точно. Для физики и машинного обучения это нормально, для денег - недостача на проде.

Деньги считают через BigDecimal, и обязательно со строковым конструктором. Я напечатал оба: new BigDecimal(0.1) даёт 0.1000000000000000055511151231257827021181583404541015625, то есть весь двоичный мусор из double, а new BigDecimal("0.1") даёт ровно одну десятую.

// Целые переполняются молча. Integer.MAX_VALUE + 1 равно минус 2147483648, без всякого исключения. Приведение long к int просто обрезает старшие биты: 4294967303 превратилось в 7. А деление 7 / 2 даёт 3, потому что дробная часть отбрасывается.

BigDecimal
точная десятичная арифметика, для денег обязательна
неразрывный пробел
U+00A0: его не убирают ни trim, ни strip

Как отвечать: «Integer a = 100, b = 100; a == b - что вернёт и почему?»

Вернёт true, но это ловушка. Integer это объект, и оператор == сравнивает ссылки, а не числа. Работает оно здесь только потому, что виртуальная машина кэширует обёртки в диапазоне от минус 128 до 127, и оба боксинга вернули один и тот же объект из пула. Я проверял границу: для 127 сравнение даёт true, для 128 уже false. Поэтому если написать те же две строки со значением 200, результат перевернётся, и это классический продовый инцидент - код прошёл тесты на маленьких идентификаторах и сломался на боевых. Обёртки я сравниваю только через equals либо распаковываю до примитивов. И рядом держу в голове второй риск: распаковка null даёт NullPointerException, причём самый неочевидный случай - тернарный оператор, где одна ветка int, а другая Integer. Тогда тип всего выражения становится int, и null-ветка распаковывается, даже если результат уходит в Object.

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

На чём валят

  • Сравнивают обёртки через ==. Работает до 127, ломается со 128 - сравнение только через equals.
  • Зовут s.replace или s.substring и не присваивают результат. Строка неизменяема, ничего не произошло.
  • Собирают строку в цикле через +=. На 40 000 итераций у меня вышло 142 мс против 5 мс у StringBuilder.
  • Пишут split(".") или split("|") без экранирования. Это регулярное выражение, результат пустой или посимвольный.
  • Ждут, что trim или strip уберут неразрывный пробел. Ни один из них его не трогает.

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

Пять вопросов из банка по этой подтеме. Всего их 25, остальные разбираются в тренажёре.

  1. #types_strings1 / 5
    Integer a=127, b=127 → a==b даёт true, а при 128 — false. Почему?
    A)127 укладывается в один байт, а 128 уже выходит за его границу, поэтому второе число вынужденно создаётся как новый объект
    B)Autoboxing для чисел больше 100 отключается, и 128 сравнивается по значению, а не по ссылке
    C)Это баг конкретной JVM: по спецификации == для равных Integer обязано давать true
    D)Integer кэширует объекты в диапазоне -128..127; вне кэша valueOf создаёт новые объекты, == сравнивает ссылки
    показать ответ и разбор
    +D)Integer кэширует объекты в диапазоне -128..127; вне кэша valueOf создаёт новые объекты, == сравнивает ссылки

    // разбор: Autoboxing идёт через Integer.valueOf, который держит кэш готовых объектов для −128..127 (по умолчанию). Для значений из этого диапазона возвращается один и тот же объект, поэтому == (сравнение ссылок) даёт true. Для 128 valueOf создаёт новый объект каждый раз — ссылки разные, == даёт false, хотя значения равны. Мораль: обёртки всегда сравнивать через equals; == для Integer — классическая ловушка на собесе.

  2. #types_strings2 / 5
    Почему неизменяемость (immutability) String считается плюсом?
    A)Неизменяемая строка занимает меньше памяти, чем изменяемая, за счёт отсутствия внутреннего буфера
    B)Так строку можно менять из потока без синхронизации, ведь каждый поток правит свою копию
    C)Безопасное разделение: пул строк, надёжные ключи map, потокобезопасность, кэшируемый hashCode
    D)Только ради того, чтобы работал оператор + для склейки строк; иных причин у неизменяемости нет
    показать ответ и разбор
    +C)Безопасное разделение: пул строк, надёжные ключи map, потокобезопасность, кэшируемый hashCode

    // разбор: Раз строку нельзя изменить после создания, её можно безопасно разделять: работает пул строковых литералов (экономия памяти), String годится в ключи HashMap (хеш и равенство не «поедут» после вставки), объект потокобезопасен без синхронизации, а hashCode можно посчитать один раз и закэшировать. Платой служит то, что любое «изменение» создаёт новую строку — для активной конкатенации берут StringBuilder. Неизменяемость — не про экономию буфера, а про безопасность разделения.

  3. #types_strings3 / 5
    String s = new String("x"); чем s отличается от литерала "x"?
    A)Ничем: компилятор оптимизирует new String в обычный литерал, и s == "x" даёт true
    B)S хранится в пуле строк, а литерал — в куче, поэтому сравнение по == вернёт false из-за этого
    C)New String завершится ошибкой компиляции: конструктор String со строкой давно запрещён
    D)new создаёт отдельный объект в куче, поэтому s != "x" по ==; s.intern() вернёт объект из пула
    показать ответ и разбор
    +D)new создаёт отдельный объект в куче, поэтому s != "x" по ==; s.intern() вернёт объект из пула

    // разбор: Литерал "x" ссылается на объект из пула строк. new String("x") ЯВНО создаёт новый объект в куче со своим содержимым, поэтому s == "x" даёт false (ссылки разные), хотя s.equals("x") — true. Метод s.intern() вернёт каноничный экземпляр из пула, и тогда == с литералом станет true. Поэтому строки сравнивают через equals, а new String(...) без нужды — антипаттерн (лишний объект мимо пула).

  4. #types_strings4 / 5
    Почему для склейки строк в цикле берут StringBuilder, а не оператор +?
    A)StringBuilder — изменяемый буфер: дописывает на месте, тогда как += в цикле плодит промежуточные строки
    B)Оператор + для строк не компилируется внутри тела цикла — писать его разрешено лишь в линейном коде
    C)StringBuilder сжимает результат, поэтому итоговая строка занимает меньше места в памяти
    D)Разницы в производительности нет, StringBuilder нужен лишь чтобы код красивее выглядел
    показать ответ и разбор
    +A)StringBuilder — изменяемый буфер: дописывает на месте, тогда как += в цикле плодит промежуточные строки

    // разбор: String неизменяем, поэтому каждое s = s + x создаёт новый объект и копирует всё накопленное — в цикле это квадратичная работа и куча мусора. StringBuilder — изменяемый буфер: append дописывает в него, расширяя массив по мере надобности, а строку материализует один раз в конце (toString). Для одиночной склейки пары литералов компилятор и сам подставит StringBuilder, но в цикле его надо заводить руками. Это про производительность и давление на GC, не про размер результата.

  5. #types_strings5 / 5
    Integer i = null; int x = i; — что произойдёт?
    A)X получит значение 0, так как null-обёртка при распаковке трактуется как ноль по умолчанию
    B)X станет специальным значением, а поймать ошибку удастся только через отдельную проверку типа
    C)Ошибка компиляции: присвоить Integer переменной int напрямую не получится
    D)NullPointerException при распаковке (unboxing): вызывается i.intValue() у null-ссылки
    показать ответ и разбор
    +D)NullPointerException при распаковке (unboxing): вызывается i.intValue() у null-ссылки

    // разбор: Присваивание int x = i требует распаковки (unboxing), которая под капотом вызывает i.intValue(). Раз i == null, вызов метода у null даёт NullPointerException — причём в неожиданном месте, где явного разыменования в коде не видно. Это классическая ловушка autoboxing: обёртка может быть null, примитив — нет. Особенно коварно в тернарниках и коллекциях. Защита — проверять на null или работать в терминах обёрток там, где значение может отсутствовать.

дальше

Теорию прочитали. Навык ставится повторением

В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.