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

В чому різниця між Heap та Stack

Кожен виклик методу створює у стеку потоку ізольований Stack Frame. Розмір фрейму розраховується компілятором javac заздалегідь на етапі компіляції та фіксується в заголовку мет...


🟢 Junior Level

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


Побутова аналогія

  • Stack (Робочий стіл): Невеликий персональний стіл інженера. На ньому розміщені лише ті креслення та інструменти, з якими він працює просто зараз над поточною операцією. Коли деталь готова, робочий стіл миттєво змахується начисто. Місця обмаль, але все під рукою.
  • Heap (Центральний склад): Величезний спільний склад підприємства. На ньому зберігаються готові верстати, матеріали та контейнери. Доступ до нього мають усі робітники фабрики, пошук потрібного ящика потребує часу, а прибиранням непотрібних матеріалів займається спеціальна клінінгова служба (Garbage Collector).

Порівняльна таблиця базових характеристик

Характеристика Stack (Стек) Heap (Купа)
Що зберігається? Примітивні локальні змінні, посилання на об’єкти, фрейми виклику методів Усі екземпляри класів (new), масиви, рядки
Область видимості Приватна для кожного потоку (Thread-Safe) Спільна для всіх потоків застосунку (Shared Memory)
Розмір пам’яті Невеликий (за замовчуванням 1 МБ на потік) Великий (гігабайти, визначається параметрами JVM)
Керування пам’яттю Апаратне за принципом LIFO (миттєво під час виходу з методу) Збирач сміття (Garbage Collector) з паузами Stop-The-World
Критична помилка java.lang.StackOverflowError java.lang.OutOfMemoryError: Java heap space

Наочний приклад розподілу пам’яті

public class MemoryDemo {
    public static void main(String[] args) {
        int age = 25;                  // Примітив -> зберігається в Stack фрейму main
        String role = "Engineer";      // Посилання role в Stack -> рядок "Engineer" у String Pool (Heap)
        User user = new User("Alice"); // Посилання user в Stack -> об'єкт new User() у Heap
        
        calculate(age);                // Створюється новий стек-фрейм для calculate()
    }

    private static void calculate(int value) {
        int result = value * 2;        // result та value живуть у стек-фреймі calculate()
    }                                  // Фрейм calculate() знищується, пам'ять Stack звільняється
}

🟡 Middle Level

Внутрішня будова стекового фрейму (Stack Frame)

Кожен виклик методу створює у стеку потоку ізольований Stack Frame. Розмір фрейму розраховується компілятором javac заздалегідь на етапі компіляції та фіксується в заголовку методу в байткоді:

┌────────────────────────────────────────────────────────┐
│                      STACK FRAME                       │
├────────────────────────────────────────────────────────┤
│ 1. Local Variable Table (Таблиця локальних змінних)    │
│    [0] = this (для нестатичних методів)                │
│    [1] = param1, [2] = local_var                      │
├────────────────────────────────────────────────────────┤
│ 2. Operand Stack (Стек операндів для інструкцій JVM)   │
│    iload_1, iload_2, iadd, istore_3                    │
├────────────────────────────────────────────────────────┤
│ 3. Frame Data (Службові дані фрейму)                   │
│    - Dynamic Linking (посилання на Runtime Constant Pool)│
│    - Normal Method Completion (адреса повернення)      │
│    - Exception Dispatch Reference (таблиця catch/try)  │
└────────────────────────────────────────────────────────┘

Параметр JVM: -Xss (наприклад, -Xss1m). Збільшення -Xss дає змогу витримувати глибшу рекурсію, але зменшує максимальну кількість потоків ОС, яку може створити процес.


Алокація в Heap: TLAB та Bump-the-pointer

У наївній реалізації багатопотокова алокація в спільну купу вимагала б синхронізації (м’ютексів) на глобальному покажчику купи, що призводило б до колосальних просідань продуктивності.

Для розв’язання цієї проблеми HotSpot JVM використовує TLAB (Thread Local Allocation Buffer):

  1. Під час старту кожному потоку виділяється невеликий персональний буфер усередині регіону Eden (зазвичай десятки-сотні кілобайтів).
  2. Алокація об’єкта в TLAB відбувається через механізм Bump-the-pointer: покажчик вільного місця просто зміщується на розмір об’єкта (pointer += size).
  3. Операція виконується без атомарних інструкцій (CAS) та блокувань, що робить алокацію в Java швидшою за системний виклик malloc() у C++.
  4. Якщо об’єкт перевищує вільний обсяг TLAB, потік запитує новий TLAB через атомарну операцію CAS, або алокує важкий об’єкт безпосередньо в спільну область Eden/Old Gen.

Оптимізації JIT: Escape Analysis та Scalar Replacement

HotSpot JIT-компілятор (C2) аналізує область видимості посилань на створювані об’єкти за допомогою механізму Escape Analysis (Аналіз витоку/втечі):

  • GlobalEscape: Посилання на об’єкт залишає межі методу (повертається через return, зберігається в статичне поле або поле іншого довгоживучого об’єкта). Об’єкт зобов’язаний жити в Heap.
  • ArgEscape: Посилання передається в аргументи іншого методу, але не зберігається там.
  • NoEscape: Об’єкт створюється, використовується і припиняє існування суворо всередині одного методу.

Якщо об’єкт має статус NoEscape, JIT застосовує оптимізацію Scalar Replacement (Скалярна заміна):

  • Об’єкт не алокується в купі взагалі та навіть не створюється як цілісна структура даних!
  • Його поля «розкладаються» на окремі скалярні примітиви й розміщуються безпосередньо в регістрах процесора або в стековому фреймі.
  • Додатково застосовується Lock Elision (Усунення блокувань): якщо на об’єкті викликається synchronized, блокування повністю вирізається JIT-компілятором, оскільки об’єкт гарантовано недоступний іншим потокам.
public void processCoordinates() {
    Point point = new Point(10, 20); // NoEscape: посилання не залишає метод
    int sum = point.x + point.y;
    // C2 розкладе Point на int point_x = 10; int point_y = 20; 
    // Навантаження на GC дорівнює НУЛЮ!
}

🔴 Senior Level

Compressed OOPs та бар’єр 32 ГБ пам’яті

У 64-бітних системах покажчики на об’єкти (OOP — Ordinary Object Pointers) мають розмір 8 байтів (64 біти). Це збільшує розмір купи на 30–40% і різко знижує ефективність процесорних кешів L1/L2/L3 (Cache Misses).

Для вирішення цієї проблеми в 64-бітній JVM HotSpot застосовується технологія Compressed OOPs (-XX:+UseCompressedOops):

  • Оскільки всі об’єкти в Java вирівнюються за межею 8 байтів (молодші 3 біти адреси завжди дорівнюють 000), JVM відкидає ці 3 нулі під час зберігання адреси в пам’яті.
  • Під час завантаження адреси в регістр CPU виконується побітовий зсув ліворуч на 3 біти (address << 3), відновлюючи початкову 64-бітну адресу.
  • Це дає змогу адресувати 32-бітним числом обсяг пам’яті: $2^{32} \times 8 \text{ байтів} = 32 \text{ ГБ}$.
Heap < 32 GB: Посилання займають 4 байти (Compressed OOPs активні)
Heap > 32 GB: Посилання займають 8 байтів (Compressed OOPs вимикаються)

⚠️ Highload Gotcha: Купа з -Xmx31g вміщує більше корисних даних і працює на 15–20% швидше, ніж купа з -Xmx33g. У разі перевищення 32 ГБ розмір усіх посилань подвоюється, миттєво з’їдаючи зайві 2–4 ГБ пам’яті та знижуючи пропускну здатність шини пам’яті.


Анатомія об’єкта в пам’яті (Object Layout)

Кожен об’єкт у Heap складається зі службового заголовка та корисних даних:

┌────────────────────────────────────────────────────────┐
│                   OBJECT HEADER                        │
├────────────────────────────────────────────────────────┤
│ 1. Mark Word (8 байтів на 64-бітній JVM)               │
│    - HashCode об'єкта (ліниво обчислюваний identity)   │
│    - Age bits (4 біти: вік об'єкта від 0 до 15)        │
│    - Biased Locking / Lock status (стан блокувань)     │
│    - GC tagging bits                                   │
├────────────────────────────────────────────────────────┤
│ 2. Klass Word (4 байти при Compressed Class Pointers)  │
│    - Посилання на метадані класу в Metaspace           │
├────────────────────────────────────────────────────────┤
│ 3. Array Length (4 байти, тільки для масивів)          │
├────────────────────────────────────────────────────────┤
│ 4. Instance Data (значення полів об'єкта)              │
│    - Поля перевпорядковуються JVM для мінімізації gap  │
├────────────────────────────────────────────────────────┤
│ 5. Padding (Вирівнювання)                              │
│    - Доповнення нулями до розміру, кратного 8 байтам   │
└────────────────────────────────────────────────────────┘

Порожній об’єкт new Object() займає рівно 16 байтів пам’яті (8 байтів Mark Word + 4 байти Klass Pointer + 4 байти padding).


Архітектура NUMA (Non-Uniform Memory Access)

На багатосокетних серверах оперативна пам’ять прив’язана до конкретних фізичних процесорів (NUMA-вузлів). Доступ ядра CPU до локальної пам’яті відбувається у 2–3 рази швидше, ніж до пам’яті, підключеної до сусіднього сокета через шину QPI/UPI.

  • Прапорець -XX:+UseNUMA змушує JVM нарізати регіони купи (Eden) з урахуванням топології NUMA.
  • Потоки, що виконуються на Socket 0, отримують TLAB у пам’яті Socket 0, усуваючи міжсокетний трафік шини та знижуючи затримки алокації.

Project Loom: Стек віртуальних потоків (Java 21+)

Класичний платформний потік Java (java.lang.Thread) обгорнутий навколо нативного потоку ОС (pthread) зі статичним стеком 1 МБ. Створення 10 000 потоків вимагає 10 ГБ фізичної пам’яті лише на стеки.

Virtual Threads (Віртуальні потоки):

  • Не мають постійного виділеного стека ОС.
  • Їхні стек-фрейми зберігаються в Heap у вигляді об’єктів Continuation.
  • Під час блокування (наприклад, читання з сокета або Thread.sleep()) віртуальний потік відмонтовується (unmount) від носійного потоку (Carrier Thread), а його стек серіалізується в купу (починаючи всього з кількох сотень байтів).
  • Під час відновлення завдання стек відновлюється (mount) на будь-який вільний Carrier Thread. Це дає змогу запускати мільйони конкурентних потоків в єдиному процесі.

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

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

«Stack — це приватна для кожного потоку область пам’яті зі швидким LIFO-доступом, де зберігаються стекові фрейми викликів методів, локальні примітиви та посилання на об’єкти. Він очищується апаратно та миттєво під час виходу з методу, а переповнення призводить до StackOverflowError. Heap — спільна для всіх потоків пам’ять, де зберігаються екземпляри об’єктів, масиви та рядковий пул. Купа керується Garbage Collector, а алокація в ній оптимізована за допомогою потокобезпечних буферів TLAB (bump-the-pointer). Завдяки аналізу витоку (Escape Analysis) JIT-компілятор може розкласти короткоживучі об’єкти на примітиви на стеку (Scalar Replacement). Найважливіший поріг у production — 32 ГБ купи, вище якого вимикаються Compressed OOPs, знижуючи продуктивність».


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

  1. Чи може об’єкт у Java створюватися на стеку, а не в купі?
    • Відповідь: Специфікація JVM формально вимагає, щоб усі об’єкти створювалися в Heap. Проте в сучасних реалізаціях (HotSpot JVM) завдяки Escape Analysis компілятор C2 виконує Scalar Replacement: якщо об’єкт не «втікає» за межі методу, сам об’єкт взагалі не інстанціюється, а його поля розміщуються як скалярні змінні у стековому фреймі або регістрах CPU. Це дає той самий ефект, що й stack allocation, не створюючи навантаження на GC.
  2. Чому купа розміром 31 ГБ часто працює ефективніше та швидше, ніж 33 ГБ?
    • Відповідь: За розміру купи до 32 ГБ JVM вмикає Compressed OOPs (стиснені покажчики), адресуючи пам’ять із кроком у 8 байтів і використовуючи 4-байтні посилання замість 8-байтних. У разі перевищення 32 ГБ JVM змушена перейти на повні 64-бітні покажчики. Через це всі посилання в об’єктах та масивах подвоюються в обсязі, займаючи на 20–30% більше RAM і вимиваючи процесорні кеш-лінії L1/L2/L3. У результаті корисна місткість купи 33 ГБ виявляється нижчою, ніж у 31 ГБ.
  3. Де зберігається стек віртуальних потоків (Virtual Threads) у Java 21+?
    • Відповідь: Стек віртуальних потоків зберігається в купі (Heap) у вигляді чанків continuation-об’єктів. Віртуальний потік займає в пам’яті від пари сотень байтів до декількох кілобайтів і динамічно зростає/стискається. Тільки в момент безпосереднього виконання на процесорі його поточний фрейм монтується на нативний стек носійного потоку (Carrier Thread).
  4. Що відбувається, якщо потік намагається алокувати масив розміром 50 МБ, чи поміститься він у TLAB?
    • Відповідь: Ні, стандартний розмір TLAB становить від десятків до сотень кілобайтів. Для масивів та об’єктів, розмір яких перевищує ліміт TLAB, JVM використовує механізм прямої алокації в загальний регіон Eden із синхронізацією, або (у збирачі G1) алокує його безпосередньо в категорію Humongous Regions (спеціальні неперервні ланцюжки регіонів для об’єктів розміром $> 50\%$ від розміру регіону G1).

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

  • 🚩 Твердження: «Garbage Collector очищує пам’ять як у Heap, так і в Stack» (груба помилка: пам’ять стека звільняється апаратно під час зміщення Stack Pointer).
  • 🚩 Омана: «Об’єкти ЗАВЖДИ і БЕЗ ВИНЯТКІВ створюються в Heap» (демонструє незнання Escape Analysis та Scalar Replacement).
  • 🚩 Встановлення -Xmx34g на production-серверах без обґрунтування (демонструє нерозуміння бар’єра Compressed OOPs у 32 ГБ).
  • 🚩 Уявлення про примітиви як такі, що «завжди живуть на стеку» (примітивні поля всередині об’єктів new User() живуть разом з об’єктом у Heap).

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