Виртуальная машина Java (JVM): байткод, JIT и сборка мусора

Виртуальная машина Java (JVM): байткод, JIT и сборка мусора Полезное

Виртуальная машина Java (JVM, Java Virtual Machine) — это среда исполнения, которая запускает Java-байткод независимо от процессора и операционной системы. Компилятор javac переводит исходный код не в машинные команды конкретного процессора, а в промежуточный байткод, а его уже исполняет JVM на нужной платформе.

Ниже разберем цепочку «исходник -> байткод -> исполнение», три подсистемы JVM (загрузчик классов, механизм выполнения с JIT, сборщик мусора) и разведем понятия JVM, JRE и JDK, которые часто путают. Тему классов и объектов как таковых, а также обзор языка Java в этой статье не рассматриваем — только машину, которая исполняет код.

JVM vs JRE vs JDK: три разных понятия

Эти три сокращения обозначают разные вещи, и путаница здесь типична. Разведем их сразу таблицей, а дальше по тексту будем опираться на эти границы.

Термин Что это Что входит Кому нужно
JVM Абстрактная машина (спецификация) и ее конкретная реализация, исполняющая байткод Загрузчик классов, механизм выполнения, области памяти, GC Нужна для любого запуска
JRE Среда выполнения: JVM плюс стандартные библиотеки классов JVM + библиотеки (java.base и другие) Чтобы запускать готовые программы
JDK Комплект разработчика: среда выполнения плюс инструменты JRE + javac, javap, jar, jdb и другие Чтобы писать и собирать программы

Важное уточнение: JVM — это одновременно и документ (спецификация JVM в составе Java SE), и программа, которая ей соответствует. Реализаций много; самая распространенная — HotSpot из проекта OpenJDK. Начиная с Java 11, отдельного standalone-дистрибутива JRE от Oracle нет: среду выполнения либо получают внутри JDK, либо собирают под приложение утилитой jlink.

От исходника к байткоду

Java-компиляция двухступенчатая. Сначала javac превращает текст в байткод и складывает его в .class-файлы. Байткод — это не машинный код процессора, а компактные инструкции для стековой виртуальной машины (iload, iadd, invokevirtual и подобные).

Возьмем минимальный полный пример и доведем его до наблюдаемого результата.

public class Hello {
    public static void main(String[] args) {
        int a = 2;
        int b = 3;
        System.out.println(a + b);
    }
}

Компилируем и запускаем:

javac Hello.java
java Hello

Вывод:

5

Команда javac создала файл Hello.class — это и есть байткод. Заглянем внутрь дизассемблером javap, чтобы увидеть, что реально исполняет машина:

javap -c Hello

Фрагмент вывода (метод main) — он воспроизводится этой командой на любом актуальном JDK:

0: iconst_2
1: istore_1
2: iconst_3
3: istore_2
...
invokevirtual java/io/PrintStream.println

Инструкции iconst_2 и iconst_3 кладут числа на стек операндов, istore_1 и istore_2 сохраняют их в локальные переменные; перед сложением iload_1 и iload_2 (в полном листинге) возвращают значения на стек, где iadd их складывает, а invokevirtual вызывает метод. Именно эти команды, а не текст программы, и получает на вход JVM. Один и тот же Hello.class без перекомпиляции запустится на Windows, Linux и macOS — в этом суть переносимости.

Загрузчик классов: как байткод попадает в память

Первая подсистема JVM — загрузчик классов (class loader). Он находит .class-файлы, читает их в память и готовит к исполнению. Работа идет в три этапа:

  • загрузка (loading) — чтение байткода класса в память;
  • связывание (linking) — проверка корректности байткода (verification), подготовка статических полей, разрешение ссылок;
  • инициализация (initialization) — выполнение статических инициализаторов и присваивание начальных значений.

Загрузчиков несколько, и они образуют иерархию с делегированием «сначала спрашиваем родителя»:

  • bootstrap — загружает базовые классы платформы (модуль java.base);
  • platform — загружает остальные классы платформенных модулей;
  • application (system) — загружает классы вашего приложения с classpath или modulepath.

Загрузка ленивая: класс попадает в память не заранее, а в момент первого обращения к нему. Этап verification — это встроенная защита: JVM отказывается исполнять байткод, который нарушает правила типобезопасности или структуру стека, поэтому битый или подделанный .class не пройдет дальше проверки. Обратите внимание: имена platform-загрузчика актуальны для Java 9 и новее; в более старых версиях вместо него был extension class loader — это отличие стоит учитывать в legacy-коде.

Механизм выполнения и JIT-компиляция

Вторая подсистема — механизм выполнения (execution engine). Он превращает байткод в реальные действия процессора. Здесь важно не путать два режима.

  • Интерпретация — JVM читает и выполняет байткод инструкция за инструкцией. Старт быстрый, но сам код медленнее нативного.
  • JIT-компиляция (Just-In-Time) — «горячие» участки, которые вызываются часто, компилируются в машинный код прямо во время работы и дальше исполняются нативно.

HotSpot называется так именно потому, что ищет «горячие точки» и компилирует их. В нем работает многоуровневая компиляция (tiered compilation): быстрый компилятор C1 дает раннюю оптимизацию, а более агрессивный C2 перекомпилирует самые нагруженные методы. За счет профиля исполнения JIT применяет оптимизации, недоступные обычному компилятору «наперед»: инлайнинг, устранение проверок, спекулятивные допущения с откатом (деоптимизацией), если допущение не подтвердилось.

Это упрощенная модель: помимо интерпретатора и JIT существует и заранее-компиляция (AOT), но для стандартного HotSpot базовый путь — именно «интерпретация плюс JIT».

Сборка мусора: управление памятью

Третья подсистема — сборщик мусора (garbage collector, GC). В Java память под объекты выделяется в куче при new, а освобождается автоматически: GC находит объекты, до которых из программы больше нельзя добраться по ссылкам, и возвращает их память. Ручного free, как в C или C++, нет, и это снимает целый класс ошибок вроде двойного освобождения.

«Мусор» здесь — строгий термин: это недостижимые объекты, а не просто давно не использованные. Достижимость считается от корней (GC roots): локальных переменных на стеке, статических полей и подобного.

В современных сборках GC не один, их выбирают под задачу:

  • G1 (Garbage-First) — сборщик по умолчанию для серверных конфигураций начиная с Java 9, а с Java 27 — во всех окружениях (прежде на слабых машинах с одним ядром или малой памятью JVM выбирала Serial); делит кучу на регионы и балансирует паузы и пропускную способность;
  • ZGC и Shenandoah — сборщики с очень короткими, субмиллисекундными паузами, рассчитанные на большие кучи и низкую задержку; в актуальных JDK ZGC работает только в поколенческом (generational) режиме;
  • Parallel GC — ориентирован на максимальную пропускную способность, паузы длиннее.

Выбор GC задается флагом JVM (например, -XX:+UseZGC). Универсально «лучшего» сборщика нет: G1 — разумный дефолт для большинства приложений, ZGC и Shenandoah берут там, где критична задержка, Parallel — где важнее общая пропускная способность батч-обработки.

Платформонезависимость и другие языки на JVM

Принцип write once, run anywhere («написано однажды — запускается везде») означает, что переносим именно байткод, а не исходник. Абстракцию от железа обеспечивает связка «единый формат .class плюс реализация JVM под каждую ОС». Полностью абсолютной переносимости это не гарантирует: поведение может отличаться из-за версии Java, доступной памяти или обращения к платформенным API, поэтому сборку под несколько сред все равно проверяют.

Побочный, но важный эффект: JVM исполняет любой корректный байткод, а не только полученный из Java. Поэтому на ней работают и другие языки — Kotlin, Scala, Groovy, Clojure: их компиляторы тоже выдают .class-файлы. Для программиста это значит, что экосистема JVM (библиотеки, инструменты, сборщики мусора) доступна из нескольких языков сразу.

Выводы

  • JVM исполняет промежуточный байткод из .class-файлов, а не машинный код конкретного процессора; переносимость обеспечивает именно единый формат байткода плюс реализация JVM под каждую ОС.
  • JVM, JRE и JDK — разные уровни: машина, среда выполнения с библиотеками и комплект разработчика с инструментами.
  • Три подсистемы JVM: загрузчик классов (load-link-init с проверкой байткода), механизм выполнения (интерпретация плюс JIT в HotSpot) и сборщик мусора.
  • Память в куче освобождается автоматически: GC собирает недостижимые объекты; по умолчанию используется G1, для низких задержек берут ZGC или Shenandoah.
  • На JVM компилируются и другие языки (Kotlin, Scala, Groovy), потому что машине важен байткод, а не исходный язык.

Где применяется / связь с практикой

Понимание JVM — это база для отладки и настройки Java-приложений: выбор и тюнинг сборщика мусора, чтение стектрейсов и .class-файлов, поиск утечек памяти и «прогрев» JIT под нагрузкой. Без этой модели производительность и поведение приложения в проде остаются черным ящиком.

Освойте тему на практике

Разобрать устройство JVM на практике — от компиляции первого класса до профилирования GC — удобно на курсе Java Basic: там byte-code, память и инструменты JDK показывают на живых примерах. Посмотреть формат и уровень занятий можно на открытых уроках Otus — это бесплатно и помогает понять, подходит ли вам направление.

FAQ

Java — компилируемый или интерпретируемый язык?
И то и другое. Исходник компилируется в байткод компилятором javac, а байткод во время работы и интерпретируется, и частично компилируется в машинный код JIT-компилятором внутри JVM.

Можно ли запустить программу, имея только JRE, без JDK?
Да. JRE (JVM плюс библиотеки) достаточно, чтобы исполнять готовый байткод. JDK нужен, чтобы компилировать исходники и пользоваться инструментами вроде javac и javap. С Java 11 среду выполнения обычно берут внутри JDK или собирают через jlink.

Нужно ли вручную освобождать память в Java?
Нет, память освобождает сборщик мусора автоматически. Задача разработчика — не удерживать лишние ссылки на ненужные объекты, иначе они останутся достижимыми и не будут собраны, что и приводит к утечкам памяти.

OTUS Журнал
Скидка 5% 14-20 сентября на курсы (popup)