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