Статья

Классы и их загрузка в Java

Любое сколько-нибудь нетривиальное приложение может аварийно завершиться с исключением ClassNotFoundException или NoClassDefFoundError. Однако, многие разработчики не знают, что означают эти исключения, чем они различаются и даже почему они вообще возникают.
В этой статье мы сосредоточимся на нюансах платформы, которые лежат в основе таких проблем.
Начнем с обзора загрузки классов — процесса, в ходе которого JVM находит и активирует новый тип, чтобы использовать его в работающем приложении. Центральное место в этом материале занимают объекты Class, которые представляют типы в JVM.
Класс — это фундаментальная единица программного кода, которую воспринимает и выполняет платформа Java.
Файл с расширением .class определяет тип для JVM вместе со всеми полями, методами, информацией о наследовании, аннотациями и прочими метаданными. Формат файла класса хорошо описан в стандартах, и его должен придерживаться любой язык, программы на котором рассчитаны на запуск на JVM.

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

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

Начнем с простого примера:
Class<?> cl = Class forName("MyTestClass");
Этот блок кода загружает класс MyTestClass в текущий процесс. С точки зрения JVM для этого необходимо выполнить ряд действий Сначала нужно найти файл класса, который соответствует имени MyTestClass, а затем выполнить разрешение содержащегося в нем класса. Эти шаги выполняются в низкоуровневом коде — в HotSpot соответствующий native метод называется JVM_DefineClass().

В общих чертах процедура состоит в том, что низкоуровневый код строит внутреннее представление JVM, называемое klass-объектом (который не является объектом Java). Затем, при условии, что klass-объект удалось извлечь из файла класса, JVM конструирует совместимый с Java аналог klass-объекта, который передается обратно коду Java как объект Class.

После этого объект Class, представляющий тип, становится доступным для действующей системы, и можно создавать его новые экземпляры. В предыдущем примере в переменной cl в итоге сохраняется объект Class, соответствующий типу MyTestClass. В ней не может храниться klass-объект, потому что это внутренний объект JVM, а не объект Java

Загрузка и компоновка

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

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

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

Получив поток данных, его нужно разобрать и убедиться, что он содержит действительную структуру файла класса (иногда это называется проверкой формата). Если все в порядке, создается потенциальный klass-объект, или klass-кандидат. На этой фазе, пока klass-кандидат заполняется содержимым, происходят некоторые базовые проверки (к примеру: может ли загружаемый класс обратиться к своему объявленному надклассу? не пытается ли он переопределить final методы?).

Однако по окончании процесса загрузки структура данных, соответствующая классу, еще не подходит для того, чтобы другой код ее использовал. В частности, еще нет полностью функционального klass-объекта.

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

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

Верификация

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

Перечислим несколько основных проверок:
1) Убедиться, что байт-код не пытается выполнять недопустимые или вредо- носные операции со стеком
2) Убедиться, что каждой инструкции ветвления (например, из if или цикла) соответствует правильная инструкция, к которой происходит переход
3) Проверить, что методы вызываются с правильным количеством параметров правильных статических типов
4) Проверить, что локальным переменным присваиваются только значения подходящих типов
5) Проверить, что для каждого исключения, которое может возбудиться, существует действительный обработчик catch
Эти проверки выполняются по нескольким соображениям, в том числе и ради быстродействия. Они позволяют пропускать некоторые проверки времени выполнения, отчего интерпретируемый код выполняется быстрее. Некоторые из них также упрощают компиляцию байт-кода в машинный код на стадии выполнения (речь идет о JIT-компиляции)

Подготовка

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

Разрешение

Разрешение (resolution) — это этап компоновки, на котором JVM проверяет, что надтип компонуемого класса (и все реализуемые им интерфейсы) уже скомпонованы, а если нет, то они компонуются до того, как продолжается компоновка класса. Это может вызвать рекурсивную компоновку для всех новых типов, которые ранее еще не встречались.

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

Инициализация

На этой последней фазе инициализируются все статические переменные и выполняются все статические блоки инициализации. Это важный момент, потому что только теперь JVM наконец-то начинает выполнять код из свежезагруженного класса.
Когда этот шаг завершится, класс полностью загружен и готов к работе. Класс доступен для среды выполнения, и можно создавать его экземпляры. Все дальнейшие операции загрузки классов, в которых задействован этот класс, теперь будут воспринимать его как загруженный и доступный.
Итак, конечный результат процесса компоновки и загрузки — объект Class, который представляет только что загруженный и скомпонованный тип. Теперь он полностью функционален в JVM, хотя по соображениям быстродействия некоторые компоненты объекта Class инициализируются только по необходимости.
Объекты Class — обычные объекты Java. Они обитают в куче Java, как и любые другие объекты.
В следующих частях мы рассмотрим загрузчики классов, а также проведем анализ файлов .class
java