Вопрос 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. Что такое compact strings в Java 9+]]
  • [[22. Что такое String deduplication в G1 GC]]
  • [[1. Как работает String Pool]]
  • [[13. Что делает метод substring() и как он работал до Java 7]]