Что делает метод substring() и как он работал до Java 7
Метод substring() класса String возвращает новую строку, представляющую собой часть (подстроку) исходной строки, начиная с указанного начального индекса и заканчивая указанным к...
🟢 Junior Level
Метод substring() класса String возвращает новую строку, представляющую собой часть (подстроку) исходной строки, начиная с указанного начального индекса и заканчивая указанным конечным индексом.
У метода есть две основные перегрузки:
substring(int beginIndex)— возвращает подстроку от индексаbeginIndex(включительно) до конца строки.substring(int beginIndex, int endIndex)— возвращает подстроку от индексаbeginIndex(включительно) до индексаendIndex(исключительно, то есть символ с индексомendIndexв подстроку не входит).
Суть в 30 секундах
Главная интрига этого метода на собеседованиях — его кардинальное изменение в Java 7 Update 6:
- До Java 7u6 (Java 1.0 – 6): Метод работал за $O(1)$ без копирования символов. Новая строка ссылалась на тот же самый массив символов
char[]родительской строки, просто сдвигая смещение (offset) и длину (count). Это приводило к катастрофическим утечкам памяти (Memory Leaks): маленькая подстрока из 5 символов удерживала в куче весь гигабайтный массив родителя. - Начиная с Java 7u6 и по сей день (Java 17/21): Метод работает за $O(N)$. При вызове выделяется новый массив символов/байтов, в который физически копируются нужные данные. Память изолирована, и сборщик мусора может безопасно удалить родительскую строку.
String text = "Java Interview Guide";
// 1. Извлечение с 0 по 4 индекс (символы 0, 1, 2, 3)
String sub1 = text.substring(0, 4); // "Java"
// 2. Извлечение от 5 индекса до конца
String sub2 = text.substring(5); // "Interview Guide"
// 3. Граничный случай: вся строка целиком
String same = text.substring(0, text.length());
System.out.println(same == text); // true (HotSpot возвращает this без аллокаций!)
🟡 Middle Level
Внутреннее устройство: эволюция полей String
Чтобы понять, как работал старый и работает новый substring(), сравним состав полей класса java.lang.String.
1. Реализация в Java 6 (Shared Array)
В Java 6 класс String содержал 4 поля:
public final class String {
private final char[] value; // Общий массив символов
private final int offset; // Индекс первого символа строки в массиве
private final int count; // Количество символов в строке
private int hash; // Кэшированный хеш-код
}
При вызове substring() вызывался package-private конструктор:
// Конструктор Java 6:
String(int offset, int count, char[] value) {
this.value = value; // ПРЯМАЯ ПЕРЕДАЧА ССЫЛКИ! Массив не копируется!
this.offset = offset;
this.count = count;
}
Плюс: Мгновенная работа за $O(1)$, ноль аллокаций массивов.
Минус (Memory Leak): Если прочитать файл в 100 МБ и взять line.substring(0, 10), маленькая строка хранила ссылку на 100-мегабайтный массив char[]. Даже если исходный объект line обнулялся, сборщик мусора не мог освободить 100 МБ!
2. Реализация в Java 7u6+ и Java 8 (Copying)
В обновлении Java 7u6 архитекторы JVM удалили поля offset и count. Теперь String стал содержать только:
public final class String {
private final char[] value; // Массив строго размера данной строки
private int hash;
}
Метод substring() стал вызывать Arrays.copyOfRange():
public String substring(int beginIndex, int endIndex) {
checkBoundsBeginEnd(beginIndex, endIndex, length());
int subLen = endIndex - beginIndex;
if (beginIndex == 0 && endIndex == length()) {
return this; // Оптимизация: возвращаем сам объект
}
return new String(value, beginIndex, subLen); // Копирует подмассив
}
3. Реализация в Java 9+ (Compact Strings)
С переходом на Compact Strings (JEP 254) массив char[] заменен на byte[] value с полем byte coder:
- Для Latin-1 подстрок (
coder = 0) копируется 1 байт на символ. - Для UTF-16 подстрок (
coder = 1) копируется 2 байта на символ.
Сравнительная таблица
| Параметр | Java 1.0 – 6 (до 7u6) | Java 7u6 – 8 | Java 9 – 21+ |
| :— | :— | :— | :— |
| Внутренний массив | char[] (общий с родителем) | char[] (новый изолированный) | byte[] (новый изолированный) |
| Поля класса String | value, offset, count, hash | value, hash | value, coder, hash |
| Сложность по времени | $O(1)$ | $O(N)$ (длина подстроки) | $O(N)$ (длина подстроки) |
| Аллокация памяти | Только заголовок String (0 байт массива) | Новый char[] нужной длины | Новый byte[] нужной длины |
| Утечка памяти (Memory Leak) | ⚠️ Высокий риск удержания больших массивов | ❌ Отсутствует | ❌ Отсутствует |
| Размер объекта String в куче | 32 байта (Compressed OOPs) | 24 байта (Compressed OOPs) | 24 байта (Compressed OOPs) |
🔴 Senior Level
Почему удаление offset и count стало огромной победой по памяти
На первый взгляд, отказ от $O(1)$ в пользу $O(N)$ кажется шагом назад в производительности. Однако в масштабах всей экосистемы Java это принесло гигантскую оптимизацию памяти:
- Уменьшение размера самого инстанса
Stringна 8 байт:- В 64-битной JVM с Compressed OOPs удаление двух 4-байтовых полей
int offsetиint countуменьшило размер заголовка и полей объектаStringс 32 байт до 24 байт (с учетом 8-байтового выравнивания). - В реальных Java-приложениях строки составляют от 20% до 40% всех объектов в куче. Уменьшение размера каждого инстанса
Stringна 25% сэкономило сотни мегабайт памяти в промышленных серверах без изменения строчки кода.
- В 64-битной JVM с Compressed OOPs удаление двух 4-байтовых полей
- Ликвидация скрытых утечек памяти: Большинство строк в Java имеют короткое время жизни и короткую длину. В старой реализации удержание больших буферов парсеров (JSON, XML, CSV, логи) регулярно приводило к OutOfMemoryError в продакшене.
Быстрый путь (Fast Path) и оптимизация идентичности
В реализации OpenJDK метод substring() содержит важную микрооптимизацию:
if (beginIndex == 0 && endIndex == length()) {
return this;
}
Если запрошен диапазон, покрывающий всю строку целиком, метод не создает новый объект в куче, а возвращает ссылку this.
Это позволяет безопасно писать защитные преобразования строк без накладных расходов на аллокацию:
String s2 = s1.substring(0, s1.length()); // s2 == s1 (true)
Альтернативы Zero-Copy в современной Java
Если в высоконагруженных системах (High-Frequency Trading, сетевые шлюзы, парсеры логов) требуется извлекать подстроки без копирования массивов:
- Интерфейс
CharSequence: ИспользованиеCharSequence.subSequence(), если целевой алгоритм принимаетCharSequence. java.nio.CharBuffer: МетодCharBuffer.wrap(str).subSequence(start, end)создает легковесную обертку над исходной последовательностью без копирования памяти.- Netty
ByteBuf.slice(): В сетевом программировании фреймворк Netty использует слайсы буферов (Zero-Copy Slices), разделяющие единый нативный буфер через сдвиг ридеров и райтеров.
4 Tricky Questions
1. Что вернет вызов str.substring(0, str.length()) в современной Java — создастся ли новый объект в куче?
Ответ:
Новый объект не создается.
В исходном коде метода substring() первой строкой идет проверка:
if (beginIndex == 0 && endIndex == length()) return this;
Поскольку строка иммутабельна, JVM безопасно возвращает ссылку на исходный объект this. Сравнение str == str.substring(0, str.length()) вернет true, а накладные расходы памяти и процессора равны нулю.
2. Зачем в Java 6 при вызове substring часто писали new String(str.substring(x, y)) и почему в Java 17 это грубый антипаттерн?
Ответ:
- В Java 6: Вызов
str.substring(x, y)не копировал массив символов, а создавал подстроку, ссылающуюся на родительскийchar[]. Если родительская строка была огромной (например, файл на 50 МБ), оберткаnew String(...)вызывала специальный конструктор-копировщикString(String original), который принудительно создавал независимый массивchar[]точной длины, позволяя сборщику мусора освободить 50-мегабайтный массив родителя. - В Java 17: Метод
substring()уже сам всегда выделяет новый массив точной длины черезArrays.copyOfRange(). Дополнительный вызовnew String(...)приведет к созданию абсолютно бессмысленного второго объекта-дубликата и второго массива в куче, создавая двойную нагрузку на память и GC.
3. Какие исключения и при каких условиях выбрасывает метод substring(int beginIndex, int endIndex)?
Ответ:
Метод выбрасывает java.lang.StringIndexOutOfBoundsException (наследник IndexOutOfBoundsException) в следующих трех случаях:
beginIndex < 0(отрицательный начальный индекс).endIndex > length()(конечный индекс превышает фактическую длину строки).beginIndex > endIndex(начальный индекс больше конечного, напримерstr.substring(5, 2)). Примечание: Вызовstr.substring(str.length(), str.length())является допустимым и возвращает пустую строку""без выброса исключения.
4. Может ли метод substring() в Java 9+ вернуть строку с меньшим потреблением байт на символ, чем у исходной строки?
Ответ:
В текущей реализации OpenJDK HotSpot — нет, не может (наследует coder).
В Java 9+ метод substring вызывает:
isLatin1() ? StringLatin1.newString(...) : StringUTF16.newString(...)
Если исходная строка содержала хотя бы один кириллический символ, ее поле coder равно UTF16 (2 байта на символ). Даже если из этой строки с помощью substring() вырезать подстроку, состоящую исключительно из английских букв ("abc"), она все равно будет сохранена в кодировке UTF-16 с coder = 1 и расходом 2 байта на символ. JVM не выполняет повторное сканирование символов на предмет возможности сжатия до Latin-1 ради экономии CPU-тактов.
🎯 Шпаргалка для интервью
30-секундный ответ
«Метод
substring()возвращает часть строки отbeginIndex(включительно) доendIndex(исключительно). В Java 6 он работал за $O(1)$, разделяя общий массив символовchar[]с родительской строкой через смещенияoffsetиcount. Это приводило к утечкам памяти, когда маленькая подстрока удерживала мегабайтный массив родителя. В Java 7u6 поляoffsetиcountудалили, уменьшив размер объектаStringна 8 байт, аsubstring()стал копировать символы в новый изолированный массив за $O(N)$».
Ключевые тезисы для Senior-интервью
- Эволюция реализации: Java 6 (shared
char[], $O(1)$, memory leak risk) $\to$ Java 7u6+ (isolated array copy, $O(N)$, safe) $\to$ Java 9+ (Compact Stringsbyte[]с сохранениемcoder). - Экономия памяти: Удаление
offsetиcountсократило размер каждого экземпляраStringс 32 до 24 байт (Compressed OOPs). - Fast Path:
str.substring(0, str.length())возвращаетthisбез создания новых объектов. - Устаревший воркэраунд: Конструкция
new String(str.substring(...))была нужна в Java 6 для разрыва связи с родительским массивом; в Java 17/21 это антипаттерн (двойная аллокация). - Zero-Copy альтернативы:
CharSequence.subSequence(),CharBuffer.wrap(), NettyByteBuf.slice().
Красные флаги (Чего говорить категорически нельзя)
- ❌ Утверждать, что
substring()в Java 17 работает за $O(1)$ без копирования массивов. - ❌ Рекомендовать
new String(str.substring(...))для «оптимизации памяти» в современном коде. - ❌ Забывать, что
endIndexявляется не включительным (exclusive). - ❌ Думать, что
substring()изменяет исходную строку (строки неизменяемы).