Як дізнатися, скільки пам'яті займає String
У Java немає оператора sizeof, як у C/C++, тому розмір рядка в пам'яті розраховується виходячи з внутрішньої структури об'єктів віртуальної машини JVM.
🟢 Junior Level
У Java немає оператора sizeof, як у C/C++, тому розмір рядка в пам’яті розраховується виходячи з внутрішньої структури об’єктів віртуальної машини JVM.
Рядок String у пам’яті — це не один об’єкт, а два пов’язаних об’єкти в купі (Java Heap):
- Сам об’єкт-обгортка
java.lang.String(містить заголовок, посилання на масив, прапорець кодування та кешований хеш-код). - Незалежний масив байтів
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
- Shallow Size (поверхневий розмір): Кількість пам’яті, яку займає тільки сам об’єкт
java.lang.Stringбез урахування об’єктів, на які він посилається. У 64-бітній JVM з Compressed OOPs shallow size завжди дорівнює строго 24 байтам. - 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 ГБ.
- Shallow size об’єкта
Вимірювання в рантаймі: 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):
- Інстанс
java.lang.String: 12 байтів (заголовок) + 4 байти (посилання на масив) + 1 байт (coder) + 4 байти (hash) + 1 байт (hashIsZero) + 2 байти (padding) = 24 байти. - Масив
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-інтерв’ю
- Shallow vs Retained:
Instrumentation.getObjectSize()міряє тільки shallow (24 байти для будь-якого String); JOL міряє повний граф із масивомbyte[]. - Формула розрахунку: $\text{Size} = 24 + \text{align8}(16 + \text{len} \times (1 \ll \text{coder}))$.
- Compressed OOPs (32 GB Cliff): При
-Xmx > 32gпокажчики стають 64-бітними, розмір заголовків і посилань зростає, роздуваючи пам’ять на 30–40%. - Project Lilliput: Стиснення заголовків об’єктів до 8 байтів у майбутніх версіях Java заощадить до 40% накладних витрат пам’яті рядків.
- String Pool ефект: Мільйон однакових літералів займають пам’ять лише одного об’єкта.
Червоні прапорці (Чого говорити категорично не можна)
- ❌ Стверджувати: «У Java є оператор
sizeof». - ❌ Рахувати розмір рядка як просто «кількість літер $\times$ 2 байти», геть забуваючи про заголовки об’єктів
Stringтаbyte[](~40 байтів оверхеду). - ❌ Вважати, що
Instrumentation.getObjectSize()рахує розмір рядка разом із масивом символів. - ❌ Забувати про вирівнювання пам’яті (Padding до 8 байтів).