Как на самом деле работает Java ClassLoader system? (с картинками) — Часть 1/3, Загрузка
Когда я впервые погрузился в мир загрузчиков классов Java, это было ответом на любопытный вопрос. Популярные источники (Wikipedia, Baeldung, DZone) содержат устаревшую, иногда противоречащую друг другу информацию, и это несоответствие послужило толчком для написания этой статьи — поиска ясности в лабиринте ClassLoader System.
Будучи разработчиком Java, вы наверняка сталкивались с ClassNotFoundException или NoClassDefFoundError — загадочными сообщениями, которые на мгновение останавливают наш процесс разработки. Класс не найден — понятно по названию, но не найден где? Кто и как его ищет, куда доставляет?
Попробуем погрузиться в эту тему вместе, отбросив сложности, в стиле небольших диаграмм. Полная картина того, о чем пойдет речь в этой серии статей:
Предложение
Прежде чем перейти к рассмотрению механизмов работы загрузчиков классов, важно подчеркнуть одну деталь:
Не существует «универсальной» конструкции виртуальной машины Java.
Спецификация JVM от компании Oracle, устанавливает ожидаемые компоненты и поведение для любой JVM. Однако, эта спецификация не предписывает конкретный подход к реализации этих компонентов, что приводит к тому, что на практике существует целый ряд уникальных реализаций, включая, но не ограничиваясь HotSpot/OpenJDK, Eclipse OpenJ9, GraalVM (основанной на OpenJDK). Каждая из реализаций следует спецификации, но при этом может отличаться по ряду аспектов, как производительность, стратегии сборки мусора и, как несложно предположить, детали процесса загрузки классов.
Отдельный момент, требующий внимания:
Виртуальные машины Java платформо-зависимы.
JVM для Windows OS не идентична JVM для Linux. «Но подождите», — скажете вы, — «я думал, что Java — это все о том, чтобы написать один раз, выполнить везде — независимость от платформы!». Совершенно верно. Однако независимость Java от платформы не означает, что JVM также независима от платформы. Совсем наоборот.
В большинстве статей на эту тему при описании не указывается ни конкретная версия Java, ни описанная реализация VM, что приводит к недопониманию, поскольку JVM развивается и изменяется с каждой версией. Сейчас лето 2023 года, и мир Java находится в предвкушении 21-й версии, но пока она не вышла, мы будем ориентироваться на Java 20, опираясь на саму спецификацию JVM от Oracle, и документацию Oracle Java SE для удобства.
Учитывая это, вернемся к нашей системе загрузчиков
Начиная с основ
Говоря упрощенно, при запуске приложения JVM загружает в память необходимые классы, проверяет байткод, выделяет необходимые ресурсы и, наконец, выполняет код, преобразуя байткод в инструкции машинного языка, понятные конечной машине.
Но что на самом деле означает это JVM загружает? Спецификация Java SE приводит следующий комментарий:
Loading refers to the process of finding the binary form of a class or interface with a particular name, perhaps by computing it on the fly, but more typically by retrieving a binary representation previously computed from source code by a Java compiler, and constructing, from that binary form, a Class object to represent the class or interface.
Формулируя более простым языком, когда мы говорим о «загрузке класса», мы имеем в виду:
Процесс поиска соответствующего файла .class на диске, чтения его содержимого и передачи его в среду выполнения JVM, которая представляет собой определенную часть памяти машины, предназначенную для выполнения вашего приложения.
Погружаясь глубже
В действительности, система загрузчиков классов не просто находит классы — она обеспечивает целостность и безопасность Java-приложения, соблюдая правила бинарной структуры и пространства имен среды выполнения Java.
Стоит добавить, что она обеспечивает гибкость загрузки классов из различных источников — не только из локальной файловой системы, но и по сети, из базы данных или даже сгенерированных налету.
В этой статье мы углубимся в процесс загрузки, но для полного понимания стоит упомянуть, что этапа всего 3:
Загрузка (Loading) — Начальная фаза
Процесс начинается с того, что загрузчик класса (далее, ClassLoader) получает задание найти определенный класс, что может быть инициировано самой JVM, или вызвано командой в вашем коде. Задача же здесь заключается в том, чтобы взять полное имя класса (например, java.lang.String ) и получить соответствующий файл класса (например, String.class ) из его местоположения на диске —> в память JVM.
Здесь важно понимать, что подсистема Загрузки — это не одиночный акт, а иерархическая эстафета. Каждый ClassLoader, родительский и дочерний, работает совместно, передавая эстафету ответственности до тех пор, пока нужный класс в конце концов не будет загружен.
Основополагающими принципами, определяющими этот скоординированный процесс загрузки классов, являются (полагайся на диаграмму для понимания):
- Видимость (Visibility): Дочерний ClassLoader может видеть классы, загруженные его родителем, но не наоборот, что обеспечивает инкапсуляцию;
- Уникальность (Uniqueness): Класс, загруженный родителем, не будет повторно загружен его дочерним классом, что повышает эффективность;
- Иерархия делегирования (Delegation Hierarchy): Application ClassLoader (дочерний) передает запрос на загрузку класса родителям, загрузчикам Platform и Bootstrap. Если они не могут найти класс, то запрос передается обратно по цепочке, пока класс не будет найден, или не выкинут соответсвующий ClassNotFoundException
Рассмотрим каждый загрузчик подробнее.
Boostrap ClassLoader
Старейший представитель семейства, Bootstrap ClassLoader, отвечает за загрузку основных библиотек Java, расположенных в java.base модуле ( java.lang , java.util и т.д.), необходимых для старта JVM.
Обратя внимания на диаграмму можно заметить, что другие загрузчики классов написаны на Java (объекты java.lang.ClassLoader), что означает — их также необходимо загрузить в JVM! Эту задачу также выполняет Bootstrap ClassLoader.
Во многих ресурсах Bootstrap ClassLoader описывается как «родитель» остальных загрузчиков классов. В действительность, это означает лишь логическое наследование, а не наследование Java, поскольку Bootstrap загрузчик написан на native коде, и встроен в виртуальную машину.
Убедимся на практике, что никаких Java загрузчиков, выше самих java.lang загрузчиков нет:
jshell> System.out.println(java.lang.ClassLoader.class.getClassLoader()); null
Bootstrap ClassLoader также является единственным загрузчиком, явно описанным в спецификации Oracle. Остальные зовутся «User-defined», и оставляются на рассмотрение конкретных вендоров вирутальных машин.
Platform ClassLoader
На мой взгляд, самый противоречивый.
Документация Java SE 20 говорит о нем следующее:
The platform class loader is responsible for loading the platform classes. Platform classes include Java SE platform APIs, their implementation classes, and JDK-specific run-time classes that are defined by the platform class loader or its ancestors. The platform class loader can be used as the parent of a ClassLoader instance.
Но что отличает классы платформы от основных классов, загружаемых Bootstrap загрузчиком? Посмотрим, что он на самом деле загружает:
jshell> ClassLoader.getPlatformClassLoader().getDefinedPackages(); $1 ==> Package[0] < >// empty
Получается, что в пустой Java-программе — абсолютно ничего! Теперь попробуем явно использовать класс из какого-нибудь стандартного пакета:
jshell> java.sql.Connection.class.getClassLoader() $2 ==> jdk.internal.loader.ClassLoaders$PlatformClassLoader@27fa135a jshell> ClassLoader.getPlatformClassLoader().getDefinedPackages() $3 ==> Package[1]
Получается, проще говоря, Bootstrap загружает основные классы необходимые для запуска JVM, а Platform — публичные типы системных модулей, которые могут понадобиться. Конкретного разделения необходимых/возможных модулей Java SE я не нашел, но задал соответсвующий вопрос на StackOverFlow, ссылка для любознательных 🙂
В этом контексте также важно отметить, что во многих источниках (Wiki, Baeldung, последнее обновление 2022, 2023 соотсветственно) Platform ClassLoader обзывают Extension ClassLoader, что на деле не совсем так.
Правильнее было бы утверждать, что Platform ClassLoader пришел на смену Extension ClassLoader, который искал в $JAVA_HOME/lib/ext , и использовался в Java 8 и более ранних версиях. Это изменение произошло с появлением Системы Модулей (JEP-261):
The extension class loader is no longer an instance of URLClassLoader but, rather, of an internal class. It no longer loads classes via the extension mechanism, which was removed by JEP 220. It does, however, define selected Java SE and JDK modules, about which more below. In its new role this loader is known as the platform class loader, it is available via the new ClassLoader::getPlatformClassLoader method, and it will be required by the Java SE Platform API Specification.
Application ClassLoader
Application ClassLoader, также известный как системный загрузчик классов, пожалуй, самый user-friendly из всех. Именно этот загрузчик подгружает ваши собственные реализации и библиотеки зависимостей, которые вы передали JVM (явно или неявно) при старте приложения в качестве -classpath (-cp) параметра.
public class HabrTeller < public static void main(String[] args) < // jdk.internal.loader.ClassLoaders$AppClassLoader@251a69d7 System.out.print(HabrTeller.class.getClassLoader()); >>
С точки зрения иерархии, Application загрузчик является порождением Platform загрузчика, и в документации о нем говорится следующее:
This is the default delegation parent for new java.lang.ClassLoader instances, and is typically the class loader used to start the application.
ClassLoader.getSystemClassLoader() method is first invoked early in the runtime’s startup sequence, at which point it creates the system class loader. This class loader will be the context class loader for the main application thread (for example, the thread that invokes the main method of the main class).
Резюмирая, именно этот загрузчик является родителем основного потока приложения, и будет являться родителем ваших собственных загрузчиков классов, если вы решите реализовать один.
В дополнение к трем рассмотренным, основным загрузчикам, вы можете создавать свои собственные, пользовательские загрузчики классов, непосредственно в своих Java программах, позволяя обеспечить независимость приложений (чему способствует модель делегирования загрузчиков):
В серверах типа Tomcat, этот подход используется для обеспечения независимой работы различных Web-приложений и корпоративных решений, даже если они размещены на одном сервере. Из популярных открытых примеров, мне удалось найти несколько, для дополнительного ознакомления:
- Tomcat’s Catalina WebappLoader
- Spring Boot’s LaunchedURLClassLoader
Почитать подробнее про обоснование создания собственных, и систему загрузчиков Tomcat как таковую, можно почитать здесь.
Статей по созданию собственных загрузчиков классов написано уже немало, и целью этой статьи служит скорее теория, а не практика, но при должном интересе — можем написать обновленную, отдельную версию.
На этом этапе подпроцесс загрузки подходит к концу: результатом является двоичное представление класса или типа интерфейса в JVM. Однако на этом этапе класс еще не готов к использованию, и мы рассмотрим следующий этап — Linking — во второй части этой серии.
Спасибо, что дочитали до конца! Надеюсь, вы почерпнули что-то интересное. Данный материал не претендует на звание single source of truth, но мы действительно постарались ссылаться на официальную документацию и спецификацию языка, опуская субьективное и неофициальное.
Загрузка классов в Java. Теория
Одной из основных особенностей платформы Java является модель динамической загрузки классов, которая позволяет загружать исполняемый код в JRE не перезагружая основое приложение. Такая особенность широко используется в серверах приложений, получивших последнее время высокую популярность.
В статье рассмотрены базовые понятия, аспекты и принципы модели динамической загрузки кода. В следующей статье будет рассмотрена реализация собственного загрузчика классов, как основного механизма приложения с плагино-модульной архитектурой.
Введение
Любой класс (экземпляр класса java.lang.Class в среде и .class файл в файловой системе), используемый в среде исполнения был так или иначе загружен каким-либо загрузчиком в Java. Для того, чтобы получить загрузчик, которым был загружен класс А, необходимо воспользоваться методом A.class.getClassLoader().
Классы загружаются по мере надобности, за небольшим исключением. Некоторые базовые классы из rt.jar (java.lang.* в частности) загружаются при старте приложения. Классы расширений ($JAVA_HOME/lib/ext), пользовательские и большинство системных классов загружаются по мере их использования.
Виды загрузчиков
Различают 3-и вида загрузчиков в Java. Это — базовый загрузчик (bootstrap), системный загрузчик (System Classloader), загрузчик расширений (Extension Classloader).
Bootstrap — реализован на уровне JVM и не имеет обратной связи со средой исполнения. Данным загрузчиком загружаются классы из директории $JAVA_HOME/lib. Т.е. всеми любимый rt.jar загружается именно базовым загрузчиком. Поэтому, попытка получения загрузчика у классов java.* всегда заканчиватся null’ом. Это объясняется тем, что все базовые классы загружены базовым загрузчиком, доступа к которому из управляемой среды нет.
Управлять загрузкой базовых классов можно с помощью ключа -Xbootclasspath, который позволяет переопределять наборы базовых классов.
System Classloader — системный загрузчик, реализованный уже на уровне JRE. В Sun JRE — это класс sun.misc.Launcher$AppClassLoader. Этим загрузчиком загружаются классы, пути к которым указаны в переменной окружения CLASSPATH.
Управлять загрузкой системных классов можно с помощью ключа -classpath или системной опцией java.class.path.
Extension Classloader — загрузчик расширений. Данный загрузчик загружает классы из директории $JAVA_HOME/lib/ext. В Sun JRE — это класс sun.misc.Launcher$ExtClassLoader.
Управлять загрузкой расширений можно с помощью системной опции java.ext.dirs.
Понятия
Различают текущий загрузчик (Current Classloader) и загрузчик контекста (Context Classloader).
Current Classloader — это загрузчик класса, код которого в данный момент исполняется. Текущий загрузчик используется по умолчанию для загрузки классов в процессе исполнения. В часности, при использовании метода Class.forName(«»)/ClassLoader.loadClass(«») или при любой декларации класса, ранее не загруженного.
Context Classloader — загрузчик контекста текущего потока. Получить и установить данный загрузчик можно с помощью методов Thread.getContextClassLoader()/Thread.setContextClassLoader(). Загрузчик контекста устанавливается автоматически для каждого нового потока. При этом, используется загрузчик родительского потока.
Модель делегирования загрузки
Начиная с версии Java 2 Platform, Standard Edition, v1.2 загрузчики классов образуют иерархию. Корневым является базовый (у него предка нет). Все остальные загрузчики при инициализации инстанциируют ссылку на родительский загрузчик. Такая иерархия необходима для модели делегирования загрузки. В общем случа, иерархия выглядит следующим образом.
Право загрузки класса рекурсивно делегируется от самого нижнего загрузчика в иерархии к самому верхнему. Такой подход позволяет загружать классы тем загрузчиком, который максимально близко находится к базовому. Так достигается максимальная область видимости классов. Под областью видимости подразумевается следующее. Каждый загрузчик ведет учет классов, которые были им загружены. Множество этих классов и назвается областью видимости.
Рассмотрим процесс загрузки более детально. Пусть в систем исполнения встретилась декларация переменной пользовательского класс Student.
1) Системный загрузчик попытается поискать в кеше класс Student.
_1.1) Если класс найден, загрузка окончена.
_1.2) Если класс не найден, загрузка делегируется загрузчику расширений.
2) Загрузчик расширений попытается поискать в кеше класс Student.
_2.1) Если класс найден, загрузка окончена.
_2.2) Если класс не найден, загрузка делегируется базовому загрузчику.
3) Базовый загрузчик попытается поискать в кеше класс Student.
_3.1) Если класс найден, загрузка окончена.
_3.2) Если класс не найден, базовый загрузчик попытается его загрузить.
__3.2.1) Если загрузка прошла успешно, она закончена 😉
__3.2.2) Иначе управление предается загрузчику раширений.
_3.3) Загрузчик расширений пытается загрузить класс.
__3.3.1) Если загрузка прошла успешно, она закончена 😉
__3.3.2) Иначе управление предается системному загрузчику.
_3.4) Системный загрузчик пытается загрузить класс.
__3.4.1) Если загрузка прошла успешно, она закончена 😉
__3.4.2) Иначе генерируется исключение java.lang.ClassNotFoundException.
Если в системе присутствуют пользовательские загрузчики, они должны
а) расширять класс java.lang.ClassLoader;
б) поддерживать модель динамической загрузки.
Inside
Запустим простейшее приложениие с ключем -verbose:class.
public class A
public class B extends A
public class C extends B
public class Main
public static void main( String args[]) C c = new C();
B b = new B();
A a = new A();
>
>
* This source code was highlighted with Source Code Highlighter .
Вывод показывает, что классы были загружены не в том порядке в котором были использованы. Это обусловлено наследованием.
[Loaded Main from file:/C:/devel/CL/bin/]
[Loaded A from file:/C:/devel/CL/bin/]
[Loaded B from file:/C:/devel/CL/bin/]
[Loaded C from file:/C:/devel/CL/bin/]
Загрузка классов, ClassLoader
В статье речь пойдет о классе java.lang.ClassLoader. Это самый необычный модуль не только в мире Java, но и среди большинства компилируемых языков. ClassLoader — самый низкоуровневый, «глубинный» механизм Java, позволяющий вмешиваться практически в ядро Java-машины, причем оставаясь в рамках программирования на Java.
Структуру организации загрузки java-кода при помощи ClassLoader и пример его использования можно увидеть на странице pluggable решений.
ClassLoader обеспечивает загрузку классов Java. Если говорить точнее, обеспечивают загрузку его наследники, конкретные загрузчики классов – сам ClassLoader абстрактен. Каждый раз, когда загружается какой-либо .class-файл, например, вследствие обращения к конструктору или статическому методу соответствующего класса – на самом деле это действие выполняет один из наследников класса ClassLoader.
Существует стандартный вариант реализации ClassLoader – так называемый системный загрузчик классов. Этот загрузчик используется по умолчанию при запуске приложений Java командой:
java Имя_главного_класса
Системный загрузчик классов реализует стандартный алгоритм загрузки из каталогов и JAR-файлов, перечисленных в переменной CLASSPATH (переменной среды либо параметре «-cp» утилиты «java»), а также из JAR-файлов, содержащих стандартные системные классы вроде java.lang.String и входящих в любой комплект поставки Java.
Одна из замечательных особенностей языка Java заключается в том, что можно реализовать свой собственный загрузчик классов – наследник ClassLoader – и использовать его вместо системного.
Наиболее популярный пример применения этой техники – Java-апплеты. Классы Java-апплетов, а также все классы, которыми они пользуются, автоматически загружаются с веб-сервера благодаря специальному загрузчику классов, реализованному «внутри» браузера.
Реализуя наследников ClassLoader, можно полностью контролировать процесс загрузки абсолютно всех Java-классов. Можно загружать их из любого источника, к примеру, из собственной системы каталогов, не отраженной в CLASSPATH, из базы данных или из Internet. Можно предоставить загрузку стандартных библиотечных классов системному загрузчику, но при этом протоколировать факт обращения к ним. При желании можно даже сконструировать байт-код класса в памяти и после этого работать с ним, как с нормальным классом, загруженным из «добропорядочного» .class-файла. Среди компилируемых языков подобные возможности встречаются разве что в ассемблере.
Единственное, что нельзя сделать с помощью ClassLoader‘a – это создать новый класс, не располагая его байт-кодом. Для создания нового класса необходимо получить корректный байт-код класса (образ в памяти обычного .class-файла) в виде массива byte[]. Затем его нужно передать специальному стандартному методу ClassLoader.defineClass, который «превратит» его в готовый класс – объект типа Class.
В статье рассмотрен весь этот механизм и с его помощью показано решение практической задачи – динамическая подгрузка изменившихся версий .class-файлов без перезапуска главной Java-программы.
Технология загрузки Java классов
Основной способ работы с классом ClassLoader – это реализация наследников от него. Прежде чем переходить к рассмотрению этой техники, мы немного поговорим о том, как Java использует загрузчики классов.
Как уже было отмечено, в системе всегда существует по крайней мере один готовый наследник ClassLoader – системный загрузчик. Его всегда можно получить с помощью вызова ClassLoader.getSystemClassLoader() – статического метода класса ClassLoader, объявленного следующим образом:
public static ClassLoader getSystemClassLoader()
Когда запускается приложение Java с помощью стандартной команды:
java Имя_главного_класса
виртуальная машина Java первым делом создает системный загрузчик, загружает с его помощью .class-файла вашего главного класса и вызывает статический метод вашего класса, соответствующий объявлению:
public static void main(String[] argv)
(или же сообщает об ошибке, не обнаружив такого метода).
Java – язык с отложенной загрузкой кода. Первоначально загружается только один класс – тот, который передан в качестве параметра утилите «java». Как только код этого класса обращается к какому-то другому классу (любым способом: вызовом конструктора, обращением к статическому методу или полю), загружается другой класс. По мере выполнения кода, загружаются всё новые и новые классы. Ни один класс не загружается до тех пор, пока в нем не возникнет реальная потребность. Такое поведение заложено в стандартный системный загрузчик.
Главный класс приложения всегда загружается системным загрузчиком. А какие загрузчики будут использоваться для загрузки всех прочих классов?
В Java поддерживается понятие «текущего» загрузчика классов. Текущий загрузчик – это тот загрузчик классов (экземпляр некоторого наследника ClassLoader), который загрузил класс, код которого исполняется в данный момент. Каждый класс «помнит» загрузивший его загрузчик. Загрузчик, загрузивший некоторый класс, всегда можно узнать, вызвав метод getClassLoader у объекта типа Class, соответствующего данному классу :
public ClassLoader getClassLoader()
Например, если мы находимся внутри некоторого метода класса TestClass, то вызов TestClass.class.getClassLoader() вернет ссылку на загрузчик, загрузивший этот класс, т.е. загрузивший тот самый байт-код, который выполняет данный вызов.
Когда возникает необходимость загрузить другой класс вследствие обращения к его конструктору, статическому методу или полю, виртуальная Java-машина автоматически обращается к текущему загрузчику классов, о котором «помнит» текущий исполняемый класс. При этом другой класс также «запоминает» этот загрузчик в качестве текущего. Иначе говоря, текущий загрузчик, загрузивший данный класс, по умолчанию наследуется всеми классами, прямо или косвенно вызываемыми из данного.
Так как главный класс приложения обычно загружается системным загрузчиком, то он же используется и для загрузки всех остальных классов, необходимых приложению. В случае Java-апплета браузер загружает главный класс апплета своим собственным загрузчиком (умеющим читать классы с веб-сервера); в результате тот же самый загрузчик используется для загрузки всех вспомогательных классов апплета.
На самом деле наследование текущего загрузчика – лишь поведение по умолчанию. Загрузчик классов можно написать и так, что он не будет наследоваться для некоторых классов. В тот момент, когда к загрузчику приходит запрос «выдать класс по заданному имени», он может передать этот запрос какому-нибудь другому загрузчику. Тогда данный класс и другие классы, вызываемые из него, будут загружаться новым загрузчиком. Например, специальный загрузчик, реализуемый браузером для загрузки классов апплетов, вполне может «передать свои полномочия» системному загрузчику, когда дело касается стандартного системного класса вроде java.lang.String.
Стандартный способ загрузить некоторый класс загрузчиком, отличным от текущего – специальная версия статического метода Class.forName:
public static Class forName(String name, boolean initialize, ClassLoader loader);
В качестве name передается полное имя класса (с указанием пакета), в качестве loader – требуемый загрузчик. Не столь очевидный (и не столь важный) параметр initialize управляет инициализацией класса, т.е. установкой значений всех static-полей класса и исполнением кода в секциях:
static
Если значение initialize равно true, то инициализация происходит немедленно, в противном случае – откладывается до первого обращения к любому конструктору, статическому методу или полю этого класса.
Более простая форма метода Class.forName
public static Class forName(String className);
всегда использует текущий загрузчик классов. На самом деле, вызов
Class.forName(name)
Class.forName(name, true, Текущий_класс.class.getClassLoader());
где Текущий_класс – имя класса, внутри которого содержится данный вызов.
Загрузив класс, можно создать его экземпляр или вызвать статический метод средствами отражений. Дальше этот класс может обычными средствами языка Java обращаться к другим классам – для них будет вызван тот же самый загрузчик loader (либо какие-то другие загрузчики, если реализация loader в какой-то момент «решит» передать управление другому загрузчику). Простейший пример:
Class class_= Class.forName("Имя_класса", true, нестандартный_загрузчик); class_.newInstance(); // создается экземпляр класса
Методы класса ClassLoader
Полный список методов можно найти в документации к ClassLoader‘у? В данной статье рассмотрены наиболее важные из них.
Один из методов ClassLoader был уже рассмотрен. Это статический метод, возвращающий ссылку на стандартный системный загрузчик.
public static ClassLoader getSystemClassLoader()
Среди прочих методов самый «бросающийся в глаза»:
public Class loadClass(String name)
Этот метод загружает класс с заданным именем. На самом деле его реализация сводится к вызову другого protected-метода:
protected synchronized Class loadClass(String name, boolean resolve);
Как можно догадаться, переопределение этого protected-метода – один из основных способов для реализации собственного загрузчика классов.
Не понятно, почему метод loadClass(String name) объявлен как public. Ведь уже существует стандартный способ загрузки класса по имени, с помощью произвольного загрузчика – вызов
Class.forName("Имя_класса",true,loader);
Классы Class и ClassLoader расположены в общем пакете – так что метод loadClass(String name) вполне мог бы быть protected. Это не помешало бы методу Class.forName его вызвать. Может быть, раз уж loadClass – public-метод, то вместо Class.forName(«Имя_класса», true, loader) можно пользоваться прямым обращением loader.loadClass(«Имя_класса»)?
Судя по всему, следует все же всегда использовать вызов Class.forName. Хотя это совершенно не очевидно из документации. Дальше в статье показано, что метод Class.forName выполняет с классом некоторые дополнительные действия, в частности, кэширует его, обеспечивая, в отличие от прямого вызова loadClass, стабильную работу даже при недостаточно аккуратной реализации загрузчика loader.
Имеется также группа public методов, предназначенных для загрузки ресурсов:
public URL getResource(String name); public InputStream getResourceAsStream(String name); public final Enumeration getResources(String name); public static URL getSystemResource(String name); static InputStream getSystemResourceAsStream(String name); static Enumeration getSystemResources(String name);
Методы Class.getResource и Class.getResourceAsStream обращаются к соответствующим методам текущего загрузчика, загрузившего данный класс. Главное отличие методов работы с ресурсами класса ClassLoader – абсолютные пути. Путь к ресурсу отсчитывается не от каталога, содержащего данный class-файл (как в случае Class.getResource и Class.getResourceAsStream), а от одного из каталогов, указанных в переменной CLASSPATH.
Следует обратить внимание, что названия методов getSystemResource, getSystemResourceAsStream, getSystemResources не означают, что загружаются какие-то особые «системные» ресурсы. Слово «System» в этих названиях говорит о том, что для загрузки ресурсов будет в любом случае использоваться стандартный системный загрузчик.
Любая реализация ClassLoader должна обеспечивать работоспособность перечисленных методов.
Перезагрузка классов «на лету»
Обычно в книгах по языку Java реализацию ClassLoader рассматривают на примере загрузки .class-файлов из какого-либо нестандартного источника, например, каталога, не указанного в переменной CLASSPATH. Такой пример достаточно прост, но, не очень интересен. В большинстве ситуаций поиск .class-файлов в путях, перечисленных в CLASSPATH, – вполне нормальное решение. Загрузка же из принципиально иных источников вряд ли будет полезна вне контекста куда более сложной задачи, включающей такие вопросы, как политика безопасности или кэширование загруженных файлов на локальном диске.
Попробуем решить другую задачу. Предположим, разрабатывается большая программа на Java. По каким-либо причинам эту программу нежелательно часто перезагружать: останавливать и запускать снова. Например, это может быть сложная серверная программа, каждую секунду обслуживающая многих клиентов, для которой даже сравнительно кратковременная неработоспособность является критичной. Или просто программа настолько «тяжелая», что полный ее перезапуск требует несколько минут, и часто перезапускать ее крайне неудобно. В то же время программа постоянно развивается. Создаются новые независимые блоки, переписываются и отлаживаются старые. Возможно, программа «умеет» исполнять сторонние классы, разработанные пользователями. В таких условиях возникает естественное желание, чтобы подключение новых классов или новых версий старых классов не требовало полной остановки и перезапуска программы.
Что касается действительно новых классов – тут проблем нет. Можно в любой момент скомпилировать новый класс с новым уникальным именем – даже когда программа уже запущена. Если ваша программа после этого выполнит вызов Class.forName(name) с этим именем (например, в результате автоматического сканирования каталогов поиска CLASSPATH в поисках новых .class-файлов), то этот класс будет успешно подключен, и программа сможет им пользоваться.
Что касается версий .class-файлов – тут все значительно хуже. Однажды обратившись к некоторому классу, стандартный системный загрузчик запомнит его в своем внутреннем кэше и будет всегда использовать именно его. Никакие последующие перекомпиляции .class-файла и даже физическое удаление этого файла не отразятся на работе этого класса. Кроме полного перезапуска программы (т.е. всей виртуальной машины Java), нельзя заставить системный загрузчик «забыть» тот байт-код класса, который он однажды загрузил.
Итак, основная задача – написать загрузчик классов, аналогичный стандартному системному, который, в отличие от него, умел бы «забывать» загруженные ранее версии классов и загружать .class-файлы заново.
Заодно будет решена и более простая традиционная задача – загрузка .class-файлов из собственного списка каталогов поиска. Это ничего не будет стоить, т.к. какая разница – загружать файлы непременно из каталогов CLASSPATH или из каких-либо других. Необходимо учесть, что некоторые (или все) каталоги CLASSPATH могут попасть в наш список.
Для простоты не будем поддерживать в этом загрузчике работу с JAR-архивами. Все-таки JAR предназначен для упаковки достаточно стабильных версий программных модулей, которые вряд ли стоит обновлять настолько часто, что ради этого нежелательно выполнять перезапуск основной Java-программы. В частности, загрузку стандартных библиотечных классов (пакеты java.lang и подобные), которые обычно находятся в JAR-файле, мы возложим на системный загрузчик.
Назовем новый загрузчик DynamicClass-Overloader – «динамический перезагрузчик классов».
Методы загрузчика ClassLoader’а
Итак, необходимо создать наследника абстрактного класса ClassLoader, который умел бы загружать классы из некоторого заданного набора каталогов поиска также, как это делает системный загрузчик для каталогов, перечисленных в переменной CLASSPATH. В отличие от системного загрузчика наш вариант ClassLoader должен уметь «забывать» о загруженных ранее классах. Для этого необходимо реализовать следующие методы ClassLoader’а:
protected synchronized Class loadClass(String name, boolean resolve) throws ClassNotFoundException; protected Class findClass(String name) throws ClassNotFoundException; protected java.net.URL findResource(String name); protected java.util.Enumeration findResources(String name) throws IOException;
Абстрактный класс ClassLoader в действительности предоставляет реализацию для первого метода, loadClass, основанную на двух других protected-методах – findLoadedClass и findClass. Метод findLoadedClass объявлен как final – его переопределять не нужно. Эта реализация проверяет, не был ли загружен класс раньше вызовом «findLoadedClass(name)». Если нет – делает попытку загрузить класс стандартным образом, и если эта попытка терпит неудачу – обращается к методу findClass.
Для решения более традиционной задачи – обеспечения загрузки классов из нестандартного источника – такая реализация вполне подходит. В этом случае было бы достаточно реализовать метод findClass. Но нам необходимо загружать классы из вполне стандартного источника (хотя и нестандартным образом): из набора каталогов, который может соответствовать стандартной переменной CLASSPATH. Значит не нужно первым делом вызывать стандартный загрузчик, полагаясь на то, что для наших имен классов он потерпит неудачу, и loadClass обратится к нашему методу findClass. Нужно реализовать свою версию loadClass, действующую наоборот: вначале пытающуюся загрузить .class-файл самостоятельно и лишь в случае неудачи (скажем, для классов из пакета java.lang, которые обычно упакованы в JAR-архив) обращающуюся к системному загрузчику.
Методы findResource и findResources, подобно findClass, обеспечивают работоспособность public-методов загрузки ресурсов getResource, getResourceAsStream и getResources. Для ресурсов задачу динамической перезагрузки без рестарта программы решать не нужно – они и так всегда загружаются динамически. Поэтому, в отличие от ситуации с findClass, вполне достаточно переопределить методы findResource и findResources. Сделать это необходимо, так как, возможно, придется использовать наш загрузчик с каталогами, неизвестными системному загрузчику, т.е. отличными от каталогов CLASSPATH.
Не будем отвлекаться на реализацию метода findResources. Его использование представляется чересчур экзотичным. Реализуем только findResource – это почти ничего не стоит, и на этом методе основаны все типичные приемы работы с ресурсами (через методы Class.getResource и Class.getResourceAsStream).
Согласно документации loadClass должен просто найти или загрузить указанный класс с помощью findClass, после чего, если параметр resolve содержит true, вызвать для полученного класса protected-метод resolveClass. Что этот метод в точности делает – для нас в данный момент неважно.
Метод findClass должен загрузить байт-код указанного класса (в нашем случае это просто чтение файла), после чего выполнить для полученного массива байтов специальный «магический» метод defineClass:
protected final Class defineClass(String name, byte[] b, int off, int len) throws ClassFormatError;
Это как раз то самое место, где цепочка байтов – образ .class-файла (фрагмент массива b длины len по смещению off) – «чудесным образом» превращается в готовый к использованию класс. Метод defineClass, как и следовало ожидать, реализован в native-коде. Именно он помещает байт-код класса в недра виртуальной машины, где он приобретает вид, пригодный для непосредственного исполнения на конкретной аппаратной платформе, в частности, компилируется в машинный код процессора для более быстрого исполнения (так называемая технология Just-In-Time, сокращенно JIT-компиляция).
Наконец, метод findResource должен просто найти файл, соответствующий данному ресурсу – по тем же правилам, по которым отыскивается файл класса в методе findClass – и вернуть ссылку на него в виде URL.
Системный загрузчик классов не просто загружает файлы классов с диска, но еще и запоминает их во внутреннем кэше – так что последующие обращения к loadClass для того же имени класса просто выдают готовый объект Class из кэша. Кэширование, вообще говоря, представляется разумной идеей: зачем каждый раз заново обращаться к диску? Мы будем хранить кэш в нестатическом private-поле типа java.util.HashMap нашего класса DynamicClassOverloader. Таким образом, каждый новый экземпляр нашего загрузчика будет создаваться с новым кэшем, и для «забывания» загруженных ранее классов будет достаточно просто создать новый экземпляр загрузчика.
import java.io.*; public class DynamicClassOverloader extends ClassLoader < private Map classesHash = new java.util.HashMap(); public final String[] classPath; //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ public DynamicClassOverloader(String[] classPath) < // Набор путей поиска - аналог переменной CLASSPATH this.classPath= classPath; >//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ protected synchronized Class loadClass(String name, boolean resolve) throws ClassNotFoundException < Class result= findClass(name); if (resolve) resolveClass(result); return result; >//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ protected Class findClass(String name) throws ClassNotFoundException < Class result= (Class)classesHash.get(name); if (result!=null) < /* * System.out.println("% Class " + name + * " found in cache"); */ return result; >File f= findFile(name.replace('.','/'),".class"); // Класс mypackage.MyClass следует искать файле // mypackage/MyClass.class /* * System.out.println("% Class " + name + * (f==null?"":" found in "+f)); */ if (f==null) < // Обращаемся к системному загрузчику в случае // неудачи. findSystemClass – это метод // абстрактного класса ClassLoader с объявлением // protected Class findSystemClass(String name) // (т.е. предназначенный для использования в // наследниках и не подлежащий переопределению). // Он выполняет поиск и загрузку класса по // алгоритму системного загрузчика. Без вызова // findSystemClass(name) нам пришлось бы // самостоятельно позаботиться о загрузке всех // стандартных библиотечных классов типа // java.lang.String, что потребовало бы // реализовать работу с JAR-архивами // (стандартные библиотеки почти всегда // упакованы в JAR) return findSystemClass(name); >try < byte[] classBytes= loadFileAsBytes(f); result= defineClass(name, classBytes, 0, classBytes.length); >catch (IOException e) < throw new ClassNotFoundException( "Cannot load class " + name + ": " + e); >catch (ClassFormatError e) < throw new ClassNotFoundException( "Format of class file incorrect for class " + name + " : " + e); >classesHash.put(name,result); return result; > //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ protected java.net.URL findResource(String name) < File f= findFile(name, ""); if (f==null) return null; try < return f.toURL(); >catch(java.net.MalformedURLException e) < return null; >> //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ /** * Поиск файла с именем name и, возможно, расширением * extension в каталогах поиска, заданных параметром * конструктора classPath. Имена подкаталогов в name * разделяются символом '/' – даже если в операционной * системе принят другой разделитель для подкаталогов. * (Именно в таком виде получает свой параметр метод * findResource.) */ private File findFile(String name, String extension) < File fl for (int k=0; k return null; > //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ public static byte[] loadFileAsBytes(File file) throws IOException < byte[] result = new byte[(int)file.length()]; FileInputStream f = new FileInputStream(file); try < f.read(result,0,result.length); >finally < try < f.close(); >catch (Exception e) < // Игнорируем исключения, возникшие при // вызове close. Они крайне маловероятны и не // очень важны - файл уже прочитан. Но если // они все же возникнут, то они не должны // замаскировать действительно важные ошибки, // возникшие при вызове read. >; > return result; > //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ >
Для проверки создаем тестовый класс TestModule.java, который будем загружать нашим загрузчиком:
public class TestModule < public String toString() < return "TestModule, version 1!"; >>
Создаем тест Test.java, который будет загружать этот класс:
import java.io.*; public class Test < public static void main(String[] argv) throws Exception < ClassLoader loader; for (;;) < loader= new DynamicClassOverloader( new String[] ); // текущий каталог "." будет единственным // каталогом поиска Class clazz= Class.forName("TestModule", true, loader); Object object= clazz.newInstance(); System.out.println(object); new BufferedReader( new InputStreamReader(System.in)).readLine(); > > >
Размещаем все эти файлы в один каталог, компилируем и запускаем Test:
java Test
В каждой итерации бесконечного цикла создается экземпляр нашего загрузчика loader, с его помощью загружается класс TestModule, создается его экземпляр и распечатывается, при этом, как обычно, неявно используется метод toString(), реализованный в TestModule. Затем ожидается нажатие на клавиатуре клавиши ENTER (либо Ctrl-C для выхода из программы).
Пока наш тест ждет нажатия ENTER, перейдем в другое окно (ОС Windows) или консоль (ОС Unix), чуть-чуть исправим класс TestModule: изменим результат toString() на «TestModule, version 2!» и перекомпилируем его. После чего вернемся в наш тест и нажмем ENTER.
Сработало! В следующей итерации цикла мы видим результат работы свежей версии класса TestModule.class – будет напечатана новая строка «TestModule, version 2!».
Мы добились успеха, не выходя из программы, модифицировали class-файл, и новая версия класса была успешно загружена.
Класс TestModule можно заменить на любой другой сложный класс, конструктор которого инициирует сколь угодно сложную цепочку действий. Все классы, которые в процессе этого будут задействованы, будут точно так же динамически перезагружаться.
Первое тестирование
Тест работает, но возникает законный вопрос – ну и что? Да, мы можем в определенный момент запустить некий класс, заданный своим именем, после чего он будет выполнять какие-то действия и, в конце концов, вернет строку «object.toString()». Но это в общем-то ничем принципиально не отличается от запуска новой java-программы стандартным вызовом
java Имя_класса
Вспомним постановку задачи. У нас есть большая, очень большая Java-программа, полный перезапуск которой занимает длительное время и крайне нежелателен. Мы хотим иметь возможность в какой-то момент быстро перезагрузить некоторые ФРАГМЕНТЫ программы, чтобы все классы, относящиеся к этим фрагментам, после этого момента заново считывались с диска. Вероятнее всего, эти классы должны активно взаимодействовать друг с другом и со стационарной, неперезагружаемой частью программы. Например, они могут реализовывать различные интерфейсы, которыми пользуется стационарная часть программы, их экземпляры могут сохраняться в различных переменных в стационарной части и т. д.
В терминах нашего загрузчика классов это означает, что мы должны уметь взаимодействовать с классами, загруженными вызовом
Class.forName("Имя_класса", true, loader)
Мы должны работать с их экземплярами, вызывать методы, обращаться к полям, перехватывать исключения, причем по возможности стандартными способами языка Java. Аналогично классы, загруженные разными экземплярами загрузчика, например, отвечающие за слабо связанные фрагменты большой программы, которые нужно перезагружать в разные моменты – должны уметь взаимодействовать друг с другом.
Казалось бы, с этим нет никаких проблем. В тесте мы получили вызовом newInstance() переменную типа Object. Но если мы знаем, что ее тип – TestModule, мы можем спокойно привести ее к этому типу и работать дальше обычным образом:
. Class clazz= Class.forName("TestModule",true,loader); TestModule testModule = (TestModule)clazz.newInstance();
работаем с полями testModule, вызываем методы и т.д.
Если бы здесь был обычный вызов Class.forName(«TestModule»), все было бы нормально. Это был бы простейший вариант классической схемы построения расширяемых систем. В качестве аргумента forName мог бы выступать любой наследник TestModule (или класс, реализующий интерфейс TestModule), реализация которого неизвестна и не обязательно доступна в момент компиляции системы. Об этом способе работы с отражениями рассказывалось в первой части статьи.
Но с нашим необычным загрузчиком классов нас ждет неприятная неожиданность. При попытке приведения типа будет возбуждено исключение ClassCastException – ошибка приведения типа!
Новые свойства языка – «динамические» и «истинно-статические» классы
Здесь мы столкнулись с проявлением достаточно общей проблемы. Чтобы понять ее природу, нужно заново внимательно рассмотреть понятие класса в языке Java.
В мире объектно-ориентированного программирования, в частности в Java, мы привыкли к тому, что существует два уровня иерархии сущностей – класс и экземпляр. Класс с заданным именем в системе всегда один – он однозначно идентифицируется своим полным именем. Экземпляров же у класса может быть много. Так, в Java поля с квалификатором static принадлежат целому классу, каждое такое поле существует в системе в единственном экземпляре. В отличие от этого для обычного (нестатического) поля отдельная его версия присутствует в каждом экземпляре класса.
Создав наш загрузчик DynamicClassOverloader, всегда загружающий свежие версии class-файлов, мы принципиально изменили ситуацию. Теперь есть три уровня иерархии: сам класс, версия класса – тот байт-код, который был загружен конкретной версией DynamicClassOverloader (возможно, меняющийся в процессе работы программы) и экземпляры конкретной версии класса.
На самом деле виртуальная машина Java «считает» классом как раз то, что для нас является версией. Хотя это и неочевидно из документации, но класс в Java однозначно идентифицируется не только полным именем, но еще и экземпляром загрузчика, загрузившего этот класс. Каждый экземпляр загрузчика классов порождает собственное пространство имен, внутри которого классы действительно однозначно идентифицируются полными именами, но классы в разных пространствах имен, загруженные разными загрузчиками, вполне могут иметь идентичные имена.
На самом деле, с точки зрения Java, класс TestModule, возникающий в результате прямого вызова «TestModule testModule= . » в тексте файла Test.java, и класс TestModule, полученный вызовом:
Class.forName("TestModule",true,loader)
– это два совершенно разных класса. Первый был загружен системным загрузчиком (вместе с самым главным классом Test), а второй – одним из экземпляров нашего загрузчика DynamicClassOverloader. По классическим законам объектно-ориентированного программирования приведение типов между ними невозможно.
Более того, каждая итерация нашего цикла, оказывается, порождала новую версию класса TestModule, не связанную с предыдущими. В этом можно убедиться следующим образом. Модифицируем класс TestModule:
public class TestModule < private static int counter = 0; public String toString() < return "TestModule, version 1! " + (counter++); >>
В нормальных условиях каждое новое обращение к методу toString() такого класса привело бы к получению строки с новым, увеличенным на 1 значением счетчика counter. Если бы в тесте Test.java был обычный вызов Class.forName(«TestModule»), мы бы это и увидели: на каждой итерации цикла распечатывались бы разные значения счетчика. А с нашим загрузчиком мы все время будем видеть нулевое значение. Каждое пересоздание экземпляра загрузчика DynamicClassOverloader приводит к появлению нового пространства имен, в котором инициализируется совершенно новая версия класса TestModule, ничего не знающая о предыдущих версиях и о содержащихся в них статических переменных.
Фактически, мы придали языку Java новое свойство – «динамичность» классов. Теперь, обращаясь прямо или косвенно к любому классу, придется думать о том, какая именно версия этого класса будет использована и можно ли ее использовать совместно с тем классом, который к ней обращается.
Свойство, что и говорить, крайне неудобное. Как же теперь работать с «динамическими» классами? Ведь все версии классов, которыми пользуется наш первый класс, загруженный DynamicClassOverloader, и которые тем самым тоже загружены нашим загрузчиком, – все эти версии неизвестны в пространстве имен стационарной части – в нашем случае в главном классе Test.
Решение этой проблемы – заблокировать свойство «динамичности» для некоторых классов, т.е. потребовать, чтобы такие классы наш загрузчик загружал стандартным способом – через вызов «findSystemClass(name)». Назовем такие классы «истинно-статическими» – «true-static». Такие «true-static»-классы можно будет свободно совместно использовать в стационарной части программы и всех версиях «динамических» классов. Для «true-static»-классов всегда будет существовать только одна версия, как и предполагается в обычном языке Java, и не будет проблем несоответствия типа. Можно, например, сделать «true-static» все ключевые интерфейсы, основные типы данных, которые должны получать и возвращать «динамические» классы, базовые типы исключений, подлежащие перехвату и единообразной обработке, и т. п.
В сущности, уже в реализованной нами версии загрузчика существовали «true-static»-классы – это библиотечные классы из пакетов типа java.lang, которые мы не пытались грузить самостоятельно. Скажем, такими были стандартные типы Object и String. Именно поэтому в первоначальном варианте теста мы смогли получить от созданного экземпляра динамического класса TestModule строку String – результат метода toString().
Можно придумать много соглашений, по которым загрузчик должен опознавать «true-static»-классы. Например, можно проверить существование некоторого ключевого static-поля или проверить, не реализован ли в классе некоторый специальный пустой интерфейс. Мы ограничимся наиболее простым (хотя и не всегда удобным) вариантом: будем проверять, не содержит ли имя класса name цепочки символов «true-static» без учета регистра символов.
Итак, начинаем модифицировать наш загрузчик DynamicClassOverloader и добавляем в методе findClass сразу перед вызовом:
File f= findFile(name.replace(".","/"),".class");
дополнительную проверку имени name. Вот начало исходного текста нового метода findClass:
protected Class findClass(String name) throws ClassNotFoundException < Class result= (Class)classesHash.get(name); if (result!=null) < // System.out.println("% Class " + name + " // found in cache"); return result; >if (name.toLowerCase().indexOf("truestatic")!=-1) return findSystemClass(name); File f= findFile(name.replace('.','/'),".class"); .
Попробуем этим воспользоваться. Создаем «true-static»-класс TrueStaticModule.java:
public class TrueStaticModule < protected static int counter= 0; public int getCounter() < return counter; >>
В нем есть public-метод getCounter(), которым мы собираемся пользоваться в стационарной части программы. Наследуем от него «динамический» класс DynamicModule.java:
public class DynamicModule extends TrueStaticModule < public String toString() < return "DynamicModule, version 1! "+(counter++); >>
Наконец, переписываем тест Test.java – «стационарную часть» программы:
import java.io.*; public class Test < public static void main(String[] argv) throws Exception < ClassLoader loader; for (;;) < loader= new DynamicClassOverloader( new String[] ); // текущий каталог "." будет единственным // каталогом поиска Class clazz= Class.forName("DynamicModule", true, loader); TrueStaticModule tsm; tsm=(TrueStaticModule) clazz.newInstance(); System.out.println(tsm.getCounter()); System.out.println(tsm); new BufferedReader( new InputStreamReader (System.in)).readLine(); > > >
Компилируем все эти файлы и запускаем:
java Test
Все должно работать нормально. Как и раньше мы можем прямо в процессе работы теста модифицировать и перекомпилировать класс DynamicModule, и это изменение будет учтено. Но для «общения» со стационарной частью программы и для хранения счетчика обращений к методу toString() теперь используется «true-static»-класс TrueStaticModule. Поэтому мы не получаем исключения ClassCastException, а счетчик counter корректно увеличивается на протяжении всей работы теста.
Поставленную задачу можно считать в принципе решенной.
Чтобы использование нашего загрузчика стало действительно удобным, стоило бы еще реализовать специальный сервисный «true-static»-класс с методом forName, аналогичным стандартному forName. Только в отличие от стандартного, наш forName обращался бы к нашему загрузчику, экземпляр которого, внутреннее private-поле, создавался бы при первом обращении к forName. Параметры конструктора для нашего загрузчика можно было бы настраивать с помощью специальных полей сервисного класса. Кроме того, в нашем сервисном классе был бы специальный метод invalidate, обнуляющий private-поле с нашим загрузчиком и вынуждающий метод forName при следующем вызове заново создать загрузчик.
Метод invalidate можно было бы вызывать в Java-программе всякий раз, когда требуется перезагрузить с диска новые версии всех «динамических» классов. Написание подобного сервисного класса – достаточно понятная задача, и мы не будем на ней останавливаться.
Правила работы с «динамическими» классами
При практическом использовании описанного выше загрузчика классов программирование в языке Java заметно усложняется. Нужно помнить о возможной «динамичности» классов: каждый экземпляр загрузчика порождает отдельную версию каждого такого класса в собственном пространстве имен. Нужно заботиться о том, чтобы некоторые классы были «true-static». Все это требует четкого понимания описанных выше механизмов и достаточной аккуратности.
Ниже сформулировано несколько правил, которыми следует руководствоваться при программировании в таком, изменившем свое поведение, языке Java.
Будем называть динамической частью Java-программы ту систему «динамических» классов, которая загружается некоторым экземпляром нашего загрузчика DynamicClassOverloader, и стационарной частью – основную систему классов, которая загружает динамическую часть, используя экземпляры DynamicClassOverloader. В программе может быть и несколько динамических частей, никак не связанных друг с другом, одновременно загруженных несколькими экземплярами DynamicClass-Overloader. В стационарную часть входят, в частности, все «true-static»-классы.
ПРАВИЛО A. Стационарную часть программы – в частности, все «true-static»-классы – следует разрабатывать таким образом, чтобы никак не использовать информацию о структуре «динамических» классов: имена классов, имена их членов, типы параметров у методов. Иными словами, нельзя прямо ссылаться на конкретные имена «динамических» классов и обращаться к ним средствами языка Java. Единственным способом взаимодействия стационарной части программы с «динамическими» классами должна быть их загрузка вызовом
Class.forName("TestModule",true,loader)
и использование системы «true-static»-классов (или интерфейсов), известных и стационарной, и динамической частям. Например, можно обращаться (через приведение типа) к «true-static»-интерфейсам, которые реализуют «динамические» классы, получать результаты методов этих интерфейсов в виде «true-static»-классов, перехватывать «true-static»-исключения и т. д.
Сформулированное правило вполне логично и «выдержано в духе» объектно-ориентированного программирования. «Динамические» классы для того и сделаны «динамическими», чтобы их можно было разрабатывать и компилировать уже после того, как стационарная часть программы скомпилирована и запущена. Поэтому стационарная часть и не должна ничего «знать» об этих классах кроме того, что они, возможно, реализуют какие-то заранее известные «true-static»-интерфейсы или, скажем, как-то работают со static-полями заранее известных «true-static»-классов.
В первом нашем тесте, вызвавшем ошибку приведения типа, мы нарушили это правило. При приведении типа мы непосредственно сослались из стационарной части программы на имя «динамического» класса TestModule. В правильном решении, которое мы привели позже, мы преобразовывали полученный объект неизвестного нам «динамического» типа к типу «true-static»-класса TrueStaticModule – предка «динамического» класса.
Заметим, сформулированное правило не является категорическим. Его вполне можно нарушать, т.е. прямо ссылаться из стационарной части на «динамические» классы. Просто следует помнить, что классы, которые будут при этом задействованы, и классы с теми же именами, к которым будет обращаться динамическая часть программы, – это совершенно разные классы, лежащие в разных пространствах имен.
Например, и стационарная, и динамическая части программы могут активно пользоваться некоторыми сервисными библиотеками, и эти библиотеки нет никакой необходимости делать «true-static». Они вполне могут быть «динамическими». Если мы изменим реализацию некоторого метода в таком классе-библиотеке и перекомпилируем этот класс, то стационарная часть этого «не заметит» и будет продолжать работать со своей старой версией библиотеки, а динамическая часть, после очередного пересоздания экземпляра загрузчика, воспользуется новой версией.
Из общего правила A можно выделить несколько более простых частных правил.
ПРАВИЛО A1. Не следует ссылаться из стационарной части программы (в частности, «true-static»-класса) на какие-либо поля, конструкторы или методы «динамического» класса. Точнее, следует иметь в виду, что такая ссылка означает обращение к версии «динамического» класса, загруженной системным загрузчиком по умолчанию, а никак не к версии, загруженной нашим загрузчиком DynamicClassOverloader.
ПРАВИЛО A2. Все аргументы и результат любого метода в «true-static»-классе, используемого для связи между стационарной и динамической частями программы, например, метода «true-static»-интерфейса, который реализуют конкретные «динамические» классы, – должны иметь либо примитивный тип, либо тип «true-static»-класс. То же самое относится к public-полям, используемым с аналогичными целями. (К «true-static»-классам мы относим также все стандартные системные классы, вроде java.lang.String или java.io.File, которые наш загрузчик не пытается грузить самостоятельно.)
ПРАВИЛО A3. Если «динамические» классы возбуждают какие-либо исключения, которые, возможно, потребуется перехватить оператором
catch (Тип_исключения e)
в стационарной части программы, то перехватываемый класс Тип_исключения должен быть «true-static».
ПРАВИЛО B. Нужно учитывать, что каждый экземпляр загрузчика порождает независимое пространство имен. Любая ссылка на «динамический» класс: на его конструкторы, методы, статические или обычные поля действует только в пределах текущего пространства имен и не относится к версиям того же класса, загруженным другими экземплярами загрузчика.
Частные следствия из этого правила:
ПРАВИЛО B1. Не следует думать, что статические поля «динамического» класса существуют в системе в единственном экземпляре. В каждом пространстве имен существует отдельная версия класса со своим набором статических полей.
Например, есть такая практика управления поведением Java-класса. Объявляется статическое public-поле, скажем,
public static boolean debugMode= false;
влияющее на работу некоторых методов класса. Обычно используется значение этого поля по умолчанию. Но при желании главный класс Java-приложения на этапе общей инициализации системы и загрузки конфигурационных файлов может записать в это поле другое значение.
С «динамическими» классами такой прием «не проходит». Главный класс в стационарной части может повлиять на значение static-поля только для одной версии «динамического» класса, загруженной системным загрузчиком. Все последующие версии, загруженные экземплярами DynamicClassOverloader, получат умолчательное, заново инициализированное значение этого static-поля.
Если «динамический» класс действительно нуждается в наборе истинно глобальных полей, разделяемых всеми своими версиями, то самое естественное решение – определить внутри этого класса локальный «true-static»-класс. Например:
public class Мой_динамический_класс < public static class TrueStaticSection < public static boolean debugMode= false; // другие глобальные переменные >. >
ПРАВИЛО B2. Если «динамический» класс нуждается в доступе к некоторым полям, методам, конструкторам или локальным классам некоторого «true-static»-класса, то все эти члены «true-static»-класса обязаны быть public, даже если «динамический» и «true-static»-класс лежат в одном пакете или внутри одного java-файла. Исключение: если «динамический» класс наследует «true-static», то он, как обычно, имеет доступ к protected-членам «true-static»-класса.
Например, если в «динамический» класс вложен локальный «true-static»-класс, то «динамический» класс, вопреки обычной практике, не может пользоваться private-полями или полями с дружественным доступом вложенного класса.
Дело в том, что с точки зрения виртуальной машины Java-классы, загруженные разными загрузчиками и поэтому лежащие в разных пространствах имен, всегда имеют друг с другом столь же слабую связь, как и классы, лежащие в разных пакетах. «Дружественный» доступ или доступ к private-членам вложенного класса между разными пространствами имен «не работает».
Будьте внимательны: подобная ошибка (разумеется) не отслеживается компилятором Java и обнаруживается в виде системного исключения уже при выполнении программы.
Есть еще одно специфическое правило, не вытекающее из описанных выше общих принципов.
ПРАВИЛО C. Если некоторые методы класса являются native – эти методы реализованы в отдельном машинно-зависимом модуле, загружаемом методом System.loadLibrary в секции статической инициализации класса, то такой класс обязан быть «true-static».
Это – внутреннее свойство современной версии Java-машины, по крайней мере, для платформы Windows. Java-машина не допускает повторной загрузки внешнего модуля вызовом loadLibrary с тем же самым именем – такая попытка вызывает исключение. Для «динамических» классов инициализация происходит многократно в каждой версии класса. Если в «динамическом» классе попытаться в соответствии с документацией обратиться к System.loadLibrary в секции статической инициализации:
static
то уже вторая загрузка такого класса нашим загрузчиком возбудит внутреннее исключение с сообщением: «данный внешний модуль уже загружен другим загрузчиком классов».
Не обесценивают ли описанные сложности нового удобства – возможности «на лету» перекомпилировать и перезагружать классы? Я считаю, что нет – при условии грамотного проектирования системы в целом. На самом деле, описанные выше правила касаются только разработки сравнительно небольшого блока: стандартизованного интерфейса между стационарной и динамической частью. При разработке остальных блоков стационарной части, не работающих с «динамическими» классами, все описанные нюансы несущественны: стационарная часть загружается системным загрузчиком по обычным правилам.
При разработке динамических частей программы, расширяющих ее функциональность, возможно, сторонними разработчиками, все эти правила, за исключением очень простого правила C, обычно также можно спокойно игнорировать. Все правила, за исключением C, относятся к доступу к «динамическим» классам из стационарной части и к работе с различными пространствами имен, но никак не затрагивают разработку системы «динамических» классов в пределах одного пространства имен. Поэтому все новые классы, разрабатываемые для расширения динамической части программы при уже выработанном протоколе общения со стационарной частью, почти всегда можно объявлять «динамическими» и спокойно разрабатывать по обычным правилам языка Java.
Особенности кэширования: различие loadClass и forName
Здесь хотелось бы ненадолго вернуться назад к нашей реализации DynamicClassOverloader. В приведенной выше реализации есть две закомментированные строки с вызовом System.out.println, позволяющим увидеть момент загрузки класса и извлечение его из кэша.
Если их раскомментировать, можно увидеть удивительную вещь. Когда я воспользовался таким загрузчиком для исполнения большого проекта на Java, включающим тысячи различных классов и реализующим интенсивные вычисления, я обнаружил, что проверка наличия класса в кэше
Class result = (Class)classesHash.get(name); if (result!=null)
не срабатывает никогда!
Я пробовал повторно обращаться к загруженным классам всеми возможными способами: прямым обращением к классу, через Class.forName с различными наборами параметров, путем наследования, перехвата исключений и т. д. Во всех случаях, кроме прямого обращения к методу нашего загрузчика loadClass, до исполнения наших методов loadClass и findClass дело просто не доходило. Очевидно, Java-машина реализует дополнительное кэширование загруженных классов. Пока мы явно не подаем указание использовать другой экземпляр загрузчика с помощью соответствующего аргумента метода Class.forName. Все классы, уже загруженные один раз нашим загрузчиком, автоматически извлекаются из какого-то внутреннего кэша. Фактически наше кэширование, реализованное в DynamicClassOverloader, оказалось ненужным – классы и так прекрасно кэшируются.
Я не знаю, насколько можно полагаться на эту особенность Java-машины. Документация к классу ClassLoader рекомендует использовать вызов protected-метода findLoadedClass для выяснения, был ли уже загружен данный класс. Но в моих тестах мне не удавалось этим воспользоваться – этот метод всегда возвращал null.
Я все же считаю реализацию собственного кэша «на всякий случай» целесообразным, тем более что это требует всего нескольких строк кода. Кроме того, собственный кэш необходим, если при использовании нашего загрузчика классов предполагается пользоваться вместо классического вызова
Class.forName ("Имя_класса",true,loader)
loader.loadClass ("Имя_класса")
Здесь метод loadClass вызывается напрямую – и здесь уже никто кроме нас не позаботится о кэшировании однажды загруженных классов. (В отличие от этого вызов Class.forName всегда обращается к внутреннему кэшу, и, если для данного загрузчика loader класс name уже однажды загружался, он будет найден и возвращен в результате forName без обращения к loadClass и findClass.)
Хуже того, если мы не будем реализовывать кэширование в загрузчике, т.е. будем в любом случае читать класс с диска и вызывать для него метод defineClass. В этом случае два последовательных вызова
loader.loadClass ("Имя_класса") loader.loadClass ("Имя_класса")
для одинакового класса приведут к низкоуровневому исключению LinkageError! Виртуальная машина не разрешает в рамках одного и того же экземпляра загрузчика дважды определять один и тот же класс, т.е. обращаться к методу defineClass. С вызовом
Class.forName ("Имя_класса",true,loader)
такую проблему пронаблюдать не удастся, даже если мы не будем реализовывать кэш.
Описанное поведение, как мне кажется, является веским аргументом, чтобы по возможности никогда, за исключением тестов, не использовать прямой вызов
loader.loadClass("Имя_класса")
отдавая предпочтение вызову
Class.forName("Имя_класса",true,loader)
Class.forName("Имя_класса",false,loader)
У внимательного читателя при изучении нашего загрузчика DynamicClassOverloader мог возникнуть вопрос. Если наша цель – научить загрузчик «забывать» старые версии классов, почему мы попросту не реализовали в классе DynamicClassOverloader метод invalidate:
public void invalidate()
очищающий наш кэш classesHash? Почему вместо этого мы пошли по более сложному пути: созданию новых экземпляров загрузчика?
Теперь мы можем ответить на этот вопрос. При использовании вызова
Class.forName("Имя_класса",true,loader)
метод invalidate, очищающий classesHash, просто не дал бы никакого эффекта. Виртуальная машина все равно извлекала бы класс из внутреннего кэша, пока мы не сменили бы экземпляр loader используемого загрузчика. А при использовании прямого вызова
loader.loadClass("Имя_класса")
метод invalidate, вместо ожидаемого «забывания» старых классов, привел бы к низкоуровневому исключению LinkageError.
Применения DynamicClassOverloader
Приведем несколько примеров ситуаций, где можно применить созданный нами загрузчик DynamicClassOverloader помимо решения поставленной нами главной задачи – загрузки измененных версий классов «на лету» без остановки основной программы.
Прежде всего напрашивается простейшее применение: профайлинг загрузки классов. С помощью параметра «-verbose:class» стандартной утилиты «java» можно проследить, какие классы загружаются в процессе работы. Но собственный загрузчик может сделать намного больше. Очень легко дополнить приведенный выше исходный код DynamicClassOverloader сбором любой статистики о загружаемых файлах: сколько классов различных пакетов загружается, сколько времени расходуется на загрузку классов, какие классы загружаются обычно в первую очередь и т. д.
DynamicClassOverloader можно использовать для загрузки классов из нестандартного каталога, не указанного в переменной CLASSPATH. Это требуется не так уж часто, но в некоторых ситуациях без этого сложно обойтись. Например, предположим, что вы пишете систему обработки документов, к которым могут прилагаться специальные Java-классы: как апплеты в веб-страницах или скрипты в документах Microsoft Word. Пользователь может работать с любым числом таких документов, расположенных где угодно в пределах своей файловой системы. Очевидно, для загрузки и исполнения таких классов, сопутствующих документу, стандартный набор путей из CLASSPATH окажется недостаточным.
Наконец, отдельные пространства имен, порождаемые экземплярами нашего загрузчика, из неудобного недостатка могут превратиться в ключевое достоинство. Обычно разработчики Java-классов избегают конфликтов имен своих классов благодаря стандартной системе именования пакетов, когда в имя пакета включается серия вложенных доменов Internet, соответствующих уникальному веб-сайту разработчика. Но если Java применяется в упрощенном виде – скажем, для реализации небольших скриптов конечными пользователями продукта – такая схема именования может оказаться чересчур обременительной. (Те же апплеты редко размещают в пакете. Часто пакет вообще не указывается, т.е. используется корневой package.)
Представленный загрузчик позволяет надежно изолировать друг от друга подобные простые классы, не нуждающиеся во взаимодействии друг с другом и разработанные, возможно, разными разработчиками. Если два класса загружены разными экземплярами DynamicClassOverloader, они могут спокойно иметь идентичное имя. Они «живут» в разных пространствах имен и не могут ничего «знать» друг о друге.
Заключение
Реализовав собственный загрузчик классов, мы познакомились с миром отражений Java и много узнали о «внутренней кухне» виртуальной Java-машины. Конечно, это далеко не все.
Здесь не представлено исчерпывающее руководство, а только предложена краткая экскурсия, позволяющая познакомиться с основными понятиями, технологиями и проблемами мира отражений Java и, надеюсь, позволяющую почувствовать изящество и мощь этого чудесного языка. С помощью всего лишь около сотни строк исходного текста Java был создан инструмент, позволивший существенно «подправить» поведение Java-машины и изменивший свойства самого языка Java.
Как ClassLoader работает на Java?
Java Classloader является частью JRE (Java Runtime Environment), которая динамически загружает классы Java в JVM (виртуальная машина Java). В частности, Java-программа, в отличие от написанной на C или C ++, не является одним исполняемым файлом, а состоит из нескольких отдельных файлов классов, каждый из которых соответствует одному классу Java. Обычно классы загружаются только по требованию. Это означает, что эти файлы классов Java не загружаются сразу в память, а загружаются по требованию, как это требуется программе (Class Loader). Class Loader — это компонент с Java Execution Engine, который загружает двоичные данные из файлов .class, доступных в пути класса, в область методов. Загрузка класса в область метода происходит только в первый раз, когда класс ссылается в запущенном приложении Java. Для всех других ссылок данные повторно используются из области метода, если класс не был РАЗГРУЗЛЕН.
ClassLoader в Java работает по трем принципам:
- Делегирование
- Видимость
- Уникальность
Принцип делегирования запроса запроса загрузки класса на загрузчик родительского класса и загружает только класс, если родитель не может найти или загрузить класс. Принцип видимости позволяет загрузчику дочерних классов видеть все классы, загруженные родительским ClassLoader, но загрузчик родительского класса не может видеть классы, загруженные дочерним. Принцип единственности позволяет загружать класс ровно один раз, что в основном достигается путем делегирования и гарантирует, что дочерний класс ClassLoader не перезагрузит класс, уже загруженный родителем.
Все виртуальные машины JVM (Java) включают один загрузчик классов, встроенный в виртуальную машину. Этот встроенный загрузчик называется первоклассным загрузчиком классов. Это несколько особенное, потому что виртуальная машина (виртуальная машина) предполагает, что она имеет доступ к хранилищу доверенных классов, которые могут быть запущены виртуальной машиной без проверки. Когда виртуальная машина Java запущена, используются три загрузчика классов:
- Загрузочный загрузчик класса Bootstrap
- загрузчик классов расширений
- загрузчик системного класса
Загрузочный загрузчик класса bootstrap загружает внутренние классы JDK, обычно загружает rt.jar и другие базовые классы, например Java.lang. * Классы пакетов. Класс загрузчика классов расширений загружает классы из каталога расширений JDK, обычно $ JAVA_HOME / lib / ext каталога. Он реализуется классом sun.misc.Launcher $ ExtClassLoader. Системный загрузчик классов загружает код, найденный на Java.class.path, который сопоставляется с переменной среды CLASSPATH. Это реализуется классом sun.misc.Launcher $ AppClassLoader.
Создание простого ClassLoader
Загрузчик классов начинается с того, что является подклассом Java.lang.ClassLoader. Единственным абстрактным методом, который должен быть реализован, является loadClass(). Поток loadClass() выглядит следующим образом:
Загрузчик классов начинается с того, что является подклассом Java.lang.ClassLoader. Единственным абстрактным методом, который должен быть реализован, является loadClass(). Поток loadClass() выглядит следующим образом.
Погрузчики классов являются иерархическими. Начальный класс загружается с помощью общедоступного метода static main(), объявленного в вашем классе. Все последующие загруженные классы загружаются классами, которые уже загружены и запущены.
Иерархия загрузчика классов
Большинство программистов Java никогда не должны явно использовать загрузчики классов (за исключением загрузки ресурсов, чтобы они все еще работали, когда они были объединены в JAR), не говоря уже о написании собственных. ClassLoaders используются в очень больших системах и серверных приложениях, чтобы делать такие вещи, как:
Class Loaders являются функциональным компонентом виртуальной машины Java, которая загружает данные класса из файла .class или из сети в область «Метод» в «Кучах». Каждый загрузчик классов имеет собственное пространство имен, а классы, вызываемые определенным загрузчиком классов, попадают в его пространство имен. Классы, вызываемые двумя разными загрузчиками классов, не будут иметь видимости друг над другом, что приведет к повышению безопасности. Механизм делегирования родительского дочернего элемента класса загрузчика гарантирует, что классы Java api никогда не могут быть взломаны несанкционированным кодом. Это связано с тем, что существуют загрузчики классов, время выполнения Java не должно ничего знать о файлах и файловых системах при запуске Java-программ. Кроме того, Java ClassLoader написан на языке Java. Это означает, что легко создать собственный ClassLoader, не понимая более тонкие детали JVM (виртуальная машина Java).