- JVM: краткий курс общей анатомии
- JVM: предисловие.
- 1. Java Class file и Byte-код
- 2. Bytecode Verification.
- 3. Classloading Engine
- 3a. Процесс загрузки класса
- 3b. Линковка
- 4. Исполнение байт-кода (Execution Engine):
- 5. Meta Information
- 6. Threading and synchronization.
- 7. Memory Management. Garbage Collection.
- 7a. Сборка мусора
- 8. Manageability & Monitoring
- Реализации JVM
JVM: краткий курс общей анатомии
Существуют разные реализации JVM. Докладчики занимаются разработкой HotSpot JVM и Excelcior Jet. В докладе будут освещены аспекты, характерные для любой JVM.
Пост будет интересен желающим поверхностно разобраться в кишках Java, не углубляяся в что-то совсем низкоуровневое. Рекомендую посмотреть доклад, на студенческом дне JPoint 2016 он был одним из самых интересных и живых. Но если вы не располагаете часом времени, вы можете ознакомиться с сокращенным пересказом выступления здесь.
После прочтения вы получите представление об обработке Java-машиной ваших классов, о методах их исполнения, о сборке мусора.
Приятного прочтения.
JVM: предисловие.
У каждой программы, работающей на JVM есть main-метод, который находится в одном из классов из classpath. У веб-приложений нет main-метода. В веб приложениях программа для JVM — сам сервер.
Classpath — список директорий и архивов (.jar)
В java есть native-методы — методы, использующие код из нативных для платформы библиотек.
1. Java Class file и Byte-код
Один класc исходного кода соответствует одному файлу .class после трансляции в Java байт-код. Структура у байт-кода следующая:
- Сначала прописывается Constant Pool: числа, строки, указатели на классы, методы, поля.
- Затем прописывается описание класса: имя, модификаторы, суперкласс, суперинтерфейсы, поля, методы, атрибуты. Поля и методы тоже имеют атрибуты (например, атрибут static).
- Массива инструкций
- Стека операндов инструкций метода
- Массива локальных переменных
Рассмотрим пример работы команд из байткода:
0: iload 3 — взять int значение из слота №3.
2: bipush 5 — положить в стек интовую переменную из ячейки № 5.
4: iadd — сложить переменные в стеке.
5: istore 4 — положить результат в ячейку № 4.
Байт код строго типизирован. Так, символ i указывает на выполнение операций только для int-чисел. В спецификации четко прописано, как должны работать инструкции для каждого варианта JVM.
2. Bytecode Verification.
- Платформенные классы берутся из Java Runtime (платформенные)
- Из classpath
- Сгенерированные на лету (Proxy, Reflection)
- Приложение может загружать классы при помощи своего внутреннего и определенного разработчиком classloader.
3. Classloading Engine
3a. Процесс загрузки класса
Загрузка классов осуществляется одним из трех методов:
- Платформенные классы — bootstrap-загрузчик
- Классы из classpath — системным загрузчиком (AppClassLoader)
- Классы приложения могут создавать свои загрузчики, которые будут грузить классы
Загрузчики классов образуют уникальные пространства имен классов. Классы в JVM идентифицируются по паре имя+загрузивший класс загрузчик.
Итак, как же происходит процесс загрузки классов?
- Читается .class файл, проверяется корректность формата (может выбросить ClassFormatError)
- Создается рантайм представление класса в выделенной области памяти (Meta Space)
- Грузятся суперкласс и суперинтерфейсы (Если не вышло загрузить, сам класс не будет считаться загруженным)
- Класс становится доступным JVM для анализа (методы, поля, атрибуты)
3b. Линковка
Прежде чем класс будет готов к исполнению, требуется произвести ряд приготовлений:
- Изначально поля, методы классы представлены в виде текста. Требуется заполучить какое-то внутреннее представление ссылок.
- Осуществить верификацию байт-кода
Задача: Что произойдет при исполнении байт-кода:
Ответ: Байт-код не исполнится. Это происходит потому что верификация байт-кода происходит до его исполнения (верификация осуществляется один раз, производится проверка корректности инструкций, проверка выхода за пределы стека операндов и локальных переменных, проверка совместимости типов) При непрохождении хотя бы одной из проверок код из этого класса исполнен не будет.
Если попытаться запустить этот код с отключенной верификацией:
, то в рантайме будет получена java.lang.StackoverflowError.
Исполнение верифицированного кода позволяет JVM исполнять код очень эффективно. Верифицированный код не может сломать JVM. Многие операции маппятся на машинные команды 1:1.
- Вызов статического инициализатора класса (присваивание значений). Должна произойти до того, как класс будет готов к исполнению.
- Случается при вызове: (создания объекта через new, доступа до статического поля или вызова статического метода)
- Провоцирует инциализацию супер-класса и супер-интерфейсов с default-методами.
4. Исполнение байт-кода (Execution Engine):
Байт-код может исполняться двумя способами:
Интерпретатор работает следующим образом:
- Извлекает номер инструкции.
- В зависимости от необходимых операндов, их типа и количества, выполняем их загрузку. Выполняем операции из байт кода с этими операциями.
- Повторяем 1 и 2, пока байт код не закончится.
Интерпретация работает медленно. Намного быстрее работает скомпилированный код. Но компиляторы тоже бывают разные: некоторые работают быстро, но производят не слишком эффективные инструкции. Некоторые хорошо оптимизируют код, но делают это дольше.
Компиляторы также можно разделить по моменту компиляции:
- Динамические (JIT): компилируют одновременно с работой программы, работают на горячем (часто исполняемом) коде. Горячий код вычисляется с помощью динамического профилировщика. Используют информацию времени исполнения для оптимизаций. Несомненно, оказывают негативное влияние на работу программы.
- Статические компиляторы (AOT): Не ограничены в ресурсах для оптимизации программ. Компилируют каждый метод, применяя самые агрессивные оптимизации. На оптимизацию не тратятся ресурсы во время исполнения программы (быстрее стартуем). Уместны для программ, в которых мало горячего кода. Интересно, что во время работы статически скомпилированной Java-программы может вообще не исполниться байт-код.
Все современные JVM стараются как можно меньше исполнять байт-код, интерпретируя его. По возможности осуществляется компиляция и исполнение инструкций на процессоре.
5. Meta Information
Для того, чтобы JVM могла, например, работать с Reflection, необходимо хранить некоторую мета-информацию. Reflection работает за счет того, что JVM предоставляет возможность находить в Meta Space поля и методы по имени. Без динамической компиляции такое было бы невозможно, поскольку символьная информация (информация о именах полей, методов) беспощадно вычищается при статической компиляции.
Как пример, доступ к аннотациям во время исполнения. Spring, например, анализирует аннотации (и не только их) именно благодаря механизму Reflection.
В jdk 7 добавили MethodHandle — целевой объект вызова через инструкцию invokedynamic. Может быть доступом к полю, методу, другим MethodHandle. Этакий Reflection 2.0
Java Native Interface (JNI) — интерфейс на нативном языке (например, C) между JVM и ОС. Используется для реализации native методов на языке C. Любая JVM должна удовлетворять этому интерфейсу. С помощью JNI написаны многие платформенно-зависимые операции Java SE. В JVM реализуется как доступ к Meta Space.
6. Threading and synchronization.
Java.lang.Thread
Java-поток маппится на поток ОС в отношении 1:1. С потоком связана память, используемая для локальных переменных и стека операндов методов. Любой тред хранит информацию о стеке вызовов метода в потоке (stack trace).
При выбрасывании исключения JVM обходит stacktrace, находит обработчик для этого исключения и передает управление этому обработчику.
Рассмотрим проблему синхронизации потоков. Сложности:
- Компилятор может переставлять код для оптимизаций.
- Сам процессор при исполнении машинные инструкций может осуществлять перестановочные оптимизации.
Объявление переменной как volatile запрещает перестановочные оптимизации любого уровня над этой переменной.
Синхронизация (synchronized -методы и -блоки) может быть реализована за счет нативных средств ОС (mutex). Однако mutex’ы достаточно дороги. В случае, когда реально конкуренции за ресурс не происходит, JVM может оптимизировать выполнение и не создавать Overhead’а!
7. Memory Management. Garbage Collection.
В спецификации JVM есть инструкция для создания нового объекта (вызова оператора new). При вызове new только что созданный объект располагается в куче (Java Heap). Heap специфичен для JVM. Структура аллоцированного объекта специфична для JVM, за исключением следующих требований:
- Указатель на класс в заголовке объекта (Строгая типизация)
- Монитор (lock) — указывает на возможность синхронизации на объекте.
- Identity hashcode
- Флаги для JVM
Аллокация объектов в Java не основана на malloc под один объект. JVM запрашивает у ОС память не под один объект, а сразу на большое количество объектов, что позволяет отложить следующий запрос памяти у JVM. В хипе под новые объекты память выделяется простым передвижением границы внутри Heap.
(прим. Видимо, примерно это имеют в виду, когда говорят, что Java кушает много оперативки)
Некоторые JVM умеют оптимизировать расположение объектов таким образом, чтобы ссылающиеся друг на друга объекты лежали рядом.
Выделение памяти в одном потоке не должно блокировать работу других потоков. Поэтму в JVM, как правило, у каждого потока есть свой кусочек памяти.
Поля в объекте могут быть переупорядочены из соображений экономии памяти, особенностей архитектуры, для выраванивания.
7a. Сборка мусора
Что такое мусор? Допустим, объекты, на которые нет ссылок. А если, например, есть 3 объекта, ссылающихся друг на друга по циклу? Выходит, сложно определить мусор исходя из ссылок. Давайте пойдем другим путем.
Пусть мусором будут объекты, которые не могут использоваться программой. Возникает вопрос: какие могут использоваться?
Фрейм метода = локальные переменные метода + стек операндов метода
«Не мусором» или «живыми объектами» (1, 2, 3 вместе называют корневым множеством объектов, GC roots) являются:
- Объекты в статических полях классов
- Объекты из фреймов всех методов на stacktrace’ах всех Java потоков
- Объекты из JNI ссылок в native методах
- Объекты, на которые ссылается «не мусор»
Можно выделить 2 базовых алгоритма сборки мусора:
- Mark-and-sweep: помечает живые объекты, обходя дерево зависимостей, выметает мусор.
- Stop-and-copy — копирует живые объекты в специальное место, свободное место (мусор + места, образованные при копировании живых объектов) отдает под аллокацию.
Множество живых объектов определено в конкретный момент времени исполнения. При исполнении множество меняется. Чтобы собрать мусор в общем случае нужно остановить потоки, чтобы определить, что является мусором (Stop the world, STW пауза). Отсюда подвисания JVM.
- Инкрементальный: собирать не весь мусор за одну паузу
- Параллельный: собирать мусор во многих потоках
- Одновременный (concurrent) — собирать одновременно с работой программы.
Момент очистки не предписан спецификацией. Гарантируется, что память не закончится при аллоцировании объекта. В крайнем случае, если при создании объекта памяти будет не хватать, сборка мусора осуществится сразу после неудачной попытки создания объекта, а объект будет создан повторно, уже удачно.
Слабая гипотеза о поколениях (выполняется не всегда): Предполагаем, что большинство объектов умирает молодыми, а старые объекты редко ссылаются на молодые.
Поколенный GC — частный вид инкрементального GC. В первую очередь поколенный GC просматривает область Heap’а с молодыми объектами. Объекты, пережившие несколько «чисток», помещаются в область Heap’а со старыми объектами.
8. Manageability & Monitoring
JVM знает про программу все: загруженные классы, живые объекты, все потоки, все исполняемые методы. Этой информацией ведь можно и поделиться с разаработчиком/пользователем.
- JVM Tool Interface (JVM TI): отладчики и профилировщики
- Java Management Beans — инструменты мониторинга запущенных приложений: JConsole, Java Mission Control, AMC, JMX console.
Резюмируя, окинем общим взглядом нашу картинку:
На вход приходит байткод и реализация нативных методов. Затем байт-код может быть скомпилирован в машинный код и метаданые или отправлен непосредственно на исполнение.
Далее классы грузятся. При исполнении код, который не был скомпилирован, отправляется на интерпретацию или компилязцию в JIT. Все объекты затем отправляются на исполнение по своим тредам, периодически подвергаются чистке. Все это наблюдается инструментами мониторинга через мета-информацию.
Реализации JVM
Совместимы с Java SE спецификацией:
- Oracle HotSpot
- IBM J9
- Excelsior JET
- Azul (HotSpot-based, но свой GC)
- SAP, RedHat (HotSpot-based под свои платформы)