Стек и куча в Java

Введение

Во время выполнения Java-программы JVM использует разные области памяти. Одни из них нужны для хранения объектов, другие — для вызовов методов, локальных переменных и служебных данных.
В этой статье мы разберем такие области памяти, как стек и куча. Понимание разницы между ними позволит лучше разобраться, как Java работает с объектами, почему ссылки и объекты хранятся в разных местах, как сборщик мусора освобождает память и откуда появляются ошибки StackOverflowError и OutOfMemoryError.

1. Стек

1.1. Общие сведения

Стек (Stack) — это линейная структура данных, работающая по принципу LIFO: last in, first out («последним пришёл — первым ушёл»).
Элементы в стек добавляются и удаляются только с одной стороны — через вершину стека. Операцию добавления обычно называют push, а операцию удаления — pop.
Работа стека показана на анимации ниже. При выполнении push новый элемент помещается на вершину стека, а при выполнении pop удаляется с вершины. После pop вершиной становится элемент, который находился под удалённым.
Работа стека
Получить доступ к элементу в середине стека, не снимая верхние элементы, невозможно — это ограничения, связанные с принципом LIFO.
В программировании стек как структура данных применяется для:
  • хранения промежуточных состояний
  • разбора и вычисления математических выражений
  • проверки правильности скобочных последовательностей
  • реализации алгоритмов обхода графов и деревьев и так далее
В JVM слово «стек» используется в другом, более конкретном смысле: это область памяти, связанная с выполнением методов. Как устроен стек JVM и какую роль он играет при выполнении Java-программ, разберём в следующем разделе.

1.2. Стек в Java

В Java каждый поток имеет собственный стек, а при каждом вызове метода в этом стеке создаётся новый блок данных — кадр (или фрейм) стека.
Фрейм хранит информацию, которая требуется JVM для выполнения конкретного вызова метода. Обычно он содержит:
  • аргументы, переданные в метод
  • локальные переменные метода, включая примитивные значения и ссылки на объекты в куче
  • операндный стек, который JVM использует для промежуточных вычислений внутри метода
  • служебную информацию: например, адрес возврата — сведения о том, с какой инструкции байт-кода продолжится выполнение после завершения метода
Когда метод завершает работу, его фрейм удаляется из стека. После этого выполнение возвращается в тот метод, из которого был сделан вызов.
Рассмотрим пример:
public class Main {

    public static void main(String[] args) {
        int x = foo();
        System.out.println(x);
    }

    static int foo() {
        return 10;
    }
}
Ниже на схемах показано, как стек вызовов изменяется при выполнении этой программы. Для простоты показаны только фреймы методов main() и foo(). Служебные фреймы JVM, вызовы библиотек и внутренние вызовы System.out.println() не отображаются. Размеры фреймов условны.
1
Шаг 1. Запуск main()
После запуска программы JVM создаёт фрейм для метода main():
|            |
|    ...     |  <- свободное место
|            |
+------------+
| main()     |  <- вершина стека
| args       |
| x = ?      |
+------------+
2
Шаг 2. Вызов foo()
При вызове foo() поверх фрейма main() создаётся новый фрейм. Фрейм main() остаётся в стеке и ждёт завершения foo(). Во время выполнения метод foo() помещает возвращаемое значение 10 в операндный стек своего фрейма.
|            |
|    ...     |  <- свободное место
|            |
+------------+
| foo()      |  <- вершина стека
| operand: 10|
+------------+
| main()     |
| args       |
| x = ?      |
+------------+
3
Шаг 3. Возврат в main()
После завершения foo() его фрейм удаляется из стека. Значение 10 возвращается в main() и присваивается переменной x.
|            |
|    ...     |  <- свободное место
|            |
+------------+
| main()     |  <- вершина стека
| args       |
| x = 10     |
+------------+
После этого программа продолжает выполнение в main() со строки System.out.println(x).

1.3. Рекурсия и стек

Предыдущий пример показывал вызов одного метода из другого. Но бывает, что метод вызывает сам себя — это называется рекурсией. Обычно при каждом рекурсивном вызове метод получает другое значение параметра.
Стек при этом работает так же, как и при обычных вызовах: для каждого нового вызова JVM создаёт отдельный фрейм. Старые фреймы не исчезают: они остаются ниже в стеке и ждут завершения самого вложенного вызова.
Рассмотрим метод вычисления факториала:
static int factorial(int n) {
    if (n == 0) return 1;
    return n * factorial(n - 1);
}
При вызове factorial(3) метод последовательно вызывает сам себя с меньшим значением n:
factorial(3)
    ↓
factorial(2)
    ↓
factorial(1)
    ↓
factorial(0)
После этой цепочки вызовов стек будет выглядеть так:
|                 |
|       ...       |
|                 |
+-----------------+
| factorial(0)    |  <- вершина стека
+-----------------+
| factorial(1)    |
+-----------------+
| factorial(2)    |
+-----------------+
| factorial(3)    |  <- вызван первым
+-----------------+
Когда n достигает нуля, рекурсивные вызовы прекращаются. После этого начинается обратный ход: фреймы удаляются из стека в порядке LIFO, а результаты возвращаются от самого вложенного вызова к первому.
factorial(0) возвращает 1
factorial(1) возвращает 1 * 1 = 1
factorial(2) возвращает 2 * 1 = 2
factorial(3) возвращает 3 * 2 = 6

1.4. Переполнение стека

При каждом вызове метода в стеке создаётся новый фрейм. Если условие остановки отсутствует или глубина рекурсии слишком велика, стек может переполниться, и JVM выбросит ошибку StackOverflowError.
При обнаружении StackOverflowError в первую очередь стоит изучить трассировку стека и найти повторяющийся шаблон вызовов. Обычно он показывает, какой метод вызывает сам себя или участвует в циклической цепочке.
Не стоит путать StackOverflowError и OutOfMemoryError. StackOverflowError возникает, когда переполняется стек уже работающего потока. OutOfMemoryError возникает, когда JVM не может выделить память: например, для новых объектов в куче или для создания нового потока, которому тоже требуется собственный стек.
Перечислим возможные причины возникновения StackOverflowError.
1
Слишком глубокая или бесконечная рекурсия
Наиболее распространённая причина переполнения стека — слишком глубокая или бесконечная рекурсия. В этом случае метод вызывает сам себя так много раз, что фреймы вызовов заполняют доступное пространство стека.
Пример бесконечной рекурсии:
class Hello {

    public static void main(String[] args) {
        sayHello();
    }

    private static void sayHello() {
        System.out.println("Hello");
        sayHello();
    }
}
Фрагмент вывода:
...
Hello
Hello
Hello
Hello
Exception in thread "main" java.lang.StackOverflowError
...
at Hello.sayHello(Hello.java:8)
at Hello.sayHello(Hello.java:9)
at Hello.sayHello(Hello.java:9)
at Hello.sayHello(Hello.java:9)
...
В трассировке видно, что строка с вызовом sayHello() повторяется много раз. Это главный признак проблемной рекурсии: один и тот же метод снова и снова появляется в цепочке вызовов.
2
Создание объекта внутри самого себя
StackOverflowError может возникнуть, если объект создаёт внутри себя объект того же класса как поле экземпляра.
Например:
class Example {
    private Example example = new Example();
}
При создании Example создаётся поле example, для него снова создаётся Example, затем снова создаётся поле example — и так далее. По сути, это тоже рекурсивная цепочка создания объектов.
3
3. Циклические вызовы конструкторов или методов
Ошибка может возникнуть и при циклических связях между классами, когда создание одного объекта приводит к созданию другого, а тот снова создаёт первый.
Например:
class A {
    private B b = new B();
}

class B {
    private A a = new A();
}
При создании A создаётся B, при создании B снова создаётся A, и цепочка повторяется до переполнения стека.
4
4. Большое количество локальных данных
Теоретически переполнение стека может быть связано и с очень большими фреймами: например, если метод содержит много локальных переменных или вызывает другие методы с большим количеством данных в стеке. На практике такая причина встречается значительно реже, чем рекурсия или циклические вызовы.

1.5. Все, что нужно знать о стеке

Обобщим и дополним полученные сведения о стеке:
  • стек работает по принципу LIFO: последний добавленный фрейм удаляется первым
  • стек связан с выполнением методов: при вызове метода в стек добавляется новый фрейм, а при завершении метода этот фрейм удаляется
  • фрейм хранит данные, необходимые для выполнения конкретного вызова метода: аргументы, локальные переменные, ссылки на объекты, операндный стек и служебную информацию
  • локальные переменные и ссылки, хранящиеся во фрейме, существуют только во время выполнения соответствующего метода
  • в стеке не хранятся сами объекты. Если внутри метода создаётся объект, он размещается в куче, а во фрейме стека хранится ссылка на него
  • у каждого потока Java есть собственный стек, поэтому вызовы методов в разных потоках хранятся отдельно друг от друга
  • размер стека ограничен. Размер по умолчанию зависит от JVM, операционной системы и параметров запуска
  • параметр -Xss позволяет задать размер стека для потоков; иногда его увеличивают при глубокой рекурсии
  • если стек переполняется, JVM выбрасывает ошибку StackOverflowError
  • если JVM не смогла выделить память, то выбрасывается OutOfMemoryError
  • стековые фреймы удаляются автоматически при завершении методов, поэтому память стека не освобождается сборщиком мусора так, как память кучи
  • работа со стеком обычно проще и дешевле для JVM, чем работа с кучей: фреймы добавляются и удаляются в строгом порядке
  • стек не является типичным источником утечек памяти. Однако ссылки из стековых фреймов могут удерживать объекты в куче, пока соответствующий метод выполняется
В этой главе мы разобрали, как стек управляет вызовами методов и хранит локальные переменные. Но в стеке хранятся не сами объекты, а только ссылки на них. Объекты, созданные через new, размещаются в другой области памяти — куче. В следующей главе рассмотрим, как она устроена и чем отличается от стека.

2. Куча

Куча — это область памяти JVM, в которой хранятся объекты, созданные во время работы программы.
Создаваемый с помощью new объект размещается в куче, а ссылка на него хранится в стеке.
После выполнения строки ниже объект User создаётся в куче, а переменная user в стековом фрейме содержит ссылку на этот объект.
User user = new User();
Объекты в куче доступны не сами по себе, а через ссылки. Пока на объект есть ссылки из стека, статических полей или других объектов, программа может с ним работать. Если доступных ссылок больше нет, объект становится кандидатом на удаление сборщиком мусора.

2.1. Управление памятью

Для управления памятью куча JVM обычно делится на поколения:
  1. Young Generation — область, куда попадает большинство новых объектов. Часто она дополнительно делится на Eden и Survivor-пространства
  2. Old Generation — область для объектов, которые пережили несколько сборок мусора в Young Generation и продолжают использоваться программой
В старых версиях HotSpot JVM существовала область Permanent Generation, где хранились метаданные классов. Начиная с Java 8 её заменил Metaspace: метаданные классов стали храниться в native-памяти, вне обычной кучи Java. Подробнее об этом можно узнать в нашей статье, а также в видео по этой теме.
Поколения памяти JVM до Java 8 и в современных версиях Java

2.2. Все, что нужно знать о куче

Куча имеет следующие важные особенности:
  • объекты в куче живут дольше, чем фреймы методов в стеке: они не удаляются сразу после завершения метода, в котором были созданы
  • память кучи освобождается сборщиком мусора: он удаляет объекты, на которые больше нет доступных ссылок
  • если в куче не хватает места для нового объекта, а сборщик мусора не может освободить достаточно памяти, JVM выбрасывает ошибку OutOfMemoryError: Java heap space
  • работа с кучей обычно дороже для JVM, чем работа со стеком: объекты необходимо размещать, отслеживать и очищать с помощью сборщика мусора
  • объекты в куче могут быть доступны нескольким потокам одновременно. Если несколько потоков изменяют один и тот же объект, доступ к нему необходимо синхронизировать

3. Пример использования стека и кучи

Основываясь на рассмотренной ранее информации, разберём пример кода и посмотрим, где хранятся примитивные значения, ссылки и объекты.
class Person {
    int id;
    String name;

    public Person(int id, String name) {
        this.id = id;
        this.name = name;
    }
}

public class PersonBuilder {

    public static void main(String[] args) {
        int id = 23;
        String name = "John";
        Person person = null;
        person = buildPerson(id, name);
    }

    private static Person buildPerson(int id, String name) {
        return new Person(id, name);
    }
}
Рассмотрим выполнение кода по шагам.
1
Запуск main()
При запуске метода main() в стеке создаётся его фрейм. В нём появляются локальные переменные:
  • id — примитивное значение типа int
  • name — ссылка типа String
  • person — ссылка типа Person
После выполнения строк:
int id = 23;
String name = "John";
Person person = null;
память можно представить так:
Стек                  Куча
+---------------+     +---------------+
| main()        |     | String Pool   |
|               |     |               |
| id = 23       |     |               |
| name ---------|-------> "John"      |
| person = null |     |               |
+---------------+     +---------------+
Про String Pool можно почитать в нашей статье.
2
Вызов buildPerson()
Далее выполняется строка:
person = buildPerson(id, name);
Перед тем как присвоить результат переменной person, JVM вызывает метод buildPerson(id, name). Для этого поверх фрейма main() в стеке создаётся новый фрейм — фрейм метода buildPerson().
Параметры данного метода получают значения, переданные из main():
  • id получает копию значения 23
  • name получает копию ссылки на строку "John"
Сам объект строки при этом не копируется. Обе ссылки name указывают на один и тот же объект "John" в String Pool.
Память на этом этапе можно представить так:
Стек                  Куча
+---------------+     +---------------+
| buildPerson() |     | String Pool   |
|               |     |               |
| id = 23       |     |               |
| name ---------|--+----> "John"      |
+---------------+  |  |               |
| main()        |  |  |               |
|               |  |  |               |
| id = 23       |  |  |               |
| name ---------|--+  |               |
| person = null |     |               |
+---------------+     +---------------+
3
Создание объекта Person
Внутри метода buildPerson() выполняется строка:
return new Person(id, name);
Оператор new создаёт объект Person в куче. Затем вызывается конструктор:
public Person(int id, String name) {
    this.id = id;
    this.name = name;
}
Во время выполнения конструктора поверх фрейма buildPerson() создаётся ещё один фрейм — фрейм конструктора Person(int, String).
В этом фрейме находятся:
  • this — ссылка на создаваемый объект Person
  • id — значение 23
  • name — ссылка на строку "John"
Стек                   Куча
+---------------+      +---------------+
| Person(...)   |      | Person object |
|               |      |               |
| this ---------|----->| id = 0        |
| id = 23       |      | name -------->|----+
| name ---------|--+   +---------------+    |
+---------------+  |                        |
| buildPerson() |  |                        |
|               |  |   +---------------+    |
| id = 23       |  |   | String Pool   |    |
| name ---------|--+-----> "John" <---------+
+---------------+      +---------------+    ^
| main()        |                           |
|               |                           |
| id = 23       |                           |
| name ---------|---------------------------+
| person = null |
+---------------+
4
Завершение конструктора Person
После выполнения строк:
this.id = id;
this.name = name;
объект в куче полностью инициализирован. Теперь фрейм конструктора Person(...) больше не нужен и удаляется из стека.
Что происходит:
  • объект Person уже создан в куче
  • поле id = 23
  • поле name указывает на "John"
  • ссылка на этот объект остаётся у метода buildPerson() (как результат new)
Состояние памяти
Стек                   Куча
+---------------+      +---------------+
| buildPerson() |      | Person object |
|               |      |               |
| id = 23       |      | id = 23       |
| name ---------|--+   | name ---------|----+
+---------------+  |   +---------------+    |
| main()        |  |                        |
|               |  |   +---------------+    |
| id = 23       |  |   | String Pool   |    |
| name ---------|--+-----> "John" <---------+
| person = null |      +---------------+
+---------------+
Хотя конструктор ничего не возвращает явно, но результат new Person(...) — это ссылка на объект. Эта ссылка сейчас уже существует, но ещё не записана в person в main().
5
Возврат из buildPerson()
Теперь выполняется:
return new Person(id, name);
Фактически возвращается ссылка на уже созданный объект.
Что происходит
  • ссылка на объект Person становится результатом buildPerson()
  • фрейм buildPerson() удаляется из стека
  • управление возвращается в main()
  • возвращённая ссылка помещается в операндный стек фрейма main()
Состояние памяти (до присваивания!)
Стек                  Куча
+---------------+     +---------------+
| main()        |     | String Pool   |
|               |     |               |
| id = 23       |     |               |
| name ---------|-------> "John" <---------+
| person = null |     +---------------+    |
|               |                          |
| Операнд. стек |     +---------------+    |
| operand: ref ------>| Person object |    |
+---------------+     |               |    |
                      | id = 23       |    |
                      | name ---------|----+
                      +---------------+
В этот момент:
  • buildPerson() уже нет в стеке
  • объект Person есть в куче
  • переменная person в main() всё ещё null
  • ссылка на объект Person находится в операндном стеке фрейма main()
6
Присваивание в main()
Теперь выполняется:
person = buildPerson(id, name);
Правая часть уже вычислена, это ссылка на объект Person. Ссылка извлекается из операндного стека и записывается в переменную person.
Итоговое состояние памяти
Стек                   Куча
+---------------+      +---------------+
| main()        |      | Person object |
|               |      |               |
| id = 23       |      | id = 23       |
| name ---------|--+   | name ---------|----+
| person -------|--|-->+---------------+    |
+---------------+  |                        |
                   |   +---------------+    |
                   |   | String Pool   |    |
                   +-----> "John" <---------+
                       +---------------+
В этот момент:
  • в стеке остался только фрейм main()
  • переменная person содержит ссылку на объект Person
  • объект Person находится в куче
  • переменная name и поле Person.name указывают на одну и ту же строку "John" в String Pool
На примере программы мы увидели, как распределяется память в Java и как взаимодействуют стек и куча.
Основные выводы:
  • примитивные значения (например, int id) хранятся непосредственно в стеке, внутри фрейма метода
  • ссылочные переменные (например, name, person) хранятся в стеке, но содержат ссылки на объекты в куче
  • объекты (например, Person) создаются в куче, а не в стеке
  • строковые литералы (например, "John") хранятся в String Pool и не дублируются
  • при передаче параметров:
  1. примитивы копируются по значению
  2. ссылки копируются как ссылки (указывают на тот же объект)
  • при возврате значения метод передаёт ссылку на объект, а не сам объект.
  • удаление фрейма метода из стека не удаляет объект из кучи, если на него ещё есть ссылки
Заключение
Подведём итог и сравним основные особенности стека и кучи.
Свойства
Стек
Куча
Использование
Каждый поток имеет собственный стек
Куча является общей для потоков приложения
Что хранится
Фреймы вызванных методов. В них хранятся локальные переменные, включая примитивные значения и ссылки на объекты, операндный стек и служебная информация
Объекты и массивы. Внутри объектов хранятся значения их полей: примитивные значения или ссылки на другие объекты
Порядок
Фреймы добавляются и удаляются по принципу LIFO: последним вошёл, первым вышел
Объекты не удаляются в порядке их создания. Управлением памятью занимается сборщик мусора
Время жизни
Стек существует, пока существует поток. Фрейм находится в стеке до завершения соответствующего вызова метода
Объект может существовать дольше метода, в котором был создан. Он остаётся в куче, пока достижим, а после потери достижимости может быть удалён сборщиком мусора
Размер
Ограничен. Размер стека потока зависит от JVM, платформы и настроек запуска. Его можно задавать параметром -Xss
Ограничена доступной JVM памятью и настройками. Начальный и максимальный размеры можно задавать параметрами -Xms и -Xmx
Выделение памяти
Фрейм создаётся при вызове метода и удаляется после его завершения
Память выделяется при создании объекта
Многопоточность
Стек принадлежит конкретному потоку, поэтому его фреймы не разделяются между потоками
Один объект в куче может быть доступен нескольким потокам
Скорость
Выделение и освобождение памяти обычно происходит быстрее и проще: фреймы добавляются и удаляются с вершины стека
Выделение и освобождение памяти сложнее: объекты размещаются в куче, а освобождением памяти занимается сборщик мусора
Главное различие заключается в назначении этих областей памяти. Стек связан с выполнением методов: в нём создаются и удаляются их фреймы. Куча предназначена для хранения объектов. Локальная переменная ссылочного типа находится во фрейме стека, а объект, на который она ссылается, находится в куче.
Оцените статью, если она вам понравилась!