🔤 Розділ 12 · Питання #2

В чому різниця між створенням String через літерал та через new

У мові Java створити об'єкт рядка можна двома принципово різними шляхами: через рядковий літерал або через явний виклик конструктора new String(...).


🟢 Junior Level

У мові Java створити об’єкт рядка можна двома принципово різними шляхами: через рядковий літерал або через явний виклик конструктора new String(...).

Суть у 30 секундах

  • Через рядковий літерал (String s = "Java";): JVM автоматично перевіряє String Pool (пул рядків у купі). Якщо рядок "Java" уже створювався раніше, JVM не створює новий об’єкт, а повторно використовує наявний і повертає посилання на нього.
  • Через оператор new (String s = new String("Java");): JVM завжди примусово створює новий об’єкт у звичайній купі (Heap), навіть якщо точно такий самий рядок уже присутній у String Pool.
// Літерали: переповторно використовують об'єкт із пулу
String s1 = "Hello";
String s2 = "Hello";
System.out.println(s1 == s2); // true (посилаються на один і той самий об'єкт у пам'яті)

// Конструктор new: завжди створює окремий об'єкт
String s3 = new String("Hello");
System.out.println(s1 == s3); // false (різні об'єкти в пам'яті!)

// Порівняння вмісту через equals завжди повертає true:
System.out.println(s1.equals(s3)); // true

Порівняльна таблиця

Характеристика Літерал (String s = "text") Конструктор (new String("text"))
Де створюється об’єкт У String Pool (купа) У Regular Heap (поза пулом)
Повторне використання Так (дедуплікація на льоту) Ні (кожен new створює новий екземпляр)
Витрати пам’яті Мінімальні (0 байтів, якщо рядок уже є) Виділення пам’яті під новий об’єкт заголовка
Швидкість виконання Миттєво (інструкція ldc) Повільніше (алокація в купі + виклик <init>)
Рекомендація 100% випадків за замовчуванням Тільки при декодуванні byte[] або char[]

🟡 Middle Level

Розбір на рівні байткоду JVM

Порівняємо інструкції байткоду Java для обох підходів за допомогою javap -c:

1. Створення через літерал:

String s = "Hello";

Байткод:

0: ldc           #2  // String Hello (завантаження посилання з Constant Pool)
2: astore_1          // збереження посилання в локальну змінну s

Усього одна інструкція ldc! JVM просто бере готове посилання на об’єкт із пулу констант. Жодного виділення пам’яті в купі під час виконання методу не відбувається.

2. Створення через оператор new:

String s = new String("Hello");

Байткод:

0: new           #3  // class java/lang/String (виділення сирої пам'яті в Heap)
3: dup               // дублювання посилання на вершині стека операндів
4: ldc           #2  // String Hello (завантаження літерала зі String Pool!)
6: invokespecial #4  // Method java/lang/String."<init>":(Ljava/lang/String;)V
9: astore_1          // збереження посилання на новий об'єкт у змінну s

Виконуються 4 важкі інструкції: алокація пам’яті в Heap (new), дублювання посилання (dup), завантаження літерала (ldc) та виклик конструктора (invokespecial).

Класичне запитання: скільки об’єктів створюється при new String("Hello")?

Це одне з найчастіших підступних запитань на співбесіді:

Відповідь залежить від того, чи зустрічався літерал "Hello" у JVM раніше!

  1. Якщо клас щойно завантажився, і рядка "Hello" ще не було в пулі: Створюється 2 об’єкти:
    • Об’єкт №1: рядковий літерал "Hello" реєструється в String Pool (під час резолвінгу константного пулу класу).
    • Об’єкт №2: новий об’єкт String створюється оператором new у звичайній купі (Regular Heap).
  2. Якщо літерал "Hello" вже був присутній у String Pool раніше: Створюється рівно 1 об’єкт — новий екземпляр String у Regular Heap (літерал просто повторно береться з пулу).

🔴 Senior Level

Як влаштований конструктор new String(String original) всередині HotSpot

У вихідному коді OpenJDK (Java 9+ з механізмом Compact Strings) конструктор копіювання реалізовано наступним чином:

// java.lang.String (OpenJDK 17/21)
public String(String original) {
    this.value = original.value; // УВАГА: масив байтів РОЗДІЛЯЄТЬСЯ!
    this.coder = original.coder;
    this.hash = original.hash;
}

Senior-інсайт: Коли ви пишете new String("Hello"), ви НЕ створюєте другий масив байтів byte[] у пам’яті!

  • Новий об’єкт у Regular Heap просто посилається на той самий внутрішній масив byte[] value, що й літерал у String Pool!
  • Ви витрачаєте оперативну пам’ять виключно на дублюючу «обгортку» об’єкта String:
    • 12 байтів — Mark Word та Klass Pointer (при увімкненому +UseCompressedClassPointers).
    • 4 байти — посилання на масив value.
    • 4 байти — кешований int hash.
    • 1 байт — byte coder (LATIN1 проти UTF16).
    • 1 байт — boolean hashIsZero.
    • 2 байти — padding (вирівнювання до кратності 8 байтів).
    • Разом: рівно 24 байти в купі даремно на кожен виклик new String()!

Згортання констант (Constant Folding) проти Конкатенації в рантаймі

Розуміння різниці між створенням у Compile-Time та Runtime критично важливе для запобігання прихованим витокам продуктивності:

// Приклад 1: Constant Folding (JLS §15.28)
String s1 = "hello world";
String s2 = "hello" + " " + "world"; 
// Компілятор javac сам з'єднує літерали під час компіляції!
// У байткоді s2 скомпілюється як ldc "hello world".
System.out.println(s1 == s2); // true! (один і той самий об'єкт у пулі)

// Приклад 2: Константи з модифікатором final
final String a = "hello";
final String b = " world";
String s3 = a + b; 
// Оскільки змінні a та b оголошені як final та ініціалізовані літералами,
// javac підставляє їхні значення під час компіляції!
System.out.println(s1 == s3); // true!

// Приклад 3: Звичайні змінні (Runtime)
String c = "hello";
String d = " world";
String s4 = c + d; 
// У Java 9+ генерується інструкція invokedynamic (StringConcatFactory).
// Створюється НОВИЙ об'єкт у Regular Heap (НЕ потрапляє до пулу автоматично).
System.out.println(s1 == s4); // false!
System.out.println(s1 == s4.intern()); // true (intern повернув посилання з пулу)

Історичний контекст: навіщо взагалі створили new String(String)?

До виходу Java 7 Update 6 (2012 рік) реалізація String містила 4 поля:

// Реалізація String у Java 6:
char[] value;
int offset;
int count;
int hash;
  • Метод substring() у Java 6 не копіював символи, а створював новий об’єкт String, що вказував на той самий гігантський масив char[], просто з іншими offset та count.
  • Проблема Memory Leak: Якщо ви читали текстовий файл на 100 МБ в один рядок і робили String sub = bigString.substring(0, 5), посилання sub утримувало в пам’яті весь масив на 100 МБ!
  • Рішення в Java 6: Викликали new String(bigString.substring(0, 5)). Конструктор примусово алокував новий масив рівно під 5 символів (Arrays.copyOfRange), дозволяючи GC видалити початкові 100 МБ.
  • У сучасних версіях Java (7u6+): Метод substring() завжди фізично копіює масив символів, тому конструктор new String(String) остаточно став застарілим антипатерном.

4 Tricky Questions

1. Скільки об’єктів створюється в коді String s = new String("Java");, і чому відповідь може бути як «один», так і «два»?

Відповідь:

  • Створюється 2 об’єкти, якщо рядковий літерал "Java" зустрічається в застосунку вперше. Перший об’єкт створюється в String Pool під час завантаження класу та резолвінгу його пулу констант (Constant Pool). Другий об’єкт створюється в Regular Heap оператором new.
  • Створюється рівно 1 об’єкт, якщо клас уже було завантажено, або літерал "Java" зустрічався в коді раніше. У цьому випадку рядковий об’єкт у пулі вже існує, й оператор new створює лише один новий об’єкт у звичайній купі.

2. Чому дорівнюватиме результат s1 == s2 у наступному фрагменті коду, і чому:

final String a = "foo";
final String b = "bar";
String s1 = "foobar";
String s2 = a + b;

Відповідь: Вираз поверне true. Розбір: Згідно зі специфікацією мови Java (JLS §15.28 Constant Expressions), якщо змінні оголошено з модифікатором final та ініціалізовано константними рядковими виразами під час компіляції, вираз a + b є константним виразом часу компіляції (Compile-Time Constant Expression). Компілятор javac обчислює значення на етапі збірки та поміщає в байткод готовий рядок "foobar" (ldc #index). У рантаймі змінна s2 посилається на той самий об’єкт зі String Pool, що й s1. Якщо прибрати ключове слово final хоча б з однієї зі змінних, конкатенація відбудеться в рантаймі через invokedynamic, повернеться новий об’єкт у купі, і порівняння поверне false.

3. Чи розділяють об’єкти s1 та s2 один і той самий масив байтів byte[] у наступному коді на Java 21:

String s1 = "Hello";
String s2 = new String(s1);

Відповідь: Так, розділяють! У вихідному коді конструктора public String(String original) поле value не копіюється через Arrays.copyOf, а присвоюється напряму: this.value = original.value;. Оскільки рядки та їхні внутрішні масиви байтів є абсолютно незмінними (імутабельними та інкапсульованими всередині пакета java.lang), розробникам JVM немає сенсу витрачати пам’ять на копіювання сирих байтів. У результаті s1 та s2 є двома різними об’єктами String у пам’яті (s1 != s2), але їхні внутрішні поля value вказують на один і той самий масив byte[] у купі!

4. У яких рідкісних практичних сценаріях у сучасному Java-коді дійсно необхідний виклик конструктора new String(...)?

Відповідь: Конструктор new String(...) необхідний виключно під час перетворення сирих бінарних або символьних даних у рядковий тип:

  1. Декодування байтів при мережевому чи файловому I/O: new String(byteArray, StandardCharsets.UTF_8) — перетворення отриманого мережевого пакета в рядок із явною вказівкою кодування.
  2. Конвертація масивів символів: new String(charArray) — коли символи збиралися вручну в буфері.
  3. Безпечне очищення паролів: при роботі з char[] password (хоча самі паролі рядками представляти не рекомендується, іноді потрібно сформувати одноразовий рядок для API). Виклик же конструктора new String(String str) у сучасному коді не має жодного валідного застосування.

🎯 Шпаргалка для інтерв’ю

30-секундна відповідь

«Створення через літерал ("Java") використовує String Pool: JVM повертає посилання на вже наявний об’єкт із пулу (інструкція ldc), заощаджуючи пам’ять. Створення через new String("Java") примусово виділяє пам’ять під новий об’єкт у звичайній купі (Regular Heap), ігноруючи пул. При цьому створений через new об’єкт розділяє той самий внутрішній масив byte[] value, що й літерал, даремно витрачаючи 24 байти на заголовок об’єкта. На співбесіді на запитання «Скільки об’єктів створюється при new String(“text”)?» правильна відповідь — два, якщо літерал зустрівся вперше, та один, якщо він уже був у пулі».

Чек-лист ключових понять

  1. Літерал: Використовує String Pool, 1 інструкція байткоду (ldc), 0 алокацій при повторі.
  2. Конструктор new: Створює новий об’єкт у Regular Heap (s1 != s2, але s1.equals(s2) == true).
  3. Скільки об’єктів створюється: 1 або 2 (залежно від наявності літерала в пулі констант).
  4. Внутрішній масив байтів: Конструктор new String(String) повторно використовує масив byte[] value без копіювання.
  5. Constant Folding: Конкатенація літералів та final-змінних оптимізується компілятором javac у константу з пулу.

Червоні прапорці (чого в жодному разі не можна говорити)

  • ❌ «new String(“Hello”) завжди створює рівно один об’єкт» (Забувають про створення літерала в String Pool).
  • ❌ «new String(“Hello”) копіює внутрішній масив символів» (У HotSpot поле value присвоюється за посиланням).
  • ❌ «Конструктор new String(str) потрібен для безпеки багатопоточності» (Рядки імутабельні від самого початку, конструктор не додає жодної безпеки).

Посилання на пов’язані теми