Как работает jvm
Перейти к содержимому

Как работает jvm

Что такое JVM? Знакомство с виртуальной машиной Java

Что такое JVM? Знакомство с виртуальной машиной Java главное изображение

Java virtual machine (JVM) — это программа, которая разработана для выполнения и запуска других программ на основе Java. В основе JVM лежит простая и гениальная идея, которая всегда останется одним из величайших примеров программирования в стиле кунг-фу. JVM может также использоваться для выполнения программ, написанных на других языках программирования. Подробно рассказываем, как работает JVM, для чего используется эта технология и почему она является одним из главных компонентов в платформе Java. Материал основан на статье Java-разработчика Matthew Tyson «What is the JVM? Introducing the Java Virtual Machine».

Для чего используется Java virtual machine

JVM имеет две основные функции:

  1. Позволяет запускать программы, написанные на Java, на любых устройствах или операционных системах. Так реализуется принцип Java — «Написал один раз, запускай везде».
  2. Управляет и оптимизирует память, которую используют программы.

Во время выхода первой версии Java в 1995 году все программы писались для конкретной операционной системы, а памятью управлял разработчик программного обеспечения. Поэтому появление JVM стало революцией на рынке.

Существует два основных определения JVM — техническое и повседневное:

  • Техническое определение: JVM — это софт, который выполняет код и предоставляет среду для его выполнения.
  • Повседневное определение: JVM — это способ запуска наших Java-приложений. Мы настраиваем параметры JVM, а потом полагаемся на ее автоматическое управление ресурсами во время выполнения.

Когда разработчики говорят о JVM, обычно имеется в виду процесс, который выполняется на нашем устройстве, особенно на сервере — он управляет и контролирует использование ресурсов Java-приложения.

Кто разрабатывает и обслуживает JVM?

На сегодняшний день JVM массово используется и развивается в разных проектах — как коммерческих, так и Open Sourse. Например, существует проект OpenJDK, который представляет собой полностью совместимый Java Development Kit, состоящий исключительно из свободного и открытого исходного кода. При этом, несмотря на открытость кода этого проекта, его разработкой практически полностью занимается корпорация Oracle.

Сборка мусора

В Java памятью управляет JVM с помощью процесса, который называется сборкой мусора — он непрерывно определяет и удаляет неиспользуемую память в Java-приложениях. Сборка мусора происходит внутри работающей JVM.

В начале существования Java подвергалась серьезной критике за то, что не была «Close to the metal» как C++, поэтому не была такой быстрой. Особенно спорным критики называли процесс сборки мусора. С тех пор были предложены и использованы различные алгоритмы и подходы, которые значительно улучшили и оптимизировали сборку мусора.

Три главные части JVM

JVM состоит из трех основных частей: спецификация, реализация и экземпляр. Рассмотрим каждую из них.

Спецификация JVM

Первая часть JVM — спецификация, которая до конца не определяет все детали реализации виртуальной машины. Это значит, что остается максимальная свобода творчества для разработчика, который работает с ней. Чтобы правильно реализовать виртуальную машину Java, вам нужно всего лишь уметь читать class-файлы и правильно выполнять указанные в них операции.

И так, все, что должна делать JVM — правильно запускать Java-программы. Это может показаться достаточно простым процессом, однако это очень масштабная задача, учитывая мощность и гибкость языка Java.

Реализация JVM

Реализация спецификации JVM приводит к созданию реальной программы, которая и является реализацией JVM. По сути, существует огромное количество реализаций спецификации JVM — как коммерческих, так и с открытым кодом.

Экземпляр JVM

После того, как спецификация JVM реализована и выпущена в качестве самостоятельной программы, вы можете загрузить ее как приложение. Эта загруженная программа является экземпляром виртуальной машины.

Чаще всего, когда разработчики говорят о JVM, они имеют ввиду экземпляр JVM, который работает в среде разработки. Вы можете сказать: «Привет, сколько памяти использует JVM на этом сервере?» или «Я не могу поверить, что сделал зацикленный вызов, а переполнение стека сломало мою JVM. А ведь это просто ошибка новичка!»

Загрузка и выполнение class-файлов в JVM

Мы говорили о роли JVM в запуске Java-приложений, но как виртуальная машина выполняет свою функцию? Для запуска Java-приложений JVM зависит от загрузчика классов и механизма выполнения Java.

Загрузчик классов в JVM

Все в Java — классы, и все Java-приложения состоят из классов. Любое приложение может состоять из одного или многих тысяч классов. Чтобы запустить Java-приложение, JVM должна загрузить скомпилированные .class-файлы в контекст — например, в сервер, где они будут доступны. JVM зависит от своего загрузчика класса для корректного выполнения этой функции.

Загрузчик классов Java является частью JVM — он загружает классы в память и делает их доступными для выполнения. Загрузчик классов использует технику ленивой загрузки (lazy-loading) и кэширование, чтобы сделать загрузку классов максимально эффективной. При этом использование таких методов считается достаточно простым процессом.

Все виртуальные машины Java включают в себя загрузчики классов. Спецификация JVM описывает стандартные методы для запроса и управления загрузчиком во время работы, но за выполнение этих возможностей отвечает конкретная реализация JVM. С точки зрения разработчика, механизмы, лежащие в основе загрузчика классов, обычно представляют собой черный ящик.

Механизм выполнения в JVM

После того, как загрузчик классов завершил свою работу, JVM начинает выполнять код каждого класса. Механизм выполнения — компонент JVM, который обрабатывает функции, и он необходим для корректной работы любой виртуальной машины Java.

Выполнение кода включает управление доступом к системным ресурсам. Механизм выполнения JVM находится между работой программы, с ее запросами на файловые, сетевые ресурсы и ресурсы памяти, и операционной системой, которая предоставляет эти ресурсы.

Управление системными ресурсами

Системные ресурсы могут быть разделены на две больших категории: память и все остальное.

JVM отвечает за очистку неиспользуемой памяти, при этом сборщик мусора — это механизм, который и осуществляет этот процесс. JVM также отвечает за распределение и поддержание ссылочной структуры, которую любой разработчик принимает как само собой разумеющееся. Например, механизм выполнения JVM отвечает за то, что при использовании ключевого слова new происходит запрос к операционной системе на выделение памяти.

Помимо памяти, механизм выполнения управляет ресурсами файловой системы и сети. Поскольку JVM совместима с различными операционными системами, то эта задача считается достаточно сложной. Помимо потребностей каждого приложения в ресурсах, механизм выполнения должен корректно работать с каждой операционной системой.

Эволюция JVM: прошлое, настоящее, будущее

В 1995 году разработчики JVM представили две революционные концепции, которые с тех пор стали стандартом в разработке: «Написал один раз, запускай везде» и автоматическое управление памятью. В то время совместимость софта была смелой концепцией, но сейчас это стало нормой. Точно так же, как современное поколение живет с автоматической сборкой мусора.

Можно сказать, что если Джеймс Гослинг и Брендан Эйх изобрели современное программирование, то тысячи других разработчиков усовершенствовали и развили их идеи в последующие десятилетия. Изначально виртуальная машина Java предназначалась только для Java, но сегодня она эволюционировала до поддержки многих языков программирования, включая Scala, Groovy и Kotlin.

Изучайте Java на Хекслете Вступайте в профессию и изучайте один из самых востребованных в энтерпрайзе языков программирования.

Внутренности JVM, Часть 1 — Загрузчик классов

В этой серии статей я расскажу о том, как работает Java Virtual Machine. Сегодня мы рассмотрим механизм загрузки классов в JVM.

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

В состав JVM входят различные компоненты, такие как загрузчик классов (Classloader), сборщик мусора (Garbage Collector) (автоматическое управление памятью), интерпретатор, JIT-компилятор, компоненты управления потоками. В этой статье рассмотрим загрузчик классов (Class loader).

Загрузчик классов загружает class-файлы как для вашего приложения, так и для Java API. В виртуальную машину загружаются только те class-файлы Java API, которые действительно требуются при выполнении программы.

Байт-код выполняется подсистемой исполнения (execution engine).

Что такое загрузка классов?

Загрузка классов — это поиск и загрузка типов (классов и интерфейсов) динамически во время выполнения программы. Данные о типах находятся в бинарных class-файлах.

Этапы загрузки классов

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

  • Загрузка (loading) — поиск и импорт бинарных данных для типа по его имени, создание класса или интерфейса из этого бинарного представления.
  • Связывание, линковка (linking) — выполнение верификации, подготовки и, необязательного, разрешения:
    • Верификация (verification) — проверка корректности импортируемого типа.
    • Подготовка (preparation) — выделение памяти для статических переменных класса и инициализация памяти значениями по умолчанию.
    • Разрешение (resolution) — преобразование символьных ссылок типов в прямые ссылки.

    Механизм загрузки классов в Java

    Примечание переводчика — в данном разделе описано поведение для java < 9, в java 9+ произошли небольшие изменения, которые описаны ниже.

    В Java используется модель делегирования загрузки классов. Основная идея состоит в том, что у каждого загрузчика классов есть “родительский” загрузчик. Когда происходит загрузка класса, то загрузчик “делегирует” поиск класса своему родителю, перед тем как искать класс самостоятельно.

    Модель делегирования загрузчиков классов представляет собой граф загрузчиков, которые передают друг другу запросы на загрузку. Корнем в этом графе является bootstrap-загрузчик. Загрузчики классов создаются с одним родителем, которому они могут делегировать загрузку, и осуществляют поиск класса в следующих местах:

    • Кэш
    • Родитель
    • Сам загрузчик

    Класс загружается тем загрузчиком, который ближе всего к корню, поскольку право первому загрузить класс всегда предоставляется загрузчику-родителю. Это позволяет загрузчику видеть только классы, загруженные самостоятельно, его родителем или предками. Он не может видеть классы, загруженные дочерними загрузчиками.

    В Java SE Platform API исторически было определено два загрузчика классов:

    Bootstrap class loader (базовый, первичный загрузчик) — загружает классы из bootstrap classpath.

    System class loader (системный загрузчик) — родительский класс для новых загрузчиков классов и, как правило, загрузчик классов, используемый для загрузки и запуска приложения.

    Загрузчики классов JDK 9+

    Application class loader — обычно используется для загрузки классов приложения из classpath. Также это загрузчик по умолчанию для некоторых модулей JDK, которые содержат утилиты или экспортируют API утилит. (Примечание переводчика: например, jdk.jconsole , jdk.jshell и др)

    Platform class loader — загружает выбранные (на основе безопасности / разрешений) модули Java SE и JDK. Например, java.sql.

    Bootstrap class loader — загружает основные модули Java SE и JDK.

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

    • Application class loader сначала ищет именованные модули, определенные для всех встроенных загрузчиков. Если для одного из этих загрузчиков определен подходящий модуль, то этот загрузчик загружает класс. Если в именованном модуле, определенном для одного из этих загрузчиков, класс не найден, тогда application class loader делегирует его родителю. Если класс не найден родителем, то application class loader ищет его в classpath. Классы, найденные в classpath, загружаются как члены безымянного модуля этого загрузчика.
    • Platform class loader выполняет поиск именованных модулей, определенных для всех встроенных загрузчиков. Если подходящий модуль определен для одного из этих загрузчиков, тогда этот загрузчик загружает класс. Если в именованном модуле, определенном для одного из этих загрузчиков, класс не найден, тогда platform class loader делегирует его родителю.
    • Bootstrap class loader выполняет поиск именованных модулей, определенных для него самого. Если класс не найден в именованном модуле, определенном для bootstrap-загрузчика, тогда bootstrap-загрузчик ищет файлы и каталоги, добавленные в bootstrap classpath, с помощью параметра -Xbootclasspath/a (позволяет добавить файлы и каталоги к bootstrap classpath). Классы, найденные в bootstrap classpath, загружаются как члены безымянного модуля этого загрузчика.

    Запустив этот код на установленном у меня Amazon Corretto 11.0.3, получим следующий результат:

    Как работает виртуальная машина Java — взгляд изнутри

    Каждому Java-разработчику будет очень полезно понимать, что из себя представляет JVM, как в неё попадает код и как он исполняется. Статья больше подойдёт новичкам, но найти в ней что-то новое смогут и более опытные программисты. В статье вкратце описано, как устроен class-файл и как виртуальная машина обрабатывает и исполняет байт-код.

    Основной задачей разработчиков Java было создание переносимых приложений. JVM играет центральную роль в переносимости — она обеспечивает должный уровень абстракции между скомпилированной программой и базовой аппаратной платформой и операционной системой. Несмотря на этот дополнительный «слой», скорость работы приложений необычайно высока, потому что байт-код, который выполняет JVM, и она сама отлично оптимизированы.

    Рассмотрим схему работы JVM более подробно.

    Структура class-файла

    Напишем простейшее приложение и скомпилируем его. Компилятор заботливо создаст файл с расширением class и поместит туда всю информацию о нашем мини-приложении для JVM. Что мы увидим внутри? Файл поделён на десять секций, последовательность которых строго задана и определяет всю структуру class-файла.

    Файл начинается со стартового (магического) числа: 0xCAFEBABE. Данное число присутствует в каждом классе и является обязательным флагом для JVM: с его помощью система понимает, что перед ней class-файл.

    Следующие четыре байта class-файла содержат старший и младший номера версий Java. Они идентифицируют версию формата конкретного class-файла и позволяют JVM проверять, возможна ли его поддержка и загрузка. Каждая JVM имеет ограничение версии, которую она может загрузить — более поздние версии будут игнорироваться. Как видно на примере файла выше, у нас major версия 0x34 , что соответствует Java SE 8. Для Java SE 11 будет значение 0x37 .

    С девятого байта идёт пул констант, в котором содержатся все константы нашего класса. Так как в каждом классе их может быть различное количество, то перед массивом находится переменная, указывающая на его длину, то есть пул констант представляет из себя массив переменной длины. Каждая константа занимает один элемент в массиве. Во всём class-файле константы указываются целочисленным индексом, который обозначает их положение в массиве. Начальная константа имеет индекс 1, вторая константа — 2 и т. д.

    Каждый элемент пула констант начинается с однобайтового тега, определяющего его тип. Это позволяет JVM понять, как правильно обработать идущую далее константу. Всего зарезервировано 14 типов констант:

    Тип константы Значение тега
    CONSTANT_Class 7
    CONSTANT_Fieldref 9
    CONSTANT_Methodref 10
    CONSTANT_InterfaceMethodref 11
    CONSTANT_String 8
    CONSTANT_Integer 3
    CONSTANT_Float 4
    CONSTANT_Long 5
    CONSTANT_Double 6
    CONSTANT_NameAndType 12
    CONSTANT_Utf8 1
    CONSTANT_MethodHandle 15
    CONSTANT_MethodType 16
    CONSTANT_InvokeDynamic 18

    Например, если тег указывает, что константа является строкой, JVM получает значение тега 1 и обрабатывает следующее за тегом число как длину массива байт, которые необходимо считать, чтобы получить нужную нам строку полностью.

    Прочитав блок с константами, JVM переходит к следующим двум байтам — флагам доступа, которые определяют, описывает этот файл класс или интерфейс, общедоступный или абстрактный, является ли класс финальным.

    Имена класса и его родительского класса хранятся в массиве констант, на которые указывают последующие 4 байта в файле.

    Немного иначе обстоят дела с интерфейсами. Так как класс может наследоваться от множества интерфейсов одновременно, то хранить необходимо массив ссылок на пул констант. То есть за определением класса и его родительского класса идёт число, характеризующее размер массива интерфейсов, и сам массив. Все возможные значения кодов представлены ниже.

    Имя флага Код Определение
    ACC_PUBLIC 0x0001 Объявлен публичным
    ACC_FINAL 0x0010 Объявлен финальным
    ACC_SUPER 0x0020 Специальный флаг, введённый в версии Java 1.1 для совместимости при вызове методов родителя
    ACC_INTERFACE 0x0200 Объявлен интерфейсом
    ACC_ABSTRACT 0x0400 Объявлен абстрактным
    ACC_SYNTHETIC 0x1000 Зарезервированное определение
    ACC_ANNOTATION 0x2000 Объявлен аннотацией
    ACC_ENUM 0x4000 Объявлен перечислением

    Подобную структуру имеет и следующий блок — Fields.

    Этот блок начинается с двухбайтового параметра количества полей в этом классе или интерфейсе. За ним идёт массив структур переменной длины. Каждая структура содержит информацию об одном поле: имя поля, тип, значение, если это, например, финальная переменная. В списке отображаются только те поля, которые были объявлены классом или интерфейсом, определённым в файле. Поля от родительских классов и имплементированных интерфейсов здесь не присутствуют, они задаются в своих class-файлах.

    Далее мы переходим к самому важному месту в любом классе — его методам, именно в них сосредоточена вся логика любой программы, весь исполняемый байт-код.

    Ситуация абсолютно аналогична описанным выше полям. В массиве переменной длины содержатся структуры, в которые входит полное описание сигнатуры метода: модификаторы доступа, имя метода и его атрибуты, которые также представляют из себя структуру, так как их может быть множество и каждый из них может принадлежать разным типам.

    В последнем блоке идёт дополнительная мета-информация, например имя файла, который был скомпилирован. Она может присутствовать, а может и нет. В случае каких-то проблем JVM просто игнорирует этот блок.

    Мы рассмотрели структуру файлов и готовы перейти к следующей части — загрузке class-файла в JVM и последующему выполнению байт-кода из этого класса. В качестве закрепления полученных знаний по структуре class-файла можете воспользоваться встроенным декомпилятором Java и посмотреть результат его выполнения с ключами -c -verbose (javap -c -verbose TestJava.class) .

    Загрузка классов

    Теперь, разобравшись с общей структурой файла, посмотрим, как JVM его обрабатывает.

    Чтобы попасть в JVM, класс должен быть загружен. Для этого существуют специальные классы-загрузчики:

    1. Bootstrap — базовый загрузчик, загружает платформенные классы. Этот загрузчик является родителем всех остальных классов и частью платформы.
    2. Extension ClassLoader — загрузчик расширений, потомок Bootstrap-загрузчика. Загружает классы расширений, которые по умолчанию находятся в каталоге jre/lib/ext .
    3. AppClassLoader — системный загрузчик классов из classpath, который является непосредственным потомком Extension ClassLoader. Он загружает классы из каталогов и jar-файлов, указанных переменной среды CLASSPATH , системным свойством java.class.path или параметром командной строки -classpath .
    4. Собственный загрузчик — у приложения могут быть свои собственные загрузчики.

    Главный класс приложения всегда загружается системным загрузчиком, остальные же классы могут быть загружены различными пользовательскими загрузчиками. Стоит упомянуть, что имя загрузчика создаёт уникальное пространство имён, то есть в программе может существовать несколько классов с одним и тем же полным именем, если они обрабатывались разными загрузчиками.
    Поэтому каждый загрузчик делегирует свои полномочия родителю, то есть перед поиском класса для загрузки он попытается узнать, не был ли загружен нужный класс раньше.

    После загрузки класса начинается этап линковки, который делится на три части.

    1. Верификация байт-кода. Это статический анализ кода, выполняется один раз для класса. Система проверяет, нет ли ошибок в байт-коде. Например, проверяет корректность инструкций, переполнение стека и совместимость типов переменных.
    2. Выделение памяти под статические поля и их инициализация.
    3. Разрешение символьных ссылок — JVM подставляет ссылки на другие классы, методы и поля. В большинстве случаев это происходит лениво, то есть при первом обращении к классу.

    Класс инициализируется, и JVM может начать выполнение байт-кода методов.

    JVM получает один поток байтовых кодов для каждого метода в классе. Байт-код метода выполняется, когда этот метод вызывается в ходе работы программы. Поток байт-кода метода — это последовательность инструкций для виртуальной машины Java. Каждая инструкция состоит из однобайтового кода операции, за которым может следовать несколько операндов. Код операции указывает действие, которое нужно предпринять. Всего на данный момент в Java более 200 операций. Все коды операций занимают только 1 байт, так как они были разработаны компактными, поэтому их максимальное число не может превысить 256 штук.

    В основе работы JVM находится стек — основные инструкции работают с ним.

    Рассмотрим пример умножения двух чисел. Ниже представлен байт-код метода:

    На Java это будет выглядеть так:

    По листингу выше можно заметить, что коды операций сами по себе указывают тип и значение. Например, код операции iconst_1 указывает JVM на целочисленное значение, равное единице. Такие байт-коды определены для самых часто используемых констант. Эти инструкции занимают 1 байт и введены специально для повышения эффективности выполнения байт-кода и уменьшения размера его потока. Подобные короткие константы также присутствуют и для других типов данных.

    Всего JVM поддерживает семь примитивных типов данных: byte, short, int, long, float, double и char.

    Если бы мы хотели положить в переменную а другое значение, например 11112, то нам пришлось бы использовать инструкцию sipush :

    Данные операции выполняются в так называемом фрейме стека метода. У каждого метода есть некоторая своя часть в общем стеке. Таким образом в нашем главном потоке исполнения программы создаются множество подстеков на каждый вызов метода. Более наглядно это представлено на картинке ниже:

    В каждом стек-фрейме хранится массив локальных переменных, который позволяет сохранять и доставать локальные переменные, как мы сделали в примере выше, поместив значения 1 и 5 в переменные 1 и 2. Стоить отметить, что здесь компилятор также оптимизировал байт-код, используя однобайтовые инструкции. Если бы переменных в нашем методе было много, использовался бы код операции сохранения значения вместе с указанием позиции переменной в массиве.

    Чтобы достучаться до пула констант класса и получить нужное значение, используются инструкции lcd и lcd_w . При этом lcd может ссылаться только на константы с индексами от 1 до 255, поскольку размер её операнда составляет всего 1 байт. Lcd_w имеет 2-байтовый индекс, поэтому может ссылаться на более широкий диапазон индексов.

    Вызовы методов

    Java предоставляет два основных вида методов: методы экземпляра и методы класса. Методы экземпляра используют динамическое (позднее) связывание, тогда как методы класса используют статическое (раннее) связывание.

    Виртуальная машина Java вызывает метод класса, выбирая его на основании типа ссылки на объект, который всегда известен во время компиляции. С другой стороны, когда виртуальная машина вызывает метод экземпляра, она выбирает метод для вызова на основе фактического класса объекта, который может быть известен только во время выполнения. Поэтому для вызова методов используются разные инструкции: invokevirtual и invokestatic . Данные функции ссылаются на запись в пуле констант в виде полного пути к необходимой функции. Виртуальная машина снимает нужное количество переменных со стека и передает в метод.

    Возвращаемое методом значение кладётся на стек. Типы возвращаемых значений методов указаны ниже:

    Операция Описание
    ireturn Помещает на стек значение типа int
    lreturn Помещает на стек значение long,
    freturn Помещает на стек значение float
    dreturn Помещает на стек значение double
    areturn Помещает на стек значение object
    return Не изменяет стек

    Циклы

    Осталось рассмотреть последнюю часто используемую конструкцию языка — циклы. Посмотрим, во что превратится код, представленный ниже:

    Каждый раз на стеке оказывается два числа, которые сравниваются, и если i > 9999 , происходит выход из цикла. При этом для создания цикла используется конструкция на основе goto , которая запрещена в самом языке Java, хотя ключевое слово и зарезервировано.

    Заключение

    Изначально байт-код интерпретируется в большинстве JVM, но как только система замечает, что некоторый код используется очень часто, она подключает встроенный компилятор, который компилирует байт-коды в машинный код, тем самым значительно ускоряя работу приложения.

    Таким образом, мы поверхностно рассмотрели жизненный цикл байткода в JVM: class-файлы, их загрузку и выполнение байт-кода и базовые инструкции.

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *