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

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

Розмір рядка в пам'яті залежить від версії Java і від вмісту рядка.

Мовні версії: English Russian Ukrainian

🟢 Junior Level

Розмір рядка в пам’яті залежить від версії Java і від вмісту рядка.

Простий розрахунок для Java 9+:

Загальний розмір = об'єкт String (~24 байти) + масив byte[] (~16 байт заголовок) + символи

Приклад:

String s = "Hello"; // 5 символів Latin-1
// String object: ~24 bytes
// byte[5] array:  ~21 byte (16 заголовок + 5 даних, вирівняно до 24)
// Разом: ~48 bytes

Як точно виміряти: Використовуйте бібліотеку JOL (Java Object Layout):

import org.openjdk.jol.info.GraphLayout;

String s = "Hello";
System.out.println(GraphLayout.parseInstance(s).totalSize());
// Виведе точний розмір у байтах, включаючи сам об'єкт і всі пов'язані дані

Проста аналогія: String — це коробка (об’єкт String), всередині якої лежить інша коробка (масив байт). Щоб дізнатися загальний розмір, потрібно скласти обидві коробки.


🟡 Middle Level

Структура об’єкта String (Java 9+, 64-bit JVM з CompressedOops)

String Object (24 bytes):
├── Mark Word (object header):    12 bytes
├── Class Pointer (compressed):    4 bytes
├── byte[] value (reference):      4 bytes
├── byte coder:                    1 byte
├── int hash:                      4 bytes
├── Padding:                       3 bytes (до 8-byte alignment)
└── TOTAL:                        24 bytes (округлено до кратного 8)

byte[] Array:
├── Array Header (Mark + Class):  12 bytes
├── Array length:                  4 bytes
├── Data:                          N bytes (1 byte/char для Latin-1, 2 для UTF-16)
├── Padding:                       до 8-byte boundary
└── TOTAL:                        16 + N (округлено до 8) bytes

Приклади розрахунку

Рядок Java 8 (char[]) Java 9+ (Latin-1) Java 9+ (UTF-16)
"" 40 bytes 48 bytes N/A
"Hello" 48 bytes 48 bytes N/A
"Привіт" 52 bytes N/A 52 bytes
100 chars (Latin-1) 232 bytes 140 bytes N/A
100 chars (mixed) 232 bytes N/A 240 bytes

Як виміряти на практиці

JOL (Java Object Layout):

<dependency>
    <groupId>org.openjdk.jol</groupId>
    <artifactId>jol-core</artifactId>
    <version>0.17</version>
</dependency>
// Повний розмір (String + масив)
long total = GraphLayout.parseInstance(s).totalSize();

// Детальна розкладка
System.out.println(GraphLayout.parseInstance(s).toPrintable());

Таблиця типових помилок

Помилка Наслідки Рішення
Використовувати sizeof як в C++ В Java немає sizeof Використовуйте JOL або Instrumentation.getObjectSize()
Рахувати тільки символи, забуваючи про заголовки Заниження на ~28–40 bytes Завжди враховуйте overhead: String object + array header
Не враховувати CompressedOops Неправильні розрахунки для Heap > 32GB Без CompressedOops кожен указник = 8 bytes замість 4

Порівняння: Java 8 vs Java 9+

Аспект Java 8 Java 9+ (Latin-1) Java 9+ (UTF-16)
Внутрішній масив char[] (2 bytes/char) byte[] (1 byte/char) byte[] (2 bytes/char)
Поле coder Немає 1 byte 1 byte
"Hello" розмір 48 bytes 48 bytes N/A
"Привіт" розмір 52 bytes N/A 52 bytes

Коли НЕ потрібно точно вимірювати розмір String

  • Короткоживучі рядки — Young GC збере їх безкоштовно
  • Невелика кількість рядків — overhead непомітний на фоні Heap
  • Прототипи і PoC — оптимізуйте тільки при доведеній проблемі

🔴 Senior Level

Internal Implementation — точний розрахунок

64-bit JVM з UseCompressedOops (за замовчуванням для Heap < 32GB):

// String object (Java 9+)
// Mark Word:       12 bytes (8 mark word + 4 klass pointer compressed)
// value ref:        4 bytes
// coder:            1 byte
// hash:             4 bytes
// hashIsZero:       1 byte (в деяких JDK-білдах)
// Padding:          до 24 bytes (кратне 8)
// = 24 bytes total

// byte[] array
// Mark Word:       12 bytes
// length:           4 bytes
// data:             N bytes
// Padding:          до 8-byte boundary
// = 16 + N (rounded up to 8)

Без CompressedOops (-XX:-UseCompressedOops, Heap > 32GB):

  • Кожен указник = 8 bytes замість 4
  • String object: ~40 bytes (vs 24 з compressed)
  • byte[] array: ~24 + N bytes

Edge Cases (мінімум 3)

1. String Pool — один об’єкт, багато посилань:

String s1 = "Hello";
String s2 = "Hello";
String s3 = "Hello";
// Всі три посилання вказують на ОДИН об'єкт у String Pool
// Загальна пам'ять: 44 bytes (а не 3 × 44 = 132)

2. Substring (Java 7+) — копіює масив:

String huge = "A".repeat(1_000_000); // ~1MB
String sub = huge.substring(0, 5);    // "AAAAA"
// sub — окремий byte[5], а не посилання на частину huge
// До Java 7: sub шарив масив huge (memory leak при huge.substring(0,5))
// Java 7+: копіює — безпечно, але sub = ~44 bytes

3. Interned strings — додатковий overhead:

String s = new String("Hello").intern();
// String об'єкт: ~44 bytes
// + entry у StringTable: ~24-40 bytes (залежить від JVM версії, native hashtable entry)
// Разом: ~76 bytes на унікальний інтернований рядок

4. CompressedOops вимикається при Heap > 32GB:

# При -Xmx64g: CompressedOops може вимкнутися
# String object: 40 bytes замість 24
# При 10M рядків: +160MB overhead!

5. Substring від UTF-16 рядка — наслідує UTF-16:

String mixed = "Hello Мир";    // UTF-16 (через кирилицю)
String sub = mixed.substring(0, 5); // "Hello" — все ще UTF-16!
// sub займає ~56 bytes (UTF-16: 24 String + 32 byte[10]) замість ~48 bytes
// (Latin-1: 24 String + 24 byte[5]). Різниця ~8 bytes.
// Втрата: ~5 bytes на підрядок

Продуктивність — реальні вимірювання

// JOL benchmark
String empty = "";
String latin5 = "Hello";
String cyrillic5 = "Привіт";
String latin100 = "A".repeat(100);

GraphLayout.parseInstance(empty).totalSize();     // 40 bytes
GraphLayout.parseInstance(latin5).totalSize();    // 44 bytes
GraphLayout.parseInstance(cyrillic5).totalSize(); // 52 bytes
GraphLayout.parseInstance(latin100).totalSize();  // 140 bytes
Сценарій Розмір на рядок 1M рядків 10M рядків
Empty string 40 bytes 40MB 400MB
Latin-1, 5 chars 44 bytes 44MB 440MB
UTF-16, 5 chars 52 bytes 52MB 520MB
Latin-1, 100 chars 140 bytes 140MB 1.4GB
UTF-16, 100 chars 240 bytes 240MB 2.4GB

Пам’ять і GC implications

Heap savings:

  • Compact Strings (Java 9+): ~40–50% економії для ASCII/Latin-1 рядків
  • При 70% Latin-1 рядків у додатку: загальне зниження Heap на 20–30%
  • Менше Heap → рідше Full GC → нижче latency

GC cycles:

  • Young GC: сканує Eden/Survivor — менше алокацій рядків → швидший scan
  • Old Gen: менше об’єктів → менше work для marking/compaction
  • G1 GC: менший region size для string-heavy додатків → ефективніша evacuation

Thread Safety

String — immutable, thread-safe. Розмір не змінюється після створення. coderfinal, value@Stable. Жодних race conditions при читанні розміру з кількох потоків.

Production War Story

Сценарій: Кеш 1M рядків у пам’яті (user profiles, JSON API сервіс):

  • Java 8: 1M × ~50 bytes (avg) = ~50MB
  • Java 9+ Compact: 1M × ~35 bytes (avg, 70% Latin-1) = ~35MB
  • Економія: 15MB → менше GC pressure, Full GC рідше на 25%

Сценарій 2: Highload сервіс з -Xmx2g:

  • Без CompressedOops: String overhead = ~40 bytes/object
  • З CompressedOops: String overhead = ~24 bytes/object
  • При 10M об’єктів: 160MB економії тільки на заголовках
  • Це різниця між стабільною роботою і OOM при піковому навантаженні

Сценарій 3: Log aggregator — зберігання 10M log lines у пам’яті:

  • Кожен рядок: ~200 bytes (avg, mixed Latin-1/UTF-16)
  • Разом: ~2GB тільки на рядки
  • Ввімкнення -XX:+UseStringDeduplication: економія 400MB (дублюючі log levels, host names)

Monitoring

# Перевірити CompressedOops
java -XX:+PrintFlagsFinal -version 2>&1 | grep UseCompressedOops
# bool UseCompressedOops = true  {lp64_product}

# Heap histogram — скільки рядків у пам'яті
jmap -histo:live <pid> | head -30
# num  #instances  #bytes  class name
# 1:   1234567     49382680  java.lang.String

# JOL в runtime
java -javaagent:jol-cli.jar=includes=java.lang.String -jar app.jar

# MAT (Memory Analyzer Tool)
# Heap dump → Dominator Tree → java.lang.String → Shallow/Retained Heap

# JFR — алокації
java -XX:StartFlightRecording=settings=profile,filename=recording.jfr ...
# В JFR: Memory → Object Allocation — filter by java.lang.String
// Runtime-вимірювання через Instrumentation
// (потребує -javaagent або Attach API)
long size = instrumentation.getObjectSize(stringInstance);

// JOL — повний footprint
System.out.println(GraphLayout.parseInstance(s).toFootprint());

Best Practices для Highload

  • Використовуйте JOL для точного вимірювання, не рахуйте вручну
  • CompressedOops увімкнено за замовчуванням — не вимикайте без вагомої причини
  • Compact Strings (Java 9+) дають безкоштовну економію 40–50% для Latin-1
  • String Pool: дублюючі рядки = один об’єкт (економія при високій дедуплікації)
  • Для ultra-low-latency: уникайте String, використовуйте byte[] або ByteBuf (Netty)
  • При Heap > 32GB: CompressedOops вимикається → +30–50% overhead на об’єкти → плануйте capacity
  • Для string-heavy додатків: розгляньте -XX:+UseStringDeduplication (G1 GC)
  • Профілюйте перед оптимізацією: іноді String — лише 5% Heap, і оптимізація не дасть профіту

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

Обов’язково знати:

  • Розмір String = об’єкт (~24 bytes) + byte[] масив (~16 + N bytes), де N залежить від coder
  • Latin-1: 1 байт/символ, UTF-16: 2 байти/символ (Java 9+)
  • "Hello" ≈ 48 bytes (24 String + 24 byte[5]), "Привіт" ≈ 52 bytes (UTF-16)
  • JOL (Java Object Layout) — бібліотека для точного вимірювання: GraphLayout.parseInstance(s).totalSize()
  • CompressedOops (за замовчуванням для Heap < 32GB) зменшує указники з 8 до 4 байт
  • Без CompressedOops (Heap > 32GB): String object ≈ 40 bytes замість 24

Часті уточнюючі запитання:

  • Як точно дізнатися розмір String? — JOL: GraphLayout.parseInstance(s).totalSize(). Або Instrumentation.getObjectSize().
  • Чому "" (порожній рядок) займає 48 байт? — 24 bytes (String object) + 24 bytes (порожній byte[] array із заголовком).
  • Чи впливає String Pool на розмір? — Так: дублюючі літерали = один об’єкт. 100 посилань на "Hello" = 48 bytes total, а не 4800.
  • Що буде при Heap > 32GB? — CompressedOops вимикається, кожен указник = 8 bytes. При 10M рядків: +160MB overhead.

Червоні прапорці (НЕ говорити):

  • ❌ “В Java є sizeof як в C++” — ні, використовуйте JOL або Instrumentation
  • ❌ “Розмір String = тільки довжина символів” — забудьте про заголовки об’єктів (~40 bytes overhead)
  • ❌ “CompressedOops завжди увімкнено” — вимикається при Heap > 32GB
  • ❌ “substring() досі шарить масив” — з Java 7u6 копіює, memory leak виправлено

Пов’язані теми:

  • [[19. Що таке компактні рядки в Java 9+]]
  • [[22. Що таке дедуплікація рядків в G1 GC]]
  • [[1. Як працює String Pool]]
  • [[13. Що робить метод substring() і як він працював до Java 7]]