Верификация байт кода java

JVM: краткий курс общей анатомии

Существуют разные реализации JVM. Докладчики занимаются разработкой HotSpot JVM и Excelcior Jet. В докладе будут освещены аспекты, характерные для любой JVM.

Пост будет интересен желающим поверхностно разобраться в кишках Java, не углубляяся в что-то совсем низкоуровневое. Рекомендую посмотреть доклад, на студенческом дне JPoint 2016 он был одним из самых интересных и живых. Но если вы не располагаете часом времени, вы можете ознакомиться с сокращенным пересказом выступления здесь.

После прочтения вы получите представление об обработке Java-машиной ваших классов, о методах их исполнения, о сборке мусора.
Приятного прочтения.

JVM: предисловие.

У каждой программы, работающей на JVM есть main-метод, который находится в одном из классов из classpath. У веб-приложений нет main-метода. В веб приложениях программа для JVM — сам сервер.

Classpath — список директорий и архивов (.jar)

В java есть native-методы — методы, использующие код из нативных для платформы библиотек.

Screen Shot 2018-04-21 at 18.57.34

1. Java Class file и Byte-код

Один класc исходного кода соответствует одному файлу .class после трансляции в Java байт-код. Структура у байт-кода следующая:

  1. Сначала прописывается Constant Pool: числа, строки, указатели на классы, методы, поля.
  2. Затем прописывается описание класса: имя, модификаторы, суперкласс, суперинтерфейсы, поля, методы, атрибуты. Поля и методы тоже имеют атрибуты (например, атрибут static).
  • Массива инструкций
  • Стека операндов инструкций метода
  • Массива локальных переменных
Читайте также:  Which php uses apache

Рассмотрим пример работы команд из байткода:

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 идентифицируются по паре имя+загрузивший класс загрузчик.

Итак, как же происходит процесс загрузки классов?

  1. Читается .class файл, проверяется корректность формата (может выбросить ClassFormatError)
  2. Создается рантайм представление класса в выделенной области памяти (Meta Space)
  3. Грузятся суперкласс и суперинтерфейсы (Если не вышло загрузить, сам класс не будет считаться загруженным)
  4. Класс становится доступным JVM для анализа (методы, поля, атрибуты)

3b. Линковка

Прежде чем класс будет готов к исполнению, требуется произвести ряд приготовлений:

  1. Изначально поля, методы классы представлены в виде текста. Требуется заполучить какое-то внутреннее представление ссылок.
  2. Осуществить верификацию байт-кода

Задача: Что произойдет при исполнении байт-кода:

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

Если попытаться запустить этот код с отключенной верификацией:

, то в рантайме будет получена java.lang.StackoverflowError.

Исполнение верифицированного кода позволяет JVM исполнять код очень эффективно. Верифицированный код не может сломать JVM. Многие операции маппятся на машинные команды 1:1.

  • Вызов статического инициализатора класса (присваивание значений). Должна произойти до того, как класс будет готов к исполнению.
  • Случается при вызове: (создания объекта через new, доступа до статического поля или вызова статического метода)
  • Провоцирует инциализацию супер-класса и супер-интерфейсов с default-методами.

4. Исполнение байт-кода (Execution Engine):

Байт-код может исполняться двумя способами:

Интерпретатор работает следующим образом:

  1. Извлекает номер инструкции.
  2. В зависимости от необходимых операндов, их типа и количества, выполняем их загрузку. Выполняем операции из байт кода с этими операциями.
  3. Повторяем 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, находит обработчик для этого исключения и передает управление этому обработчику.

Рассмотрим проблему синхронизации потоков. Сложности:

  1. Компилятор может переставлять код для оптимизаций.
  2. Сам процессор при исполнении машинные инструкций может осуществлять перестановочные оптимизации.

Объявление переменной как 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) являются:

  1. Объекты в статических полях классов
  2. Объекты из фреймов всех методов на stacktrace’ах всех Java потоков
  3. Объекты из JNI ссылок в native методах
  4. Объекты, на которые ссылается «не мусор»

Можно выделить 2 базовых алгоритма сборки мусора:

  • Mark-and-sweep: помечает живые объекты, обходя дерево зависимостей, выметает мусор.
  • Stop-and-copy — копирует живые объекты в специальное место, свободное место (мусор + места, образованные при копировании живых объектов) отдает под аллокацию.

Множество живых объектов определено в конкретный момент времени исполнения. При исполнении множество меняется. Чтобы собрать мусор в общем случае нужно остановить потоки, чтобы определить, что является мусором (Stop the world, STW пауза). Отсюда подвисания JVM.

  1. Инкрементальный: собирать не весь мусор за одну паузу
  2. Параллельный: собирать мусор во многих потоках
  3. Одновременный (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.

Резюмируя, окинем общим взглядом нашу картинку:

Screen Shot 2018-04-21 at 18.57.34

На вход приходит байткод и реализация нативных методов. Затем байт-код может быть скомпилирован в машинный код и метаданые или отправлен непосредственно на исполнение.

Далее классы грузятся. При исполнении код, который не был скомпилирован, отправляется на интерпретацию или компилязцию в JIT. Все объекты затем отправляются на исполнение по своим тредам, периодически подвергаются чистке. Все это наблюдается инструментами мониторинга через мета-информацию.

Реализации JVM

Совместимы с Java SE спецификацией:

  • Oracle HotSpot
  • IBM J9
  • Excelsior JET
  • Azul (HotSpot-based, но свой GC)
  • SAP, RedHat (HotSpot-based под свои платформы)

Источник

Оцените статью