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

Як дізнатися, скільки пам'яті займає String

У Java немає оператора sizeof, як у C/C++, тому розмір рядка в пам'яті розраховується виходячи з внутрішньої структури об'єктів віртуальної машини JVM.


🟢 Junior Level

У Java немає оператора sizeof, як у C/C++, тому розмір рядка в пам’яті розраховується виходячи з внутрішньої структури об’єктів віртуальної машини JVM.

Рядок String у пам’яті — це не один об’єкт, а два пов’язаних об’єкти в купі (Java Heap):

  1. Сам об’єкт-обгортка java.lang.String (містить заголовок, посилання на масив, прапорець кодування та кешований хеш-код).
  2. Незалежний масив байтів byte[] value (містить заголовок масиву, довжину та фізичні дані символів).

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

На стандартній 64-бітній JVM з увімкненим стисненням посилань (Compressed OOPs, обсяг купи до 32 ГБ): \(\text{Загальний розмір} = \text{Об'єкт String (24 байти)} + \text{Масив byte[] (16 байтів заголовок + дані + вирівнювання)}\)

  • Для латинських символів (ASCII / Latin-1) у Java 9+: 1 байт на символ.
  • Для кирилиці та емодзі (UTF-16): 2 байти на символ.
// Простий розрахунок для рядка "Hello" (5 символів Latin-1):
// 1. Об'єкт String: 24 байти
// 2. Масив byte[5]: 16 байтів (заголовок) + 5 байтів (дані) = 21 байт -> округлення до 24 байтів
// РАЗОМ: 24 + 24 = 48 байтів у пам'яті купи!

Для точного програмного вимірювання використовується бібліотека JOL (Java Object Layout) від команди OpenJDK:

import org.openjdk.jol.info.GraphLayout;

String text = "Hello";
// Повертає точний розмір об'єкта та всіх пов'язаних даних у байтах:
long totalBytes = GraphLayout.parseInstance(text).totalSize(); // 48 байтів

🟡 Middle Level

Розділення понять: Shallow Size проти Retained Size

  1. Shallow Size (поверхневий розмір): Кількість пам’яті, яку займає тільки сам об’єкт java.lang.String без урахування об’єктів, на які він посилається. У 64-бітній JVM з Compressed OOPs shallow size завжди дорівнює строго 24 байтам.
  2. Retained Size (утримуваний розмір / Deep Size): Пам’ять самого об’єкта плюс пам’ять усіх об’єктів, які будуть видалені збирачем сміття, якщо цей об’єкт буде знищено (тобто String (24 байти) + масив byte[] value).

Точна розкладка полів у HotSpot (64-bit, Compressed OOPs)

Об'єкт java.lang.String (24 байти):
├── Mark Word:               8 байтів  (стан блокувань, вік GC, хеш)
├── Compressed Klass Pointer: 4 байти   (покажчик на клас java.lang.String)
├── Посилання на byte[] value: 4 байти  (Compressed OOP покажчик)
├── byte coder:              1 байт    (0 = LATIN1, 1 = UTF16)
├── int hash:                4 байти   (кешований хеш-код)
├── byte hashIsZero (JDK 13+):1 байт   (прапорець нульового хешу)
└── Padding (вирівнювання):   2 байти   (доповнення до кратності 8 байтів)
РАЗОМ SHALLOW SIZE: 24 байти

Масив byte[] value:
├── Mark Word:               8 байтів
├── Compressed Klass Pointer: 4 байти
├── int length:              4 байти   (довжина масиву в байтах)
├── Дані:                    N байтів  (1 байт/символ для Latin-1, 2 байти/символ для UTF-16)
└── Padding (вирівнювання):   до кратності 8 байтів
РАЗОМ РОЗМІР МАСИВУ: (16 + N + 7) & ~7 байтів

Таблиця реальних розмірів рядків у Java 17/21

| Рядок | Символів | Кодування | Розмір String | Розмір byte[] | Підсумковий Retained Size | | :— | :— | :— | :— | :— | :— | | "" (порожній рядок) | 0 | Latin-1 | 24 байти | 16 байтів (даних 0) | 40 байтів | | "A" | 1 | Latin-1 | 24 байти | $16 + 1 \to$ 24 байти | 48 байтів | | "Hello" | 5 | Latin-1 | 24 байти | $16 + 5 \to$ 24 байти | 48 байтів | | "Привіт" | 6 | UTF-16 | 24 байти | $16 + 12 \to$ 32 байти | 56 байтів | | 100 символів Latin-1 | 100 | Latin-1 | 24 байти | $16 + 100 \to$ 120 байтів | 144 байти | | 100 символів UTF-16 | 100 | UTF-16 | 24 байти | $16 + 200 \to$ 224 байти | 248 байтів |


🔴 Senior Level

Проблема вимкнення Compressed OOPs при Heap > 32 GB

Прапорець -XX:+UseCompressedOops увімкнений за замовчуванням, поки максимальний розмір купи -Xmx не перевищує приблизно 32 ГБ:

  • У цьому режимі JVM кодує 64-бітні адреси у 32-бітні покажчики зі зсувом на 3 біти (масштабування до $2^{32} \times 8 = 32 \text{ ГБ}$).
  • Що відбувається при -Xmx33g? Стиснення посилань вимикається. Усі посилання на об’єкти та Klass Pointers стають 64-бітними (8 байтів замість 4).
    • Shallow size об’єкта String збільшується з 24 до 32 байтів.
    • Заголовок масиву byte[] збільшується з 16 до 24 байтів.
    • У застосунку зі 100 мільйонами рядків накладні витрати лише на службові заголовки зростають на 1.6 гігабайта!
    • Ефект «32-гігабайтного обриву» (32 GB Cliff): JVM із купою 33 ГБ часто вміщує менше реальних бізнес-даних, ніж JVM із купою 31 ГБ.

Вимірювання в рантаймі: Instrumentation API проти JOL

Існує вбудований механізм Java Agent — java.lang.instrument.Instrumentation:

public class ObjectSizeFetcher {
    private static Instrumentation instrumentation;
    public static void premain(String args, Instrumentation inst) {
        instrumentation = inst;
    }
    public static long getShallowSize(Object o) {
        return instrumentation.getObjectSize(o);
    }
}

Критичний нюанс: instrumentation.getObjectSize(str) повертає тільки Shallow Size (24 байти) незалежно від того, містить рядок 5 символів чи 5 мільйонів символів! Для глибокого вимірювання графа об’єктів без JOL розробники будують рекурсивний обхід полів через Reflection із відстеженням відвіданих посилань (IdentityHashMap), щоб виключити циклічні посилання.

Майбутнє: Project Lilliput та Compact Object Headers (Java 21+)

У межах OpenJDK Project Lilliput ведуться роботи зі зменшення заголовків усіх Java-об’єктів:

  • У Java 21 доступний експериментальний прапорець -XX:+UnlockExperimentalVMOptions -XX:+UseCompactObjectHeaders.
  • Він стискає заголовок об’єкта з 12/16 байтів до одного 64-бітного слова (8 байтів), об’єднуючи Mark Word та стиснутий ID класу.
  • Для рядків це зменшує розмір інстанса String з 24 до 16 байтів, а заголовок масиву byte[] — з 16 до 8 байтів. Економія для коротких рядків становить до 30–40% пам’яті.

4 Tricky Questions

1. Чому instrumentation.getObjectSize("Дуже довгий текст на 10 000 символів") повертає лише 24 байти?

Відповідь: Метод Instrumentation.getObjectSize(Object obj) обчислює виключно поверхневий розмір (Shallow Size) переданого екземпляра:

  • Він враховує тільки заголовок переданого об’єкта та поля, безпосередньо оголошені в класі (включаючи примітиви та самі 4-байтові посилання на інші об’єкти).
  • Він не переходить за посиланнями на пов’язані об’єкти купи. Поле value у String — це посилання. Сам об’єкт String на 64-бітній JVM з Compressed OOPs займає рівно 24 байти незалежно від довжини тексту. Масив символів byte[] — це окремий об’єкт у купі, пам’ять якого метод getObjectSize() ігнорує.

2. Скільки пам’яті в купі споживає строковий літерал "" (порожній рядок)?

Відповідь: Порожній рядок "" споживає рівно 40 байтів (на 64-bit JVM з Compressed OOPs):

  1. Інстанс java.lang.String: 12 байтів (заголовок) + 4 байти (посилання на масив) + 1 байт (coder) + 4 байти (hash) + 1 байт (hashIsZero) + 2 байти (padding) = 24 байти.
  2. Масив byte[0]: 12 байтів (заголовок) + 4 байти (поле length = 0) + 0 байтів даних = 16 байтів. Сумарно: $24 + 16 = \mathbf{40 \text{ байтів}}$.

3. Чому при збільшенні пам’яті сервера з -Xmx31g до -Xmx33g застосунок може впасти з OutOfMemoryError швидше?

Відповідь: Тому що при переході межі в ~32 ГБ JVM вимикає стиснення посилань (Compressed OOPs, -XX:+UseCompressedOops стає false):

  • Покажчики на об’єкти та покажчики на класи збільшуються з 4 до 8 байтів.
  • Мінімальне вирівнювання та розмір заголовків усіх об’єктів зростають. Інстанс String збільшується з 24 до 32 байтів, а заголовок кожного масиву — з 16 до 24 байтів.
  • У типовому Java-застосунку рядки та колекції становлять до 70% усіх об’єктів. Збільшення службового оверхеду на 30–50% «з’їдає» додані 2 ГБ пам’яті і призводить до того, що ефективна корисна місткість купи при 33 ГБ стає меншою, ніж була при 31 ГБ.

4. Якщо в застосунку створено 100 000 змінних String s = "admin", скільки пам’яті купи вони сумарно займуть?

Відповідь: Вони займуть рівно 48 байтів на всю програму (плюс пам’ять під самі змінні-посилання на стеках потоків). Оскільки "admin" — це строковий літерал, JVM розміщує його у String Pool. Усі 100 000 змінних посилатимуться на один-єдиний фізичний екземпляр String у пам’яті купи (24 байти) та один масив byte[5] (24 байти). Якщо ж створювати рядки через new String("admin"), то буде створено 100 000 ізольованих об’єктів String, що займе $100\,000 \times 24 \text{ байти} \approx 2.4 \text{ МБ}$ купи.


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

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

«У Java немає оператора sizeof. Рядок складається з двох об’єктів у купі: інстанса String (Shallow Size = 24 байти при Compressed OOPs) та масиву byte[] value (16 байтів заголовок + дані + вирівнювання до 8 байтів). Символ латиниці в Java 9+ займає 1 байт, кирилиці — 2 байти. Порожній рядок займає 40 байтів, "Hello" — 48 байтів. Точно виміряти розмір рядка з усіма залежностями (Retained Size) дозволяє бібліотека OpenJDK JOL: GraphLayout.parseInstance(str).totalSize()».

Ключові тези для Senior-інтерв’ю

  1. Shallow vs Retained: Instrumentation.getObjectSize() міряє тільки shallow (24 байти для будь-якого String); JOL міряє повний граф із масивом byte[].
  2. Формула розрахунку: $\text{Size} = 24 + \text{align8}(16 + \text{len} \times (1 \ll \text{coder}))$.
  3. Compressed OOPs (32 GB Cliff): При -Xmx > 32g покажчики стають 64-бітними, розмір заголовків і посилань зростає, роздуваючи пам’ять на 30–40%.
  4. Project Lilliput: Стиснення заголовків об’єктів до 8 байтів у майбутніх версіях Java заощадить до 40% накладних витрат пам’яті рядків.
  5. String Pool ефект: Мільйон однакових літералів займають пам’ять лише одного об’єкта.

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

  • ❌ Стверджувати: «У Java є оператор sizeof».
  • ❌ Рахувати розмір рядка як просто «кількість літер $\times$ 2 байти», геть забуваючи про заголовки об’єктів String та byte[] (~40 байтів оверхеду).
  • ❌ Вважати, що Instrumentation.getObjectSize() рахує розмір рядка разом із масивом символів.
  • ❌ Забувати про вирівнювання пам’яті (Padding до 8 байтів).

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