Стек и куча в 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 |     |               |
+---------------+     +---------------+
2
Вызов buildPerson()
Далее выполняется строка:
person = buildPerson(id, name);
Перед тем как присвоить результат переменной person, JVM вызывает метод buildPerson(id, name). Для этого поверх фрейма main() в стеке создаётся новый фрейм — фрейм метода buildPerson().
Параметры метода 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()        |     | Person object |
|               |     |               |
| id = 23       |     | id = 23       |
| name ---------|--+  | name ---------|-----+
| person = null |  |  +---------------+     |
+---------------+  |                        |
                   |  +---------------+     |
                   |  | String Pool   |     |
                   +---> "John" <-----------+
                      +---------------+
В этот момент:
  • buildPerson() уже нет в стеке
  • объект Person есть в куче
  • но переменная person в main() всё ещё null
6
Присваивание в main()
Теперь выполняется:
person = buildPerson(id, name);
(правая часть уже вычислена — это ссылка). Ссылка на объект Person записывается в person
Итоговое состояние памяти
Стек                  Куча
+---------------+     +---------------+
| main()        |     | Person object |
|               |     |               |
| id = 23       |     | id = 23       |
| name ---------|--+  | name ---------|-----+
| person -------|--|->+---------------+     |
+---------------+  |                        |
                   |  +---------------+     |
                   |  | String Pool   |     |
                   +---> "John" <-----------+
                      +---------------+
На примере программы мы увидели, как распределяется память в Java и как взаимодействуют стек и куча.
Основные выводы:
  • примитивные значения (например, int id) хранятся непосредственно в стеке, внутри фрейма метода
  • ссылочные переменные (например, name, person) хранятся в стеке, но содержат ссылки на объекты в куче
  • объекты (например, Person) создаются в куче, а не в стеке
  • строковые литералы (например, "John") хранятся в String Pool и не дублируются
  • при передаче параметров:
  1. примитивы копируются по значению
  2. ссылки копируются как ссылки (указывают на тот же объект)
  • при возврате значения метод передаёт ссылку на объект, а не сам объект.
  • удаление фрейма метода из стека не удаляет объект из кучи, если на него ещё есть ссылки.
Заключение
Прежде чем завершить эту статью, давайте суммируем различия между памятью стека и пространством кучи:
Свойства
Стек
Куча
Использование приложением
Для каждого потока используется свой стек
Пространство кучи является общим для всего приложения
Размер
Предел размера стека определен операционной системой
Размер кучи не ограничен
Хранение
Хранит примитивы и ссылки на объекты
Все созданные объекты хранятся в куче
Порядок
Работает по схеме последним вошел, первым вышел (LIFO)
Доступ к этой памяти осуществляется с помощью сложных методов управления памятью, включая Young Generation, Old и Permanent Generation
Существование
Память стека существует пока выполняется текущий метод
Пространство кучи существует пока работает приложение
Скорость
Обращение к памяти стека происходит значительно быстрее, чем к памяти кучи
Медленнее, чем стек
Выделение и освобождение памяти
Эта память автоматически выделяется и освобождается, когда метод вызывается и завершается соответственно
Память в куче выделяется, когда создается новый объект и освобождается сборщиком мусора, когда в приложении не остается ни одной ссылки на его
Оцените статью, если она вам понравилась!