В чому різниця між 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):
- Під час старту кожному потоку виділяється невеликий персональний буфер усередині регіону Eden (зазвичай десятки-сотні кілобайтів).
- Алокація об’єкта в TLAB відбувається через механізм Bump-the-pointer: покажчик вільного місця просто зміщується на розмір об’єкта (
pointer += size). - Операція виконується без атомарних інструкцій (CAS) та блокувань, що робить алокацію в Java швидшою за системний виклик
malloc()у C++. - Якщо об’єкт перевищує вільний обсяг 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 каверзних запитання з відповідями
- Чи може об’єкт у Java створюватися на стеку, а не в купі?
- Відповідь: Специфікація JVM формально вимагає, щоб усі об’єкти створювалися в Heap. Проте в сучасних реалізаціях (HotSpot JVM) завдяки Escape Analysis компілятор C2 виконує Scalar Replacement: якщо об’єкт не «втікає» за межі методу, сам об’єкт взагалі не інстанціюється, а його поля розміщуються як скалярні змінні у стековому фреймі або регістрах CPU. Це дає той самий ефект, що й stack allocation, не створюючи навантаження на GC.
- Чому купа розміром 31 ГБ часто працює ефективніше та швидше, ніж 33 ГБ?
- Відповідь: За розміру купи до 32 ГБ JVM вмикає Compressed OOPs (стиснені покажчики), адресуючи пам’ять із кроком у 8 байтів і використовуючи 4-байтні посилання замість 8-байтних. У разі перевищення 32 ГБ JVM змушена перейти на повні 64-бітні покажчики. Через це всі посилання в об’єктах та масивах подвоюються в обсязі, займаючи на 20–30% більше RAM і вимиваючи процесорні кеш-лінії L1/L2/L3. У результаті корисна місткість купи 33 ГБ виявляється нижчою, ніж у 31 ГБ.
- Де зберігається стек віртуальних потоків (Virtual Threads) у Java 21+?
- Відповідь: Стек віртуальних потоків зберігається в купі (Heap) у вигляді чанків continuation-об’єктів. Віртуальний потік займає в пам’яті від пари сотень байтів до декількох кілобайтів і динамічно зростає/стискається. Тільки в момент безпосереднього виконання на процесорі його поточний фрейм монтується на нативний стек носійного потоку (Carrier Thread).
- Що відбувається, якщо потік намагається алокувати масив розміром 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).
Пов’язані теми
- Що зберігається в Heap — детальна структура регіонів купи
- Що зберігається в Stack — детальний розбір вмісту стек-фрейму
- Що таке Garbage Collection — алгоритми збирання сміття в купі
- Що таке покоління в GC — Eden, Survivor, Tenured, Metaspace
- Що таке параметри -Xms та -Xmx — сайзинг купи в production