Стек и куча в Java

Введение

Во время выполнения Java-программы JVM использует разные области памяти. Одни нужны для хранения объектов, другие — для вызовов методов, локальных переменных и служебных данных.
В этой статье мы разберем две важные области памяти 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()      │  ← вершина стека
├────────────┤
│ 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
Если условие остановки отсутствует или глубина рекурсии слишком велика, стек может переполниться, и JVM выбросит ошибку StackOverflowError.

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

При вызове метода в стеке вызовов создается новый фрейм. Создание стековых фреймов будет продолжаться до тех пор, пока не будет достигнут конец вызовов методов, обнаруженных внутри вложенных методов.
Перечислим возможные причины возникновения этой ошибки.
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. Большое количество локальных данных
Теоретически переполнение стека может быть связано и с очень большими фреймами: например, если метод содержит много локальных переменных или вызывает другие методы с большим количеством данных в стеке. На практике такая причина встречается значительно реже, чем рекурсия или циклические вызовы.
При обнаружении StackOverflowError в первую очередь стоит изучить трассировку стека и найти повторяющийся шаблон вызовов. Обычно он показывает, какой метод вызывает сам себя или участвует в циклической цепочке.
Не стоит путать StackOverflowError и OutOfMemoryError. StackOverflowError возникает, когда переполняется стек уже работающего потока. OutOfMemoryError может возникнуть в другой ситуации — например, если JVM не может выделить память для создания нового потока, в том числе под его стек.

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

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

2. Куча (РАЗДЕЛ ДОРАБАТЫВАЕТСЯ)

Эта область памяти используется для динамического выделения памяти для объектов и классов JRE во время выполнения. Новые объекты всегда создаются в куче, а ссылки на них хранятся в стеке.
Эти объекты имеют глобальный доступ и могут быть получены из любого места программы.
Эта область памяти разбита на несколько более мелких частей, называемых поколениями:
  1. Young Generation — область где размещаются недавно созданные объекты. Когда она заполняется, происходит быстрая сборка мусора
  2. Old (Tenured) Generation — здесь хранятся долгоживущие объекты. Когда объекты из Young Generation достигают определенного порога «возраста», они перемещаются в Old Generation
  3. Permanent Generation — эта область содержит метаинформацию о классах и методах приложения, но начиная с Java 8 данная область памяти была упразднена. Подробнее об этом можно узнать из нашей прошлой статьи, а также посмотрев видео
Мы можем управлять размерами кучи в зависимости от наших требований.

Основные особенности кучи

Помимо рассмотренных ранее, куча имеет следующие ключевые особенности:

  • Когда эта область памяти полностью заполняется, Java бросает java.lang.OutOfMemoryError
  • Доступ к ней медленнее, чем к стеку
  • Эта память, в отличие от стека, автоматически не освобождается. Для сбора неиспользуемых объектов используется сборщик мусора
  • В отличие от стека, куча не является потокобезопасной и ее необходимо контролировать, правильно синхронизируя код

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