🔤 Розділ 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) $\to$ Java 7u6+ (ізольована копія масиву, $O(N)$, безпечно) $\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() змінює вихідний рядок (рядки незмінні).

Пов’язані питання