🧠 Розділ 3 · Питання #2

Що зберігається в Heap

4. Статичні змінні класів: починаючи з Java 8, статичні поля перенесені з PermGen у купу і зберігаються всередині дзеркального об'єкта java.lang.Class. 5. Класи-обгортки над при...


🟢 Junior Level

Heap (Купа) — це центральна ділянка оперативної пам’яті, що виділяється віртуальною машиною Java (JVM) для зберігання всіх динамічно створюваних об’єктів та структур даних застосунку. Купа є спільною (Shared Memory) для всіх потоків: будь-який об’єкт, створений у купі, потенційно доступний кожному потоку, який має посилання на нього.


Що конкретно розміщується в Heap:

  1. Усі екземпляри класів: будь-які об’єкти, створені за допомогою оператора new (new User(), new Order()).
  2. Усі масиви: масиви об’єктів (String[]) та масиви примітивів (int[], byte[]). У Java будь-який масив є повноцінним об’єктом і завжди живе в Heap.
  3. Рядковий пул (String Constant Pool): рядкові літерали та рядки, що пройшли інтернування через виклик .intern().
  4. Статичні змінні класів: починаючи з Java 8, статичні поля перенесені з PermGen у купу і зберігаються всередині дзеркального об’єкта java.lang.Class.
  5. Класи-обгортки над примітивами: екземпляри Integer, Long, Boolean, створені явно або в процесі автобоксингу (Integer.valueOf(42)).

Головне каверзне запитання для Junior: «Де зберігаються примітиви?»

Відповідь суворо залежить від місця оголошення змінної:

  • Локальна змінна методу: зберігається в Stack поточного потоку (int count = 10;).
  • Поле об’єкта (Instance Field): зберігається в Heap всередині структури даних самого об’єкта (class Person { int age = 30; }).
  • Елемент масиву примітивів: зберігається в Heap у неперервній області пам’яті масиву (int[] numbers = new int[5];).
public class MemoryAllocationDemo {
    private int id = 100; // Heap (поле екземпляра класу)

    public void process() {
        int localVal = 42;             // Stack (локальний примітив)
        Point point = new Point(1, 2); // Посилання point у Stack -> об'єкт Point у Heap
        int[] data = new int[]{10, 20}; // Посилання data у Stack -> масив int[] у Heap
    }
}

🟡 Middle Level

Внутрішня анатомія об’єкта в пам’яті (Object Layout)

Кожен об’єкт, розміщений у купі 64-бітної JVM HotSpot, має суворо регламентовану структуру:

┌────────────────────────────────────────────────────────┐
│                   OBJECT HEADER                        │
├────────────────────────────────────────────────────────┤
│ 1. Mark Word (8 байтів)                                │
│    - Identity HashCode, Age bits (GC), Стан блокування │
├────────────────────────────────────────────────────────┤
│ 2. Klass Word (4 байти при Compressed Class Pointers)  │
│    - Посилання на метадані класу в Metaspace           │
├────────────────────────────────────────────────────────┤
│ 3. Array Length (4 байти, присутнє тільки в масивів)   │
├────────────────────────────────────────────────────────┤
│ 4. Instance Data (значення полів об'єкта)              │
│    - Примітивні типи та посилання на інші об'єкти      │
├────────────────────────────────────────────────────────┤
│ 5. Padding (Вирівнювання)                              │
│    - Доповнення нулями до кратності 8 байтам           │
└────────────────────────────────────────────────────────┘

Правило перевпорядкування полів (Field Packing)

Для мінімізації фрагментації та оверхеду вирівнювання JVM перевпорядковує поля об’єкта не в порядку їх оголошення в Java-коді, а за розміром типів:

  1. double / long (8 байтів)
  2. int / float (4 байти)
  3. short / char (2 байти)
  4. byte / boolean (1 байт)
  5. Посилання на об’єкти (OOP — 4 або 8 байтів)

String Pool та Compact Strings (Java 9+)

  1. Історія розміщення String Pool:
    • До Java 7: String Pool розташовувався в PermGen (Permanent Generation). Це призводило до частих помилок java.lang.OutOfMemoryError: PermGen space у разі динамічної генерації рядків.
    • Починаючи з Java 7: String Pool перенесено в Java Heap, завдяки чому невикористовувані інтерновані рядки своєчасно очищаються звичайним Garbage Collector.
  2. Compact Strings (JEP 254 у Java 9):
    • До Java 9: java.lang.String усередині містив масив char[] value, де кожен символ займав 2 байти (UTF-16), навіть якщо рядок складався виключно з ASCII-символів.
    • З Java 9: рядки перейшли на byte[] value та 1-байтний прапорець кодування byte coder. Якщо рядок містить лише символи Latin-1 (ISO-8859-1), кожен символ займає 1 байт, що заощаджує до 15–20% пам’яті купи в enterprise-застосунках.

Стиснені покажчики: Compressed OOPs

На 64-бітних JVM адреса покажчика на об’єкт має займати 8 байтів. Проте за обсягу купи менше 32 ГБ HotSpot за замовчуванням вмикає прапорець -XX:+UseCompressedOops:

  • Оскільки об’єкти вирівнюються з кроком у 8 байтів, молодші 3 біти адреси завжди нульові.
  • JVM відкидає 3 нулі, стискаючи посилання до 4 байтів.
  • $2^{32} \times 8 \text{ байтів} = 32 \text{ ГБ}$ — граничний обсяг адресованої пам’яті зі стисненими покажчиками.

💡 Ефект порогу 32 ГБ: Збільшення -Xmx з 31 ГБ до 33 ГБ вимикає Compressed OOPs. Усі посилання збільшуються з 4 до 8 байтів, що з’їдає близько 3–4 ГБ пам’яті на самі лише покажчики та знижує щільність влучання в кеш процесора L1/L2/L3.


🔴 Senior Level

Shallow Size vs Retained Size

Під час профілювання витоків пам’яті та аналізу Heap Dump (через Eclipse MAT або JProfiler) ключове значення має розрізнення двох метрик розміру об’єктів:

┌────────────────────────────────────────────────────────┐
│  Shallow Size = Власна пам'ять об'єкта                 │
│  (Header + власні поля + padding)                      │
├────────────────────────────────────────────────────────┤
│  Retained Size = Shallow Size                          │
│  + Пам'ять УСІХ об'єктів, досяжних ВИКЛЮЧНО            │
│    через цей об'єкт (Dominator Tree)                   │
└────────────────────────────────────────────────────────┘

Приклад: Порожній список new ArrayList<>(100):

  • Shallow Size: заголовок (12 байтів) + посилання на внутрішній масив (4 байти) + int size (4 байти) + padding (4 байти) = 24 байти.
  • Retained Size: власні 24 байти + розмір внутрішнього масиву Object[100] (424 байти) = 448 байтів. Якщо в список додати 100 об’єктів User, які більше ніким у застосунку не використовуються, їхній обсяг також увійде в Retained Size цього списку.

Великі об’єкти: Humongous Objects у збирачі G1

У garbage collector G1 (що використовується за замовчуванням) купа розбита на тисячі однакових регіонів (від 1 МБ до 32 МБ залежно від загального розміру купи).

  • Якщо розмір об’єкта перевищує 50% від розміру регіону G1, він класифікується як Humongous Object.
  • Особливості розміщення Humongous об’єктів:
    1. Вони алокуються не в Eden, а безпосередньо в спеціальну неперервну послідовність регіонів в Old Generation.
    2. Не копіюються збирачем сміття під час Young GC (усувається оверхед на переміщення важких масивів).
    3. Можуть викликати сильну фрагментацію купи: якщо бракує достатньої кількості вільних суміжних регіонів, JVM ініціює дострокове глобальне збирання сміття Full GC.

Аналіз розподілу пам’яті через JOL (Java Object Layout)

Інструмент org.openjdk.jol:jol-core дає змогу дослідити реальну розкладку байтів у пам’яті:

import org.openjdk.jol.info.ClassLayout;

public class JolInspection {
    public static void main(String[] args) {
        System.out.println(ClassLayout.parseClass(Order.class).toPrintable());
    }
}

class Order {
    private long id;       // 8 байтів
    private int status;    // 4 байти
    private boolean paid;  // 1 байт
    private Object payload;// 4 байти (стиснене посилання)
}

Виведення JOL на 64-бітній JVM:

OFFSET  SIZE     TYPE DESCRIPTION                    VALUE
     0     8          (object header: mark word)     ...
     8     4          (object header: class word)    ...
    12     4      int Order.status                   0
    16     8     long Order.id                       0
    24     4   Object Order.payload                  null
    28     1  boolean Order.paid                     false
    29     3          (alignment/padding gap)
Instance size: 32 bytes

Зверніть увагу: JVM переставила поле status перед полем id, щоб закрити 4-байтний проміжок після заголовка (12 байтів + 4 байти int = вирівнювання за межею 8 байтів).


Off-Heap пам’ять: коли купи недостатньо

Під час роботи з десятками гігабайтів кешу (in-memory data grids, брокери повідомлень Kafka, Cassandra) алокація в Heap призводить до тривалих пауз GC. Для розв’язання цієї проблеми застосовують Off-Heap пам’ять (поза купою):

  • java.nio.ByteBuffer.allocateDirect(): пам’ять виділяється ОС безпосередньо через системний виклик malloc().
  • Project Panama (Foreign Function & Memory API, Java 22+): інтерфейси Arena та MemorySegment забезпечують безпечну, детерміновану роботу з нативною пам’яттю без оверхеду GC.

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

30-секундна відповідь (Elevator Pitch)

«У Heap зберігаються всі динамічні дані застосунку: екземпляри класів, створені через new, масиви будь-якого типу (включно з примітивними), рядковий пул (String Pool) та статичні поля класів (всередині об’єктів Class з Java 8). Анатомія будь-якого об’єкта в купі складається із заголовка (Mark Word + Klass Word), полів даних і байтів вирівнювання (Padding). За обсягу купи до 32 ГБ JVM вмикає Compressed OOPs, стискаючи посилання до 4 байтів. Під час діагностики витоків критично важливо розрізняти Shallow Size (власна вага об’єкта) та Retained Size (вага об’єкта з усім деревом залежностей, яку звільнить GC)».


3–4 каверзних запитання з відповідями

  1. Де зберігаються примітивні змінні: в Heap чи в Stack?
    • Відповідь: Локальні примітивні змінні всередині методів зберігаються у стековому фреймі поточного потоку в Stack. Проте примітивні поля об’єктів зберігаються в купі (Heap) безпосередньо всередині тіла відповідного об’єкта. Елементи масивів примітивів (byte[], int[]) також завжди розміщуються в Heap.
  2. Де зберігаються статичні змінні класів?
    • Відповідь: Починаючи з Java 8, статичні змінні зберігаються в Heap. Вони розміщуються як поля всередині дзеркального об’єкта java.lang.Class, який перебуває в купі. У нативній пам’яті Metaspace зберігаються лише метадані класу (байткод методів, пул констант, анотації).
  3. У чому різниця між Shallow Size та Retained Size об’єкта?
    • Відповідь: Shallow Size — це розмір оперативної пам’яті, займаний виключно самим об’єктом (його заголовок, примітивні поля, посилання та padding). Retained Size — це сумарний обсяг пам’яті, який буде звільнено збирачем сміття, якщо видалити цей об’єкт (тобто його власний shallow size плюс розмір усіх об’єктів, досяжних виключно через нього).
  4. Що таке Humongous об’єкти в збирачі сміття G1 і чому вони небезпечні?
    • Відповідь: У G1 об’єкт вважається Humongous, якщо його розмір перевищує 50% від розміру регіону G1. Такі об’єкти алокуються безпосередньо в спеціальний неперервний набір регіонів в Old Generation, оминаючи Eden та Survivor. Вони небезпечні тим, що спричиняють фрагментацію купи: якщо для нового великого масиву не знайдеться неперервної послідовності вільних регіонів, JVM ініціює примусовий Full GC зі Stop-The-World паузою.

Типові помилки та Red Flags

  • 🚩 Твердження: «Усі примітиви завжди зберігаються тільки в Stack» (класична помилка: примітивні поля класів живуть у Heap).
  • 🚩 Омана: «String Pool розташований у PermGen / Metaspace» (String Pool було перенесено в Heap ще в Java 7).
  • 🚩 Нерозуміння різниці між Shallow та Retained Size під час розслідування витоків пам’яті.
  • 🚩 Ігнорування порогу 32 ГБ купи (встановлення -Xmx33g призводить до вимкнення Compressed OOPs і падіння ефективності кешу процесора).

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