1. Обзор памяти процесса Java
Когда вы запускаете Java-программу, JVM (Java Virtual Machine) просит у операционной системы кусочек памяти. Иногда — скромный, иногда — весьма внушительный (особенно если вы запускаете какой-нибудь Minecraft с кучей модов). Эта память делится на несколько ключевых областей, каждая из которых играет свою роль:
- Стек (Stack) — для локальных переменных и вызовов методов.
- Куча (Heap) — для всех объектов, которые вы создаёте через new.
- Служебные области (PermGen/MetaSpace) — для метаданных классов, статических полей и прочих «магических» вещей.
Выглядит это примерно так:
┌───────────────────────────────┐
│ JVM-процесс │
│ ┌─────────────┐ │
│ │ Stack │ ← Каждый поток — свой стек!
│ └─────────────┘ │
│ ┌─────────────┐ │
│ │ Heap │ ← Общий для всех потоков
│ └─────────────┘ │
│ ┌───────────────┐ │
│ │ PermGen/ │ ← Метаданные классов
│ │ MetaSpace │
│ └───────────────┘ │
└───────────────────────────────┘
Почему это важно?
- Понимание устройства памяти помогает писать более эффективный и безопасный код.
- Проще диагностировать ошибки типа StackOverflowError или OutOfMemoryError.
- Не страшны слова «сборщик мусора» и «утечка памяти» — вы понимаете, где и что искать.
2. Стек (Stack): быстро, локально, но не навсегда
Стек — это специальная область памяти, выделяемая для каждого потока отдельно. Стек похож на стопку тарелок: последнюю положили — первую заберёте только после того, как снимете все остальные. То есть стек работает по принципу LIFO (Last In, First Out).
Зачем нужен стек?
В стеке хранятся:
- Локальные переменные методов (например, int x = 5; внутри метода).
- Адрес возврата после вызова метода (чтобы знать, куда вернуться после завершения работы метода).
Каждый раз, когда вы вызываете метод, в стек добавляется новый фрейм (stack frame) — нечто вроде коробки, в которой лежат все локальные переменные этого метода и служебная информация. Когда метод заканчивает работу, его фрейм удаляется — все его локальные переменные исчезают.
Пример
public static void main(String[] args) {
int a = 10; // a лежит в стеке main
int b = sum(a, 5); // вызываем sum
}
public static int sum(int x, int y) {
int result = x + y; // x, y, result лежат в стеке sum
return result;
}
- Когда вызывается sum, для него создаётся отдельный фрейм в стеке.
- После завершения работы sum его переменные исчезают.
Жизненный цикл переменной
Локальные переменные живут только до тех пор, пока выполняется метод, в котором они объявлены. Как только метод завершился — их уже нет, память освобождена мгновенно.
Переполнение стека
Если вы случайно (или намеренно) написали бесконечную рекурсию, каждый вызов метода будет добавлять новый фрейм в стек. В какой-то момент стек закончится, и вы получите:
Exception in thread "main" java.lang.StackOverflowError
Пример:
public static void main(String[] args) {
recurse();
}
public static void recurse() {
recurse(); // Бесконечная рекурсия!
}
Размер стека
Размер стека ограничен — обычно это несколько мегабайт на поток (можно задать параметром -Xss). Если стек закончился — программа падает с ошибкой.
3. Куча (Heap): место для ваших объектов
Куча (Heap) — это общая область памяти для всех потоков, где живут все объекты, которые вы создаёте с помощью new, а также массивы. Именно в куче происходит вся магия объектно-ориентированного программирования.
Как объекты попадают в кучу?
String s = new String("Hello");
int[] arr = new int[10];
- Переменная s — это ссылка, она лежит в стеке.
- Сам объект String и массив arr — лежат в куче.
Жизненный цикл объекта
Объект живёт в куче до тех пор, пока на него существует хотя бы одна сильная ссылка (strong reference). Как только на объект никто не ссылается — он становится «мусором» и может быть удалён сборщиком мусора (GC).
Управление памятью
В отличие от C/C++, где вы сами должны заботиться об освобождении памяти (free, delete), в Java этим занимается GC. Вы не можете явно освободить объект, но можете обнулить все ссылки на него — тогда он станет кандидатом на удаление.
Схема: где что лежит?
Stack (main)
└─ s ─┬────────────┐
│ │
▼ │
Heap │
┌─────────────┐ │
│ String "Hello"◄──┘
└─────────────┘
Особенности кучи
- Куча одна на весь процесс JVM.
- Размер кучи можно задавать при запуске (-Xmx, -Xms).
- Если в куче не осталось свободного места и GC не может освободить память — программа падает с ошибкой OutOfMemoryError.
4. PermGen и MetaSpace: где живут классы?
Когда вы пишете class MyClass { ... }, а потом запускаете программу, JVM должна где-то хранить всё, что связано с этим классом — методы, поля, байткод, статические переменные, константы и даже строковые литералы. Для этого в JVM существует особая область памяти, где «живут» классы.
Раньше, до Java 8, эту область называли PermGen (Permanent Generation). Но у неё было немало проблем — например, она имела фиксированный размер, и если места не хватало, приложение просто вылетало с ошибкой OutOfMemoryError: PermGen space.
С выходом Java 8 появилась новая, более гибкая область — MetaSpace. Она заменила старую PermGen и теперь может автоматически расширяться, занимая столько памяти, сколько нужно системе (в пределах доступной физической).
PermGen (до Java 8)
- В PermGen хранились метаданные классов, статические поля, строковые литералы.
- Размер PermGen был ограничен (по умолчанию небольшой), можно было увеличить параметром -XX:MaxPermSize=256m.
- Если в приложение динамически подгружалось много классов (например, в web-серверах), PermGen мог «закончиться», и вы получали ошибку:
java.lang.OutOfMemoryError: PermGen space
- Проблема: очистка PermGen происходила не всегда корректно, если классы выгружались динамически (например, при перезагрузке web-приложений).
MetaSpace (Java 8+)
- С Java 8 PermGen исчез и появился MetaSpace.
- MetaSpace хранит метаданные классов, но теперь в нативной памяти (за пределами Java Heap).
- Размер MetaSpace по умолчанию не ограничен (ограничивается только памятью системы), но можно задать лимит через -XX:MaxMetaspaceSize=512m.
- Ошибка при нехватке памяти теперь выглядит так:
java.lang.OutOfMemoryError: Metaspace
- В MetaSpace также попадают статические поля, методы, информация о классах.
Схема: как всё устроено
┌───────────────────────────────┐
│ JVM-процесс │
│ ┌─────────────┐ │
│ │ Stack │ ← Локальные переменные, вызовы методов
│ └─────────────┘ │
│ ┌─────────────┐ │
│ │ Heap │ ← Объекты, массивы, всё, что через new
│ └─────────────┘ │
│ ┌───────────────┐ │
│ │ MetaSpace │ ← Метаданные классов, статические поля
│ └───────────────┘ │
└───────────────────────────────┘
Почему это важно?
Если вы пишете обычные десктопные или серверные приложения, скорее всего, вы никогда не столкнётесь с ошибками PermGen или MetaSpace. Но если работаете с динамической загрузкой классов (например, плагины, web-приложения, фреймворки типа Spring, которые могут подгружать и выгружать кучу классов), то знание про MetaSpace — must-have!
5. Иллюстрация: Схема памяти JVM
flowchart TD
subgraph JVM
direction TB
Stack1["Stack (Thread 1)"]
Stack2["Stack (Thread 2)"]
Heap[Heap]
MetaSpace[MetaSpace]
end
Stack1 --ссылается на--> Heap
Stack2 --ссылается на--> Heap
Heap --использует классы из--> MetaSpace
- Каждый поток — свой стек.
- Все стеки могут ссылаться на объекты в куче.
- Объекты в куче «знают» свой класс, информация о котором лежит в MetaSpace.
6. Пример: как это выглядит в реальном коде
public class MemoryDemo {
public static void main(String[] args) {
int x = 42; // x лежит в стеке main
String s = "Hello!"; // s — ссылка в стеке, объект String в куче, литерал "Hello!" в MetaSpace
Person p = new Person("Alice"); // p — ссылка в стеке, объект Person в куче
// Вызовем метод, чтобы создать новый стек-фрейм
printPerson(p);
}
public static void printPerson(Person person) {
// person — ссылка в стеке printPerson
System.out.println(person.getName());
}
}
class Person {
private String name;
public Person(String name) {
this.name = name;
}
public String getName() { return name; }
}
Разбор:
- x — локальная переменная, живёт в стеке метода main.
- s — ссылка в стеке, объект String в куче, а строковый литерал "Hello!" — в MetaSpace.
- p — ссылка в стеке, объект Person в куче.
- Класс Person и все его методы/поля — в MetaSpace (метаданные классов).
- Вызов printPerson(p) создаёт новый стек-фрейм; внутри него локальная ссылка person указывает на тот же объект в куче.
7. Как JVM управляет памятью: краткий FAQ
Могу ли я управлять стеком?
Нет, стек полностью под контролем JVM. Вы можете только задавать его размер при запуске (-Xss).
Могу ли я управлять кучей?
Частично: размер кучи задаётся при запуске (-Xmx, -Xms). За очистку отвечает сборщик мусора (GC).
Могу ли я управлять MetaSpace?
Можно ограничить размер (-XX:MaxMetaspaceSize), но обычно это не требуется.
Что происходит при нехватке памяти?
— Если закончился стек — StackOverflowError.
— Если закончилась куча — OutOfMemoryError: Java heap space.
— Если закончился MetaSpace — OutOfMemoryError: Metaspace.
8. Типичные ошибки при работе с памятью
Ошибка №1: StackOverflowError из-за бесконечной рекурсии. Самая частая причина — забыли предусмотреть условие выхода из рекурсии. Например, метод вызывает сам себя без остановки. JVM не сможет расширить стек до бесконечности, и программа «упадёт».
Ошибка №2: OutOfMemoryError из-за переполнения кучи. Если вы создаёте слишком много объектов, на которые продолжают ссылаться переменные/коллекции (например, добавляете элементы в список, но никогда их не удаляете), куча может закончиться.
Ошибка №3: OutOfMemoryError: PermGen space / Metaspace. Если вы используете плагины или динамически подгружаете кучу классов, а MetaSpace не очищается (например, из-за неправильной выгрузки классов), может закончиться место в MetaSpace.
Ошибка №4: Путаница между ссылкой и объектом. Многие начинающие путают: переменная типа Person в стеке — это только ссылка, а сам объект — в куче.
Ошибка №5: Ожидание, что сборщик мусора удалит всё мгновенно. GC работает «по настроению» (на самом деле — по внутренним алгоритмам и при нехватке памяти), а не сразу после того, как вы обнулили ссылку. Не стоит рассчитывать на немедленное освобождение памяти.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ