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

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

Стек повністю ізольований між потоками: жоден потік не має прямого доступу до стека іншого потоку, що забезпечує абсолютну потокобезпечність локальних змінних.


🟢 Junior Level

Stack (Стек викликів) — це область оперативної пам’яті, що виділяється віртуальною машиною Java індивідуально для кожного активного потоку (Thread-Local Memory). Стек організований за принципом LIFO (Last-In, First-Out): дані останнього викликаного методу розміщуються на вершині стека й першими видаляються під час його завершення.

Стек повністю ізольований між потоками: жоден потік не має прямого доступу до стека іншого потоку, що забезпечує абсолютну потокобезпечність локальних змінних.


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

  1. Примітивні локальні змінні: значення типів int, long, boolean, double, float, char, byte, short, оголошені всередині методів.
  2. Посилання на об’єкти (Reference Variables): 4-байтні (при Compressed OOPs) або 8-байтні адреси, що вказують на реальні об’єкти, розміщені в купі (Heap).
  3. Стекові фрейми (Stack Frames): службові блоки контексту виконання кожного активного в поточний момент методу.

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

  • Стопка таць у їдальні: Виклик методу — це нова таця, покладена поверх стопки. На неї ставлять тарілки (локальні змінні). Коли страву з’їдено (метод повернув результат), верхню тацю знімають. Пам’ять звільняється миттєво — збирач сміття (GC) для стека не потрібен, оскільки апаратний покажчик вершини стека (Stack Pointer) просто зміщується процесором.

Наочний приклад

public class StackDemo {
    public void firstMethod() {
        int x = 10;                // Примітив у Stack (фрейм firstMethod)
        secondMethod(x);           // Додавання нового фрейму поверх
    }                              // Фрейм firstMethod знищується

    private void secondMethod(int param) {
        boolean flag = true;       // Примітив у Stack (фрейм secondMethod)
        User user = new User();    // Посилання user у Stack -> об'єкт у Heap
    }                              // Фрейм secondMethod знищується
}

Помилки переповнення стека:

  • java.lang.StackOverflowError: виникає в разі вичерпання розміру стека потоку (найчастіше через нескінченну рекурсію або надмірно глибоку вкладеність викликів).
  • Розмір стека налаштовується прапорцем -Xss (за замовчуванням 1024 КБ на 64-бітних ОС).

🟡 Middle Level

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

Під час кожного виклику методу віртуальна машина виділяє на вершині стека потоку новий фрейм. Точний розмір кожного компонента фрейму розраховується компілятором javac заздалегідь і фіксується в заголовку методу у скомпільованому .class файлі.

┌────────────────────────────────────────────────────────┐
│                      STACK FRAME                       │
├────────────────────────────────────────────────────────┤
│ 1. Local Variable Table (LVT)                          │
│    - Масив 32-бітних слотів                            │
│    - Слот 0: 'this' (для нестатичних методів)          │
│    - Вхідні параметри методу                           │
│    - Локальні змінні                                   │
├────────────────────────────────────────────────────────┤
│ 2. Operand Stack (Стек операндів)                      │
│    - Робоча область для обчислень байткод-інструкцій   │
│    - Виконання push/pop, додавання, порівняння         │
├────────────────────────────────────────────────────────┤
│ 3. Frame Data (Службові дані)                          │
│    - Dynamic Linking (посилання на Constant Pool класу)│
│    - Normal Method Completion (адреса повернення)      │
│    - Exception Table (диспетчеризація блоків catch)    │
└────────────────────────────────────────────────────────┘

Особливості Local Variable Table (Таблиця локальних змінних):

  • Складається з 32-бітних слотів.
  • Типи int, float, reference, boolean, byte, char, short займають 1 слот.
  • 64-бітні типи long та double займають 2 послідовні слоти.
  • Перевикористання слотів (Slot Sharing): Якщо змінні оголошені в неперетинних блоках видимості всередині методу, JVM призначає їм один і той самий індекс слота, заощаджуючи пам’ять фрейму:
    public void slotReuse() {
        {
            int a = 1; // Займає слот 1
        }
        int b = 2;     // Займає той самий слот 1!
    }
    

Аналіз стека: StackWalker API (Java 9+)

До Java 9 аналіз стека викликів проводився через new Throwable().getStackTrace() або Thread.currentThread().getStackTrace(). Це призводило до суттєвих затримок продуктивності, оскільки JVM примусово матеріалізувала весь нативний стек повністю.

Починаючи з Java 9, використовується продуктивний StackWalker:

  • Працює ліниво (lazy evaluation): фрейми зчитуються потоково лише до потрібної глибини.
  • Дозволяє фільтрувати службові фрейми (reflection, lambda, proxy).
public class StackInspection {
    public void logCaller() {
        StackWalker walker = StackWalker.getInstance(StackWalker.Option.RETAIN_CLASS_REFERENCE);
        String callerClass = walker.walk(frames -> frames
            .skip(1) // Пропустити поточний метод
            .findFirst()
            .map(StackWalker.StackFrame::getClassName)
            .orElse("Unknown"));
        System.out.println("Виклик здійснено з: " + callerClass);
    }
}

🔴 Senior Level

Трасування виконання на рівні байткоду

Розглянемо, як Local Variable Table (LVT) та Operand Stack взаємодіють на рівні інструкцій JVM:

public int calculate(int a, int b) {
    int c = a + b;
    return c;
}

Байткод методу (виведення javap -c -v):

public int calculate(int, int);
  descriptor: (II)I
  flags: (0x0001) ACC_PUBLIC
  Code:
    stack=2, locals=4, args_size=3
       0: iload_1         // Помістити int з LVT[1] (параметр a) на Operand Stack
       1: iload_2         // Помістити int з LVT[2] (параметр b) на Operand Stack
       2: iadd            // Зняти два числа зі стека, додати, покласти суму назад
       3: istore_3        // Зняти суму зі стека і зберегти в LVT[3] (змінна c)
       4: iload_3         // Помістити LVT[3] на стек для повернення
       5: ireturn         // Повернути верхнє значення Operand Stack викликаючому методу

Зверніть увагу: locals=4 (слот 0 — this, слот 1 — a, слот 2 — b, слот 3 — c), stack=2 (максимальна глибина стека операндів дорівнює 2).


Віртуальні потоки (Virtual Threads, Java 21+) та пам’ять стека

У класичній моделі платформних потоків (Platform Threads) кожен потік жорстко прив’язаний до потоку операційної системи (pthread) з фіксованим розміром стека -Xss1m. За наявності 5 000 потоків лише на стеки резервується ~5 ГБ віртуальної пам’яті.

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

  1. Стек віртуального потоку зберігається не в нативній пам’яті ОС, а в Heap у вигляді ланцюжка об’єктів Continuation.
  2. Початковий розмір стека становить лише кілька сотень байтів і зростає/зменшується динамічно.
  3. Під час блокуючої операції (сокет, блокування) віртуальний потік відмонтовується (unmount) від носійного потоку (Carrier Thread). Вміст стека зберігається в купі, а Carrier Thread негайно бере в роботу інший віртуальний потік.
  4. Це дає змогу безперешкодно утримувати в пам’яті мільйони конкурентних потоків одночасно.

Проблема закріплення (Thread Pinning Gotcha):

Якщо віртуальний потік виконує блокуючу операцію всередині блоку synchronized або нативного виклику через JNI, потік не може відмонтуватися («закріплюється» / pinned до Carrier Thread). Це блокує системний потік ОС і зводить нанівець переваги віртуальних потоків. Рішення: заміна synchronized на ReentrantLock.


Чому в Java немає Tail Call Optimization (TCO)?

У функціональних мовах (Scala, Clojure, Haskell) компілятор замінює хвостову рекурсію плоским циклом, перевикористовуючи один і той самий стек-фрейм (TCO). У HotSpot JVM пряма підтримка TCO відсутня з двох фундаментальних архітектурних причин:

  1. Security & Permissions Check: Механізм безпеки Java (AccessController.doPrivileged() та SecurityManager) виконує аудит прав доступу шляхом аналізу всього ланцюжка стек-фреймів викликаючих методів (Stack Inspection). Згортання фреймів порушило б модель безпеки платформи.
  2. StackTrace Fidelity: Для точного налагодження та формування інформативних StackTrace у логах JVM зобов’язана зберігати повну історію переходів між методами.

💡 Компенсація через JIT Inlining: Замість TCO компілятор C2 виконує агресивний Method Inlining: невеликі методи вбудовуються безпосередньо в тіло викликаючого методу, що повністю усуває накладні витрати на створення стек-фреймів.


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

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

«Stack — це приватна область пам’яті кожного потоку з миттєвим LIFO-доступом, де зберігаються стекові фрейми виконуваних методів. Усередині кожного фрейму розташовані: Local Variable Table (локальні примітиви та посилання на об’єкти купи), Operand Stack (робоча зона байткод-інструкцій) та Frame Data (динамічне зв’язування й адреси повернення). Стек очищується апаратно за покажчиком, тому GC його не зачіпає. У Java 21+ віртуальні потоки кардинально змінили роботу зі стеком: їхні фрейми зберігаються в Heap як чанки Continuation, динамічно стискаючись до сотень байтів, що дозволяє створювати мільйони потоків замість тисяч».


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

  1. Що конкретно зберігається всередині стекового фрейму?
    • Відповідь: Стек-фрейм складається з трьох основних блоків:
      1. Local Variable Table (LVT) — індексований масив слотів для зберігання вхідних параметрів, посилання this та локальних змінних.
      2. Operand Stack — стек операндів, куди інструкції байткоду завантажують значення для обчислень і куди кладуть результати.
      3. Frame Data — метадані виклику: посилання на Runtime Constant Pool поточного класу для динамічного зв’язування, адреса повернення у викликаючий метод та посилання на таблицю обробки винятків.
  2. Чому типи long та double займають два слоти в Local Variable Table?
    • Відповідь: Архітектура JVM першопочатково проектувалася з орієнтацією на 32-бітний розмір слота локальних змінних. Для розміщення 64-бітних типів даних (long та double) у специфікації JVM закріплено виділення двох послідовних слотів (індексація наступної змінної зміщується на 2).
  3. Чому в Java JVM відсутня оптимізація хвостової рекурсії (Tail Call Optimization)?
    • Відповідь: TCO відсутня навмисно заради збереження повної картини стека викликів:
      • Для функціонування перевірок безпеки, які залежать від аналізу всіх класів у стеку викликів (Stack Inspection).
      • Для збереження точної інформації в Throwable.printStackTrace() під час розслідування інцидентів. Частково виклики компенсуються JIT-компілятором за допомогою інлайнінгу методів (Method Inlining).
  4. Що таке Thread Pinning (закріплення) віртуального потоку в Java 21?
    • Відповідь: Це ситуація, коли віртуальний потік переходить у блокуюче очікування (наприклад, мережевий I/O), але не може відмонтуватися від системного носійного потоку (Carrier Thread) і серіалізувати свій стек у купу. Це відбувається, якщо блокування трапляється всередині блоку/методу synchronized або нативного JNI-виклику. У результаті носійний потік простоює, блокуючи планувальник ForkJoinPool. Для усунення цієї проблеми в Java 21+ синхронізацію замінюють на java.util.concurrent.locks.ReentrantLock.

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

  • 🚩 Твердження: «Об’єкти зберігаються в Stack» (у стеку живуть виключно примітиви та посилання-покажчики, самі об’єкти — в Heap).
  • 🚩 Омана: «Stack спільний для всіх потоків» (стек суворо ізольований і прив’язаний до конкретного потоку).
  • 🚩 Твердження: «Java підтримує TCO (хвостову рекурсію)» (демонструє нерозуміння обмежень JVM).
  • 🚩 Спроба лікувати StackOverflowError лише збільшенням прапорця -Xss без пошуку нескінченної чи неконтрольованої рекурсії в алгоритмах.

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