Получить доступ

Строки: неизменяемость, пул и StringBuilder

Почему String immutable, String pool и intern, конкатенация и StringBuilder, char/code points и compact strings.

Почему String неизменяем

String — финальный класс с финальным внутренним хранилищем: после создания строку нельзя изменить, любые «модификации» возвращают новый объект. Это сознательное проектное решение, а не ограничение.

Зачем: безопасность (строку-имя файла/класса/SQL нельзя подменить после проверки), кэширование hashCode (считается один раз — строки идеальны как ключи Map), потокобезопасность без синхронизации и возможность безопасно делить один объект между местами (пул).

Как именно кэшируется hashCode: у String есть приватное поле int hash. Первый вызов hashCode() считает значение (по формуле s[0]*31^(n-1) + … + s[n-1]) и сохраняет его в это поле, а последующие вызовы возвращают сохранённое. Это безопасно ровно потому, что содержимое строки никогда не меняется — у изменяемого объекта такой трюк сломал бы контракт hashCode. Нюанс: для строк с hashCode == 0 (например, пустая строка) кэш не «срабатывает» и пересчитывается каждый раз, но это копеечный случай.

String pool и intern

Строковые литералы хранятся в пуле строк: одинаковый литерал во всём коде — это один и тот же объект. А new String("x") всегда создаёт новый объект в куче, не из пула. Поэтому "x" == "x" может быть true, а new String("x") == "x" — false.

Как устроен пул внутри: это служебная хеш-таблица JVM (string table). С Java 7 сами строки пула живут в обычной куче (раньше — в PermGen), поэтому интернированные строки могут собираться GC, если на них не осталось ссылок. Литералы интернируются автоматически при загрузке класса; для произвольной строки то же делает intern(): ищет в таблице равную (по equals) и возвращает её, либо кладёт текущую и возвращает её.

Деталь для глубокого вопроса: размер этой таблицы задаётся флагом -XX:StringTableSize, и при массовом intern() слишком маленькая таблица даёт коллизии и заметно тормозит. Поэтому ручным intern злоупотреблять не стоит.

intern() возвращает каноническую (пуловую) ссылку. Вручную им пользуются редко (массовый intern может раздуть string table); понимать механизм нужно прежде всего чтобы не попадаться на == для строк.

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

Конкатенация и StringBuilder

Поскольку String неизменяем, конкатенация создаёт новые объекты. Одиночное a + b компилятор оптимизирует (с Java 9 — через invokedynamic и StringConcatFactory), поэтому в простых выражениях беспокоиться не о чем.

Но конкатенация в цикле (s += ...) создаёт новую строку на каждой итерации — это O(n²) по памяти и времени. Для построения строк в циклах берут StringBuilder — изменяемый буфер (внутри тот же растущий массив, что у ArrayList), амортизированно O(1) на append.

StringBuilder против StringBuffer: API у них одинаковый, но StringBuffer синхронизирован (каждый метод под замком), а StringBuilder — нет. По умолчанию берут StringBuilder: он быстрее, а синхронизация StringBuffer бессмысленна для локальной переменной. StringBuffer — исторический класс, оправданный лишь в редком случае, когда один буфер действительно делится между потоками (что почти всегда признак сомнительного дизайна).

StringBuilder sb = new StringBuilder();
for (var part : parts) sb.append(part); // O(n), а не O(n^2)
String result = sb.toString();

char, code points и compact strings

char — это 16-битный UTF-16 code unit, а не «символ Unicode». Символы вне базовой плоскости (эмодзи, редкие иероглифы) кодируются суррогатной парой из двух char, поэтому length() считает code units, а не реальные символы; для символов используют codePointCount/codePointAt.

С Java 9 действует compact strings — это автоматический, встроенный в JVM механизм, включённый по умолчанию и полностью прозрачный для кода: внутреннее хранилище String сменилось с char[] на byte[] плюс поле-coder. Если строка состоит только из Latin-1, она хранится по одному байту на символ (а не два), что заметно экономит память — большинство строк в приложениях именно такие. Ничего настраивать не нужно; механизм можно отключить флагом -XX:-CompactStrings, но на практике это почти никогда не требуется.

Как отвечать на собеседовании

Объясните неизменяемость String и её причины (безопасность, кэш hashCode, потокобезопасность, пул), различите литерал из пула и new String (и почему строки сравнивают equals, а не ==), назовите ловушку конкатенации в цикле → StringBuilder, и блесните деталями: char = UTF-16 code unit (суррогатные пары) и compact strings с Java 9.

Вопросы по теме

Читайте также

Закрытая бета

Знать тему — половина дела

На живом собеседовании sotto слышит вопрос интервьюера и показывает короткий ответ и план. Оставьте контакт — пришлём доступ и промокод.

  • Промокод на 2 часа — сверх пробных 30 минут
  • Программа для Windows — скачать сразу, войти — через Яндекс ID или VK ID
  • Прямая связь с разработчиком: что улучшить — сделаем