🔤 Раздел 12 · Вопрос #13

Что делает метод 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 это принесло гигантскую оптимизацию памяти:

  1. Уменьшение размера самого инстанса String на 8 байт:
    • В 64-битной JVM с Compressed OOPs удаление двух 4-байтовых полей int offset и int count уменьшило размер заголовка и полей объекта String с 32 байт до 24 байт (с учетом 8-байтового выравнивания).
    • В реальных Java-приложениях строки составляют от 20% до 40% всех объектов в куче. Уменьшение размера каждого инстанса String на 25% сэкономило сотни мегабайт памяти в промышленных серверах без изменения строчки кода.
  2. Ликвидация скрытых утечек памяти: Большинство строк в 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, сетевые шлюзы, парсеры логов) требуется извлекать подстроки без копирования массивов:

  1. Интерфейс CharSequence: Использование CharSequence.subSequence(), если целевой алгоритм принимает CharSequence.
  2. java.nio.CharBuffer: Метод CharBuffer.wrap(str).subSequence(start, end) создает легковесную обертку над исходной последовательностью без копирования памяти.
  3. 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) в следующих трех случаях:

  1. beginIndex < 0 (отрицательный начальный индекс).
  2. endIndex > length() (конечный индекс превышает фактическую длину строки).
  3. 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-интервью

  1. Эволюция реализации: Java 6 (shared char[], $O(1)$, memory leak risk) $\to$ Java 7u6+ (isolated array copy, $O(N)$, safe) $\to$ Java 9+ (Compact Strings byte[] с сохранением coder).
  2. Экономия памяти: Удаление offset и count сократило размер каждого экземпляра String с 32 до 24 байт (Compressed OOPs).
  3. Fast Path: str.substring(0, str.length()) возвращает this без создания новых объектов.
  4. Устаревший воркэраунд: Конструкция new String(str.substring(...)) была нужна в Java 6 для разрыва связи с родительским массивом; в Java 17/21 это антипаттерн (двойная аллокация).
  5. Zero-Copy альтернативы: CharSequence.subSequence(), CharBuffer.wrap(), Netty ByteBuf.slice().

Красные флаги (Чего говорить категорически нельзя)

  • ❌ Утверждать, что substring() в Java 17 работает за $O(1)$ без копирования массивов.
  • ❌ Рекомендовать new String(str.substring(...)) для «оптимизации памяти» в современном коде.
  • ❌ Забывать, что endIndex является не включительным (exclusive).
  • ❌ Думать, что substring() изменяет исходную строку (строки неизменяемы).

Связанные вопросы