Стек и куча в Java

Введение

Для оптимальной работы приложений JVM использует разные типы памяти, каждая из которых имеет свое назначение и особенности.
Всякий раз, когда мы объявляем переменные, создаем объекты или вызываем методы, виртуальная машина выделяет память для этих операций либо в стеке (stack), либо в куче (heap).
Для проектирования эффективных и стабильных приложений, важно разбираться в тонкостях работы этих областей памяти.
Из данной статьи вы узнаете полезные сведения про стек и кучу, необходимые Java-разработчику.

1. Стек

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

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

1.2. Стек в Java

Всякий раз, когда в Java вызывается новый метод, содержащий примитивные значения или ссылки на объекты, на вершине стека под них выделяется блок памяти, называемый фреймом (frame, кадр стека). Этот блок создается в верхней части пространства памяти стека и растет вниз по мере добавления новых данных.
Фреймы обычно содержат:
  • аргументы вызванного метода, которые были ему переданы
  • зарезервированное пространство под локальные переменные, созданные в методе
  • ссылки на объекты в куче на которые ссылается метод
  • адрес возврата, указывающий на строку байт-кода, куда методу следует вернуть результат своей работы
  • ссылку на предыдущий фрейм (указатель на фрейм вызывающего метода)
Когда метод завершает выполнение, отведенный для его нужд фрейм, очищается (выталкивается), освобождая пространство для нового метода. При этом поток выполнения программы возвращается к месту вызова метода с последующим переходом к следующей строке кода.
void main() {
    // Адрес следующей строки сохраняется как адрес возврата
    int x = foo();  
    System.out.println(x);
}
Примечание: адрес возврата — это не номер строки исходного кода, а указатель на байт-код в памяти JVM.
Рассмотрим еще пример, выводящий "Hello".
void main() {
    sayHello();
}

void sayHello() {
    System.out.println("Hello");
}
А это соответствующая ему диаграмма, отображающая кадры стека вызовов в памяти во время работы простой программы:

1.3. Пример со стеком

В программировании стек часто используется для организации рекурсии. При рекурсивном вызове метода на вершине стека сохраняется состояние текущего контекста. При возврате из рекурсивного вызова из стека происходит его извлечение и восстановление.
Рекурсивный метод — это метод, который вызывает сам себя внутри своего тела, но с другими значениями параметров.
На следующем рисунке показано, как программа для вычисления факториала числа 3 сохраняет в стеке текущее состояние для каждого рекурсивного вызова. Каждый уровень стека хранит контекст вызова (значение n и адрес возврата того места в коде, в котором был вызван метод). Каждый рекурсивный вызов создаёт новый фрейм. Результаты вычисляются в обратном порядке вызовов. При достижении n == 0 начинается процесс возврата вычисленных значений.

1.3. Особенности стека

Помимо того, что мы рассмотрели, существуют и другие особенности стека:
  • он автоматически заполняется и освобождается по мере вызова и завершения методов
  • переменные в стеке существуют, пока выполняется метод в котором они были созданы
  • он имеет фиксированный размер, который определяется JVM во время выполнения
  • в зависимости от установленной JVM размер стека по умолчанию может меняться
  • флаг -Xss можно использовать для увеличения размера стека
  • при переполнении стека, например, в случае множества вложенных вызовов методов (бывает при рекурсии), будет брошено исключение java.lang.StackOverFlowError
  • доступ к стеку осуществляется быстрее, чем к куче
  • используется для выполнения потоков, создавая новый стек для каждого из них. Когда вызывается новый метод, на вершине стека создается новый кадр. Таким образом мы гарантируем, что в каждый момент времени активен только один метод, что делает процесс потокобезопасным
  • не подвержен утечкам памяти
  • управляется не сборщиком мусора, а JVM

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

При вызове метода в стеке вызовов создается новый фрейм. Создание стековых фреймов будет продолжаться до тех пор, пока не будет достигнут конец вызовов методов, обнаруженных внутри вложенных методов.
Если во время этого процесса JVM столкнется с ситуацией, когда нет места для создания нового стекового фрейма, она бросит исключение StackOverflowError.
Перечислим возможные причины возникновения исключительной ситуации.
4
Большое количество локальных переменных
Это достаточно редкая причина.
3
Циклические отношения между классами
StackOverflowError также может быть выброшен, когда приложение спроектировано с учетом циклических отношений между классами . В этой ситуации конструкторы друг друга вызываются повторно, что приводит к выбрасыванию этой ошибки. Это также можно рассматривать как форму рекурсии .
2
Класс создается в том же классе, что и переменная экземпляра этого класса
Это приведет к тому, что конструктор того же класса будет вызываться снова и снова (рекурсивно), что в конечном итоге приведет к 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)
...
Лучшее, что можно сделать при обнаружении StackOverflowError — это внимательно изучить трассировку стека, чтобы определить повторяющийся шаблон номеров строк. Это позволит найти код с проблемной рекурсией.
В выводе ошибки можно увидеть повторяющуюся строку 9. В ней выполняется рекурсивный вызов.

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
Существование
Память стека существует пока выполняется текущий метод
Пространство кучи существует пока работает приложение
Скорость
Обращение к памяти стека происходит значительно быстрее, чем к памяти кучи
Медленнее, чем стек
Выделение и освобождение памяти
Эта память автоматически выделяется и освобождается, когда метод вызывается и завершается соответственно
Память в куче выделяется, когда создается новый объект и освобождается сборщиком мусора, когда в приложении не остается ни одной ссылки на его
Оцените статью, если она вам понравилась!