Pull to refresh
35.72

Как загружать классы в Java 8 и Java 9+?

Level of difficultyHard
Reading time24 min
Views6.3K

Привет, Хабр! Я разработчик в ИСП РАН, занимаюсь разработкой статического анализатора Svace. Недавно я столкнулся с задачей самостоятельной загрузки классов в JVM, что оказалось непросто, потому что в проекте мы используем модули Java.

Модули появились в Java, начиная с версии 9. Прошло уже несколько лет, но если попытаться найти актуальную информацию о связи модулей и загрузчиков классов, её придётся собирать по крупицам. В статье я поделюсь своим опытом изучения вопроса самостоятельной (и автоматической) загрузки классов с помощью кастомного загрузчика, а также разберу примеры, описывающие большинство случаев загрузки, постараюсь их объяснить.

Задача

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

Изначально у меня был небольшой участок устаревшего (как оказалось) кода, который мог загружать избранные классы, используя кастомный загрузчик классов (будем называть его CustomClassLoader), наследовавшийся от класса java.net.URLClassLoader. Приступив к задаче, я подумал, что мне придётся всего лишь оживить и протестировать этот код, но дальше всё пошло не по плану.

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

В чём проблема погуглить?

CustomClassLoader был написан моим коллегой, когда модули ещё не использовались в проекте. С началом использования модульной системы Java я столкнулся с проблемой: CustomClassLoader уже не мог работать как раньше, как минимум потому что находился в другом модуле (относительно того, который он должен был загружать). Нужно было разобраться в теме поглубже и починить CustomClassLoader.

Если вы попробуете поискать какие-нибудь гайды по написанию кастомного загрузчика классов, то скорее всего вы наткнётесь на код следующего вида (немного изменённый код с сайта Baeldung):

public class CustomClassLoader extends ClassLoader {
    @Override
    public Class findClass(String name) throws ClassNotFoundException {
        byte[] bytecode = loadClassFromFile(name);
        return defineClass(name, bytecode, 0, bytecode.length);
    }

    private byte[] loadClassFromFile(String fileName) {
        String classFile = fileName.replace('.', File.separatorChar) + ".class";
        InputStream inputStream = getClass().getClassLoader().getResourceAsStream();

        byte[] buffer;
        ByteArrayOutputStream byteStream = new ByteArrayOutputStream();
        try {
            int nextValue = 0;
            while ((nextValue = inputStream.read()) != -1) {
                byteStream.write(nextValue);
            }
        } catch (IOException e) {
            e.printStackTrace();
        }

        buffer = byteStream.toByteArray();
        return buffer;
    }
}

Видно, что код сильно устарел (хотя бы потому что, нет дженериков у возвращаемого значения, да и вообще, сейчас метод findClass имеет сигнатуру protected Class<?> findClass(String)), да и в таких гайдах не пишут, что модульная система — важный фактор, который надо учитывать при использовании своих загрузчиков. Получается, что какие-то гайды в интернете найти можно, но они не полны или устарели. Большинство гайдов приводят примеры кастомного загрузчика классов, использующие аргумент JVM -cp (или -classpath), хотя не говорят о modulepath и как они друг с другом соотносятся (как вывести modulepath во время исполнения — вообще отдельная загадка).

Пока я изучал, как работают загрузчики классов, на Хабре очень кстати вышла статья,
в которой была показана иерархия ClassLoader'ов, где они ищут классы, какие загрузчики бывают и т.д. Также объяснено, как загрузчики изменились со временем (про старую иерархию можно прочитать в другой статье). Но и здесь я не нашёл ответов на свои вопросы.

Java 8

Известно, что модульную систему добавили в Java 9, а вместе с этим изменили загрузку классов. Давайте разберем, как загружались классы до этих обновлений.

Примечание: код всех примеров я выложил в один GitHub репозиторий, где каждый пример сопроводил описанием: задача, решение, объяснение.

Интересна как загрузка классов по имени (Manual Loading), так и загрузка приложения в целом (Auto Loading) с помощью своего CustomClassLoader.

Manual Loading

Пусть у нас есть приложение, написанное на Java 8, и мы захотели загрузить класс Cat с помощью своего CustomClassLoader загрузчика.

  • Класс Cat — обычный класс с простым методом, печатающим "Meow" в stdout:

// Cat.java
public class Cat {
    public void talk() {
        System.out.println("Meow");
    }
}
  • Класс Main содержит метод main с ручной (manual) загрузкой класса Cat и вызовом метода talk. Дополнительно распечатаем в stdout загрузчик класса Cat:

// Main.java
public class Main {
    public static void main(String[] args) {
        ClassLoader customClassLoader = new CustomClassLoader();
        try {
            String catClassName = "ru.ispras.j8.manual.Cat";
            Class<?> catClass = customClassLoader.loadClass(catClassName);
            Object cat = catClass.getDeclaredConstructor().newInstance();

            System.out.println("Main Class ClassLoader is " + Main.class.getClassLoader());
            System.out.println("Cat Class ClassLoader is " + catClass.getClassLoader());

            Method talkMethod = catClass.getMethod("talk");
            talkMethod.invoke(cat);
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }
}
  • Класс CustomClassLoader является кастомным загрузчиком классов, который почти полностью удовлетворяет delegation model, за исключением того, что мы сначала сами попытаемся загрузить класс, а только потом делегируем его родительскому загрузчику:

// CustomClassLoader.java
public class CustomClassLoader extends ClassLoader {
    private final String PKG_PREFIX = "ru.ispras";

    @Override
    public Class<?> loadClass(String name) throws ClassNotFoundException {
        Class<?> c = findLoadedClass(name);
        if (c != null)
            return c;

        if (name.startsWith(PKG_PREFIX)) {
            c = findClass(name);
            if (c != null)
                return c;
        }

        return super.loadClass(name);
    }

    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        String classFile = name.replace('.', File.separatorChar) + ".class";
        try (InputStream inputStream = getResourceAsStream(classFile)) {
            if (inputStream == null)
                throw new ClassNotFoundException();

            byte[] bytecode = readAllBytes(inputStream);
            return defineClass(name, bytecode, 0, bytecode.length);
        } catch (IOException e) {
            throw new ClassNotFoundException();
        }
    }

    private byte[] readAllBytes(InputStream inputStream) throws IOException {
        int nextValue;
        ByteArrayOutputStream byteStream = new ByteArrayOutputStream();
        while ((nextValue = inputStream.read()) != -1) {
            byteStream.write(nextValue);
        }

        return byteStream.toByteArray();
    }
}

Пояснения:

  • loadClass сначала пытается найти класс в загруженных, затем пытается загрузить его самостоятельно, если он из нашего приложения, а затем уже делегирует эту загрузку предку. Если попытаться загрузить класс из пакета java.*, то JVM выбросит java.lang.SecurityException: Prohibited package name: java.*. Чтобы определить, что класс из нашего приложения, мы проверяем, что binary name начинается с ru.ispras.

  • findClass не делает ничего необычного, лишь грузит байткод класса (если он найден) и определяет его как класс;

Так как это Java 8, то можно смело компилировать и запускать такую программу хоть в консоли, хоть через Gradle, потому как у нас есть только classpath и никаких проблем не ожидается. При компиляции в набор .class-файлов можно запускать как:

$ java8 -cp . ru.ispras.j8.manual.Main

Main Class ClassLoader is sun.misc.Launcher$AppClassLoader@4e0e2f2a
Cat Class ClassLoader is ru.ispras.j8.manual.CustomClassLoader@15db9742
Meow
$ java8 -jar manual-1.0.jar

Main Class ClassLoader is sun.misc.Launcher$AppClassLoader@70dea4e
Cat Class ClassLoader is ru.ispras.j8.manual.CustomClassLoader@4aa298b7
Meow

Видим, что класс Cat действительно загрузился с помощью CustomClassLoader.

Auto Loading

Что нужно изменить, чтобы JVM сама использовала CustomClassLoader для загрузки классов приложения?

В документации метода ClassLoader.getSystemClassLoader() написано,
что для этого необходимо передать JVM имя нового system class loader'а через аргумент java.system.class.loader, а также определить публичный конструктор
с параметром типа ClassLoader, в качестве которого при создании CustomClassLoader будет передан экземпляр AppClassLoader.

Таким образом, новый загрузчик имеет вид:

// CustomClassLoader.java
public class CustomClassLoader extends ClassLoader {
    private final String PKG_PREFIX = "ru.ispras";

    public CustomClassLoader(ClassLoader parent) {
        super(parent);
    }

    @Override
    public Class<?> loadClass(String name) throws ClassNotFoundException { /* ... */ }

    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException { /* ... */ }

    private byte[] readAllBytes(InputStream inputStream) throws IOException { /* ... */ }
}

Изменённый класс Main:

// Main.java
public class Main {
    public static void main(String[] args) {
        Cat cat = new Cat();

        System.out.println("System ClassLoader is " + ClassLoader.getSystemClassLoader());
        System.out.println("Main Class ClassLoader is " + Main.class.getClassLoader());
        System.out.println("Cat Class ClassLoader is " + Cat.class.getClassLoader());

        cat.talk();
    }
}

Запускаем:

$ java8 -Djava.system.class.loader=ru.ispras.j8.auto.CustomClassLoader -jar auto-1.0.jar

System ClassLoader is ru.ispras.j8.auto.CustomClassLoader@75b84c92
Main Class ClassLoader is ru.ispras.j8.auto.CustomClassLoader@75b84c92
Cat Class ClassLoader is ru.ispras.j8.auto.CustomClassLoader@75b84c92
Meow

Видим, что и системный загрузчик, и загрузчик класса Cat — это CustomClassLoader.

Замечание: если мы не определим публичный конструктор в CustomClassLoader, то произойдет ошибка инициализации JVM:

Error occurred during initialization of VM
java.lang.Error: java.lang.NoSuchMethodException: ru.ispras.j8.auto.CustomClassLoader.<init>(java.lang.ClassLoader)

Java 9+

Итак, в Java 9 появилась модульная система. Из изменений нам будет интересно узнать:

  • Изменились загрузчики классов (их роли и названия);

  • Добавились модули, а вместе с этим modulepath и новые аргументы JVM.

Я не буду углубляться в тему модулей как таковых, об этом можно прочитать в других статьях. Важно, что для объявления модуля мы используем файл module-info.java, где объявляем имя модуля и контролируем зависимости и экспорт API.

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

classpath vs. modulepath

Что это, какие различия и как используется, частично можно найти в этой статье.

Для начала, как получить эти "пути"? В документации Java 8 к методу System.getProperty можно найти информацию о доступных свойствах, в числе которых значится java.class.path, которое можно распечатать и получить classpath в run time:

System.getProperty("java.class.path"); // get classpath string

В документации Java 9 добавили ещё несколько свойств, где затесался jdk.module.path:

System.getProperty("jdk.module.path"); // get modulepath string

Рассмотрим, как они влияют на загрузку классов системным загрузчиком (именно он использует локации из этих свойств при поиске классов):

  • В версии Java 8 существует только classpath, поэтому при запуске приложения sun.misc.Launcher$AppClassLoader ищет классы в местах, которые указаны здесь;

  • В версии Java 9 появляется modulepath и два "режима" запуска приложения, будем называть их модульный (когда используются аргументы --module-path/-p) и безмодульный (в остальных случаях).

    • В безмодульном режиме используется classpath, а modulepath равен null, поэтому при запуске приложения системный загрузчик jdk.internal.loader.ClassLoaders$AppClassLoader ищет классы в classpath;

    • Если вы используете аргументы JVM --module-path или -p, то системный загрузчик будет искать классы уже в modulepath, тогда как classpath пуст;

    • Таким образом, вы используете либо classpath, либо modulepath. Это ограничение написано в документации к java command в параграфе Restrictions on Class Path and Module Path.

Manual Loading (Java 8 style)

Запустим пример ручной загрузки класса, который мы написали на Java 8, но используя Java 17 (в ней есть модульная система и это LTS версия). Добавим ещё немного информации в stdout, а именно, к каким модулям принадлежат классы Main и Cat:

// Main.java
public class Main {
    public static void main(String[] args) {
        ClassLoader customClassLoader = new CustomClassLoader();
        try {
            // ...

            System.out.println("Main Class Module is " + Main.class.getModule());
            System.out.println("Cat Class Module is " + catClass.getModule());

            // ...
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }
}

Запускаем как раньше:

$ java17 -cp . ru.ispras.j17.manual.onemodule.j8style.Main

Main Class Module is unnamed module @99e937b
Cat Class Module is unnamed module @70dea4e
Main Class ClassLoader is jdk.internal.loader.ClassLoaders$AppClassLoader@531d72ca
Cat Class ClassLoader is ru.ispras.j17.manual.onemodule.j8style.CustomClassLoader@6b95977
Meow

А если запустить jar?

$ java17 -jar java8style-1.0.jar

Main Class Module is unnamed module @99e937b
Cat Class Module is unnamed module @6d6f6e28
Main Class ClassLoader is jdk.internal.loader.ClassLoaders$AppClassLoader@531d72ca
Cat Class ClassLoader is ru.ispras.j17.manual.onemodule.j8style.CustomClassLoader@15db974
Meow

А если использовать modulepath?

$ java17 -p . -m java8style

Main Class Module is module java8style
Cat Class Module is unnamed module @3a71f4dd
Main Class ClassLoader is jdk.internal.loader.ClassLoaders$AppClassLoader@28d93b30
Cat Class ClassLoader is ru.ispras.j17.manual.onemodule.j8style.CustomClassLoader@5ca881b5
Meow

Как мы видим, классы Main и Cat загрузили те же class loader'ы, что и в Java 8, но у вас должно возникнуть много вопросов. Во-первых, откуда модули в безмодульном приложении? Во-вторых, почему если jar назывался java8style-1.0.jar, то параметром для аргумента -m был java8style? И последнее: почему классы Main и Cat в любом случае в разных модулях?

Давайте разбираться. Разработчики позаботились об обратной совместимости. Это означает, что приложения, написанные на Java 8, будут запускаться, например, с использованием Java 17, даже если это jar без своего module-info.java. Для этого придумали концепции Application Module, Unnamed Module, Automatic Module и Service Module.

Кратко о них (подробно можно прочитать здесь или в других источниках):

  • Все классы, загруженные из classpath, автоматически принадлежат Unnamed Module. Это позволяет обеспечивать обратную совместимость;

  • Все приложения или библиотеки с module-info.java являются Application Module;

  • System Module является любой модуль из списка java --list-modules (Java SE и JDK модули);

  • А вот Automatic Module — это все пользовательские модули, которые мы добавляем в modulepath, но у них нет module-info.java. Тогда имя модуля наследуется от имени jar файла по алгоритму в методе ModuleFinder.of().

На последний вопрос — почему классы Main и Cat в любом случае в разных модулях? — у меня нет точного ответа, но экспериментально удалось выяснить, что каждый загрузчик классов имеет отношение к двум модулям: во-первых, он, как класс, относится к модулю загрузчика, который загрузил этот самый класс; во-вторых, имеет собственный unnamed модуль (который можно получить с помощью метода getUnnamedModule), в котором окажутся все классы, которые он загрузит самостоятельно.

Поэтому в нашем примере возникает два unnamed модуля: первый загружен системным загрузчиком, и там лежит класс Main, а второй загружен загрузчиком CustomClassLoader,
где лежит уже класс Cat.

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

java.lang.ClassCastException: class Cat cannot be cast to class Animal (Cat is in unnamed module of loader CustomClassLoader @6b95977; Animal is in unnamed module of loader 'app')

не были неожиданностью при разработке. Ещё, может, интересно будет почитать про context class loader'ы, например, здесь.

Кстати, если вы хотите поддерживать немодульные legacy библиотеки в своем модульном приложении, то вам может пригодиться Extra Java Module Info Gradle Plugin.

Auto Loading (Java 8 style)

Перейдем к автозагрузке классов приложения с помощью CustomCLassLoader. Метод main сделаем таким же, как в примере Java 8: Auto Loading.

Если мы будем запускать по-старому (через classpath), то получим следующие результаты:

$ java17 -Djava.system.class.loader=ru.ispras.j17.auto.onemodule.j8style.CustomClassLoader \
-cp . ru.ispras.j17.auto.onemodule.j8style.Main

OpenJDK 64-Bit Server VM warning: Archived non-system classes are disabled because the java.system.class.loader property is specified (value = "ru.ispras.j17.auto.onemodule.j8style.CustomClassLoader"). To use archived non-system classes, this property must not be set

Main Class Module is unnamed module @2c7b84de
Cat Class Module is unnamed module @2c7b84de
System ClassLoader is ru.ispras.j17.auto.onemodule.j8style.CustomClassLoader@355da254
Main Class ClassLoader is ru.ispras.j17.auto.onemodule.j8style.CustomClassLoader@355da254
Cat Class ClassLoader is ru.ispras.j17.auto.onemodule.j8style.CustomClassLoader@355da254
Meow
$ java17 -Djava.system.class.loader=ru.ispras.j17.auto.onemodule.j8style.CustomClassLoader \
-jar java8style-1.0.jar

OpenJDK 64-Bit Server VM warning: Archived non-system classes are disabled because the java.system.class.loader property is specified (value = "ru.ispras.j17.auto.onemodule.j8style.CustomClassLoader"). To use archived non-system classes, this property must not be set

Main Class Module is unnamed module @8efb846
Cat Class Module is unnamed module @8efb846
System ClassLoader is ru.ispras.j17.auto.onemodule.j8style.CustomClassLoader@2f0e140b
Main Class ClassLoader is ru.ispras.j17.auto.onemodule.j8style.CustomClassLoader@2f0e140b
Cat Class ClassLoader is ru.ispras.j17.auto.onemodule.j8style.CustomClassLoader@2f0e140b
Meow

Замечание:
OpenJDK 64-Bit Server VM warning возникает из-за того, что был заменён системный загрузчик и в итоге была отключена архивация классов с помощью JVM.
Это — оптимизация Class Data Sharing (CDS). Далее мы не будем обращать на это внимание.

Как мы видим, запуски с использованием classpath дают одинаковые результаты для jar-файла и для запуска скомпилированных классов. Оба класса: и Main, и Cat — загружены
с помощью CustomClassLoader без проблем и находятся в одном unnamed модуле, который привязан к загрузчику.

Если же запускать jar-файл с modulepath (потому как запуск скомпилированных классов с modulepath невозможен):

$ java17 -Djava.system.class.loader=ru.ispras.j17.auto.onemodule.j8style.CustomClassLoader \
-p . -m java8style

Main Class Module is module java8style
Cat Class Module is module java8style
System ClassLoader is ru.ispras.j17.auto.onemodule.j8style.CustomClassLoader@4617c264
Main Class ClassLoader is jdk.internal.loader.ClassLoaders$AppClassLoader@28d93b30
Cat Class ClassLoader is jdk.internal.loader.ClassLoaders$AppClassLoader@28d93b30
Meow

Теперь классы загружены в module java8style, системный загрузчик изменился, но классы Main и Cat загрузились с помощью AppClassLoader.

У меня нет ответа на вопрос почему так произошло, и мои попытки выяснить это обернулись неудачей. Могу только предполагать, что это связано с назначением загрузчиков классов:
далее я расскажу про Module API, в котором есть такая сущность, как слой (ModuleLayer). Слой содержит в себе отображение, которое ставит в соответствие каждому модулю его загрузчик, только это загрузчик специального вида с названием public final class Loader extends SecureClassLoader. Взглянув на код этого класса, можно увидеть, что это необычный загрузчик классов: он тесно связан с модульной системой и оперирует классами, которые недоступны нам, да и сам класс является final.

В связи с этим предположением мне не ясно, как можно написать кастомный загрузчик, который умеет делать всё то, что умеет класс Loader, чтобы стать его заменой.

Далее, мы продолжим рассматривать примеры вида Auto Loading, чтобы убедиться, что при запуске приложения в модульном режиме такая тенденция сохраняется.

Manual Loading (Java 17 style)

От Manual Loading (Java 8 style) этот пример будет отличаться только наличием module-info.java:

// module-info.java
module manual.onemodule.j17style {
}

При запуске с classpath результаты не меняются. Рассмотрим сразу modulepath:

$ java17 -p . -m manual.onemodule.j17style

Main Class Module is module manual.onemodule.j17style
Cat Class Module is unnamed module @f6f4d33
Main Class ClassLoader is jdk.internal.loader.ClassLoaders$AppClassLoader@4554617c
Cat Class ClassLoader is ru.ispras.j17.manual.onemodule.j17style.CustomClassLoader@5acf9800
Meow

Здесь всё так же, лишь название модуля у Main поменялось на manual.onemodule.j17style, которое было написано в module-info.java, тогда как у Cat модуль всё ещё считается unnamed.

Auto Loading (Java 17 style)

И последний одномодульный пример: добавим к примеру Auto Loading (Java 8 style) module-info.java c названием модуля auto.onemodule.j17style:

// module-info.java
module auto.onemodule.j17style {
}

Как и в прошлом примере, будем запускать только с modulepath, так как с classpath никаких изменений:

$ java17 -Djava.system.class.loader=ru.ispras.j17.auto.onemodule.j17style.CustomClassLoader \
-p . -m auto.onemodule.j17style

Error occurred during initialization of VM
java.lang.Error: class java.lang.ClassLoader (in module java.base) cannot access class ru.ispras.j17.auto.onemodule.j17style.CustomClassLoader (in module auto.onemodule.j17style) because module auto.onemodule.j17style does not export ru.ispras.j17.auto.onemodule.j17style to module java.base

Получаем ошибку, потому что модуль java.base не может получить доступ к классу CustomClassLoader. Решается простым экспортом пакета в module-info.java:

// module-info.java
module auto.onemodule.j17style {
    exports ru.ispras.j17.auto.onemodule.j17style to java.base;
}

Теперь:

$ java17 -Djava.system.class.loader=ru.ispras.j17.auto.onemodule.j17style.CustomClassLoader \
-p . -m auto.onemodule.j17style

Main Class Module is module auto.onemodule.j17style
Cat Class Module is module auto.onemodule.j17style
System ClassLoader is ru.ispras.j17.auto.onemodule.j17style.CustomClassLoader@76ed5528
Main Class ClassLoader is jdk.internal.loader.ClassLoaders$AppClassLoader@4554617c
Cat Class ClassLoader is jdk.internal.loader.ClassLoaders$AppClassLoader@4554617c
Meow

Название модуля изменилось на auto.onemodule.j17style из module-info.java, но по сравнению с запуском примера Auto Loading (Java 8 style) в модульном режиме ничего нового: системный загрузчик изменился, но модули классов Main и Cat всё ещё в модуле, который загружается с помощью AppClassLoader.

Manual Loading (два зависимых модуля)

Мы разобрали все ситуации с одним модулем: с module-info.java и без, с загрузкой класса вручную и автоматически. Пора рассмотреть, как загружать класс из другого модуля.

Для этого нам нужно будет создать два модуля. назовём их (в module-info.java) manual.fewmodules.together.dependent и manual.fewmodules.together.dependency
(а далее для краткости будем говорить о модулях dependent и dependency). В модуле dependent остаются классы Main и CustomClassLoader, а в dependency переносим класс Cat, который будем загружать.

Изменим метод main:

// module dependent
// Main.java
public class Main {
    public static void main(String[] args) {
        ClassLoader customClassLoader = new CustomClassLoader();
        try {
            Class<?> catClass = customClassLoader.loadClass(
                    "ru.ispras.j17.manual.fewmodules.together.dependency.Cat");
            Object cat = catClass.getDeclaredConstructor().newInstance();

            System.out.println("Main Class Module is " + Main.class.getModule());
            System.out.println("Cat Class Module is " + catClass.getModule());

            System.out.println("Main Class ClassLoader is " + Main.class.getClassLoader());
            System.out.println("Cat Class ClassLoader is " + catClass.getClassLoader());

            Method talkMethod = catClass.getMethod("talk");
            talkMethod.invoke(cat);
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }
}

В module-info.java модуля dependency нужно экспортировать пакет с классом Cat, а в module-info.java модуля dependent указать зависимость от модуля dependency:

// dependency/src/module-info.java
module manual.fewmodules.together.depedency {
    exports ru.ispras.j17.manual.fewmodules.together.dependency;
}
// dependent/src/module-info.java
module manual.fewmodules.together.dependent {
    requires manual.fewmodules.together.dependency;
}

Соберем из каждого модуля jar-файл и запустим их:

$ java17 -p dependent-1.0.jar:dependency-1.0.jar -m manual.fewmodules.together.dependent

Main Class Module is module manual.fewmodules.together.dependent
Cat Class Module is unnamed module @23fc625e
Main Class ClassLoader is jdk.internal.loader.ClassLoaders$AppClassLoader@42a57993
Cat Class ClassLoader is ru.ispras.j17.manual.fewmodules.together.dependent.CustomClassLoader@3fee733d
Meow

Всё получилось и никаких проблем не возникло.

Auto Loading (два зависимых модуля)

Создадим те же самые два модуля, как в предыдущем примере Manual Loading (два зависимых модуля). Посмотрим, как будет работать автозагрузка этих двух модулей.

Класс Main:

// module dependent
// Main.java
public class Main {
    public static void main(String[] args) {
        Cat cat = new Cat();

        System.out.println("Main Class Module is " + Main.class.getModule());
        System.out.println("Cat Class Module is " + Cat.class.getModule());

        System.out.println("System ClassLoader is " + ClassLoader.getSystemClassLoader());
        System.out.println("Main Class ClassLoader is " + Main.class.getClassLoader());
        System.out.println("Cat Class ClassLoader is " + Cat.class.getClassLoader());

        cat.talk();
    }
}

module-info.java модуля dependency:

// dependency/src/module-info.java
module auto.fewmodules.together.dependency {
    exports ru.ispras.j17.auto.fewmodules.together.dependency;
}

module-info.java модуля dependent:

// dependent/src/module-info.java
module.auto.fewmodules.together.dependent {
    requires auto.fewmodules.together.dependency:

    exports ru.ispras.j17.auto.fewmodules.together.dependent to java.base;
}

Запускаем:

$ java17 -Djava.system.class.loader=ru.ispras.j17.auto.fewmodules.together.dependent.CustomClassLoader \
-p dependent-1.0.jar:dependency-1.0.jar -m auto.fewmodules.together.dependent

Main Class Module is module auto.fewmodules.together.dependent
Cat Class Module is module auto.fewmodules.together.dependency
System ClassLoader is ru.ispras.j17.auto.fewmodules.together.dependent.CustomClassLoader@5a07e868
Main Class ClassLoader is jdk.internal.loader.ClassLoaders$AppClassLoader@42a57993
Cat Class ClassLoader is jdk.internal.loader.ClassLoaders$AppClassLoader@42a57993
Meow

Аналогично Auto Loading (Java 8/17 style): системный загрузчик изменился, а загрузчики модулей — нет.

Module API

Как мы видим, даже если вы добавляете новые модули в зависимости к своему приложению, нет никаких проблем с тем, чтобы загружать классы из них. Но у нас всё ещё CustomClassLoader загружает классы в unnamed модуль, а загрузка классов требует зависимостей между модулями. Пора разобраться, как загружать классы в именованные модули, а также загружать их из модулей, которые не зависят от текущего. В этом нам поможет Module API.

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

Коротко о классах этого пакета:

  • Module — модуль в run time;

  • ModuleDescriptor — информация о модуле (его зависимости, экспорты и т.п.);

  • ModuleReference — ссылка на модуль, из которой можно получить ModuleDescriptor и его местоположение;

  • ModuleFinder — класс для поиска модуля и создание ModuleReference;

  • ModuleReader — интерфейс для чтения содержимого модуля;

  • ModuleLayer — это Configuration и соотношения модуль/загрузчик классов;

  • Configuration — инкапсулирует readability graph (если коротко, то это граф зависимостей между модулями, подробнее в документации).

Manual Loading (два независимых модуля)

Создадим два таких же модуля, как в прошлом примере Manual Loading (два зависимых модуля). Дадим модулям названия manual.fewmodules.separately.nodeps.loading и manual.fewmodules.separately.nodeps.loadable (сокращённо loading и loadable). Только в этот раз у нас в module-info.java модуля loading не будет зависимости от модуля loadable, в котором в этот раз мы захотим загружать класс Main. Из-за отсутствия зависимостей у CustomClassLoader нет информации, где находится класс Main.

Обратимся к Module API. И напишем функцию createLayer в классе Main:

private static ModuleLayer createLayer(Path modulePath, String moduleName) {
    ModuleFinder finder = ModuleFinder.of(modulePath);

    ModuleLayer bootLayer = ModuleLayer.boot();
    Configuration parentConfig = bootLayer.configuration();
    Configuration config = parentConfig.resolve(finder, ModuleFinder.of(), Set.of(moduleName));
    return bootLayer.defineModulesWithOneLoader(config, new CustomClassLoader());
}

Сначала мы создаём ModuleFinder, которому при создании передаём путь к loadable.jar — модулю с классом Main. Затем мы создаём новую конфигурацию, которая наследуется от конфигурации слоя boot , сюда мы передаём finder и набор названий модулей,
которые можно найти в будущем слое. Наконец, создаём слой, опять же используя boot. Ставим в качестве загрузчика CustomClassLoader. Теперь можно воспользоваться созданным слоем, достать для нашего модуля загрузчик и загрузить класс Main.

// module loading
// Main.java
public class Main {
    public static void main(String[] args) {
        try {
            Path modulePath = Paths.get("/path/to/loadable.jar");
            String moduleName = "manual.fewmodules.separately.nodeps.loadable";
            String mainClassName = "ru.ispras.j17.manual.fewmodules.separately.nodeps.loadable.Main";

            ModuleLayer layer = createLayer(modulePath, moduleName);
            ClassLoader loadableCL = layer.findLoader(moduleName);
            Class<?> mainClass = loadableCL.loadClass(mainClassName);

            System.out.println("[loading] Main Class Module is " + Main.class.getModule());
            System.out.println("[loading] Main Class ClassLoader is " + Main.class.getClassLoader());
            System.out.println("ClassLoader for [loadable] module is " + loadableCL);

            Method mainMethod = mainClass.getMethod(null, String[].class);
            mainMethod.invoke(null, (Object) args);
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }

    private static ModuleLayer createLayer(Path modulePath, String moduleName) { /* ... */ }
// module loadable
// Main.java
public class Main {
    public static void main(String[] args) {
        Cat cat = new Cat();

        System.out.println("[loadable] Main Class ClassLoader is " + Main.class.getClassLoader());
        System.out.println("[loadable] Cat Class ClassLoader is " + Cat.class.getClassLoader());

        System.out.println("[loadable] Main Class Module is " + Main.class.getModule());
        System.out.println("[loadable] Cat Class Module is " + Cat.class.getModule());

        cat.talk();
    }
}

Запускаем:

$ java17 -p loading-1.0.jar -m manual.fewmodules.separately.nodeps.loading

[loading] Main Class Module is module manual.fewmodules.separately.nodeps.loading
[loading] Main Class ClassLoader is jdk.internal.loader.ClassLoaders$AppClassLoader@4554617c
ClassLoader for [loadable] module is jdk.internal.loader.Loader@4a574795
[loadable] Main Class ClassLoader is jdk.internal.loader.Loader@4a574795
[loadable] Cat Class ClassLoader is jdk.internal.loader.Loader@4a574795
[loadable] Main Class Module is module manual.fewmodules.separately.nodeps.loadable
[loadable] Cat Class Module is module manual.fewmodules.separately.nodeps.loadable
Meow

Модули классов оказались такими, какими мы и хотели, но загрузчик класса Cat — не CustomClassLoader как мы ожидали, а некий Loader. Дело в том, что в методе defineModuleWithOneLoader происходит создание нового загрузчика Loader (тот самый, о котором я упоминал в примере Java 8: Auto Loading), родителем которого становится переданный CustomClassLoader.

private static ModuleLayer createLayer(Path modulePath, String moduleName) {
    /* ... */
    return bootLayer.defineModulesWithOneLoader(config, new CustomClassLoader());
}

Действительно, в документации так и написано:

public ModuleLayer defineModulesWithOneLoader(Configuration cf, ClassLoader parentLoader)

parentLoader — The parent class loader for the class loader created by this method; may be null for the bootstrap class loader.

Таким образом, у нас нет возможности как-то передать свой загрузчик классов в слой, лишь указать родителя для новосозданного. Причём, согласно той же документации, родителю загрузка класса будет делегирована только в случае, если Loader не найдёт пакет, в котором находится класс; можно придумывать разные хаки на основе этой модели делегирования.

Пример хака: каким-то образом портим название пакета, в котором лежит искомый класс, чтобы загрузка делегировалась к CustomClassLoader, а кастомный загрузчик, в свою очередь, исправлял бы имя пакета. Тогда классы, необходимые для исполнения кода искомого класса, тоже будут загружены с помощью CustomClassLoader. Если первый искомый класс будет классом Main, то и весь модуль загрузится с помощью CustomClassLoader.

Чтобы CustomClassLoader находил байткод классов, его нужно немного изменить:

// CustomClassLoader.java
public class CustomClassLoader extends ClassLoader {
    private Module module = null;

    public void setModule(Module module) {
        this.module = module;
    }

    /* ... */

    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        /* ... */
        try (InputStream inputStream = module.getResourceAsStream(classFile)) {
            /* ... */
        } catch (IOException e) {
            throw new ClassNotFoundException();
        }
    }
}

То есть загрузчику нужно передать модуль, классы которого он будет в будущем загружать, а после в методе findClass использовать метод getResourceAsStream от поля module, а не от this, как было до этого. Но будьте готовы к тому, что загруженные CustomClassLoader классы будут в unnamed модуле.

$ java17 -p . -m manual.fewmodules.separately.nodeps.loading

[loading] Main Class Module is module manual.fewmodules.separately.nodeps.hack.loading
[loading] Main Class ClassLoader is jdk.internal.loader.ClassLoaders$AppClassLoader@5c647e05
ClassLoader for [loadable] module is jdk.internal.loader.Loader@27716f4
[loadable] Main Class ClassLoader is ru.ispras.j17.manual.fewmodules.separately.nodeps.hack.loading.CustomClassLoader@5451c3a8
[loadable] Cat Class ClassLoader is ru.ispras.j17.manual.fewmodules.separately.nodeps.hack.loading.CustomClassLoader@5451c3a8
[loadable] Main Class Module is unnamed module @1b28cdfa
[loadable] Cat Class Module is unnamed module @1b28cdfa
Meow

Manual Loading (два независимых модуля и зависимость)

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

Добавим третий модуль manual.fewmodules.separately.withdeps.dependency (теперь префикс изменился и оканчивается на withdeps), сокращённо dependency.

// module dependency
// Dog.java
public class Dog {
    public void talk() {
        System.out.println("Woof");
    }
}
// module dependency
// module-info.java
module manual.fewmodules.separately.withdeps.dependency {
    exports ru.ispras.j17.manual.fewmodules.separately.withdeps.dependency;
}

Немного изменим модуль loadable:

// module loadable
// Main.java
public class Main {
    public static void main(String[] args) {
        Cat cat = new Cat();
        Dog dog = new Dog();

        System.out.println("[loadable] Main Class ClassLoader is " + Main.class.getClassLoader());
        System.out.println("[loadable] Cat Class ClassLoader is " + Cat.class.getClassLoader());
        System.out.println("[dependency] Dog Class ClassLoader is " + Dog.class.getClassLoader());

        System.out.println("[loadable] Main Class Module is " + Main.class.getModule());
        System.out.println("[loadable] Cat Class Module is " + Cat.class.getModule());
        System.out.println("[dependency] Dog Class Module is " + Dog.class.getModule());

        cat.talk();
        dog.talk();
    }
}
// module loadable
// module-info.java
module manual.fewmodules.separately.withdeps.loadable {
    requires manual.fewmodules.separately.withdes.dependency;

    exports ru.ispras.j17.manual.fewmodules.separately.withdeps.loadable;
}

Для загрузки нам в простейшем случае нужно всего лишь добавить новый модуль в слой:

// module loading
// Main.java
public class Main {
    public static void main(String[] args) {
        try {
            Path loadableModulePath = Paths.get("/path/to/loadable-1.0.jar");
            String loadableModuleName = "manual.fewmodules.separately.withdeps.loadable";
            Path dependencyModulePath = Paths.get("/path/to/dependency-1.0.jar");
            String dependencyModuleName = "manual.fewmodules.separately.withdeps.dependency";

            String mainClassName = "ru.ispras.j17.manual.fewmodules.separately.withdeps.loadable.Main";

            ModuleLayer layer = createLayer(
                    loadableModulePath, loadableModuleName,
                    dependencyModulePath, dependencyModuleName
            );
            /* ... */
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }

    private static ModuleLayer createLayer(Path mp1, String mn1, Path mp2, String mn2) {
        ModuleFinder finder = ModuleFinder.of(mp1, mp2);

        ModuleLayer bootLayer = ModuleLayer.boot();
        Configuration parentConfig = bootLayer.configuration();
        Configuration config = parentConfig.resolve(finder, ModuleFinder.of(), Set.of(mn1, mn2));
        return bootLayer.defineModulesWithOneLoader(config, new CustomClassLoader());
    }
}

Запускаем:

$ java17 -p . -m manual.fewmodules.separately.withdeps.loading

[loading] Main Class Module is module manual.fewmodules.separately.withdeps.loading
[loading] Main Class ClassLoader is jdk.internal.loader.ClassLoaders$AppClassLoader@5c647e05
ClassLoader for [loadable] module is jdk.internal.loader.Loader@30f39991
[loadable] Main Class ClassLoader is jdk.internal.loader.Loader@30f39991
[loadable] Cat Class ClassLoader is jdk.internal.loader.Loader@30f39991
[dependency] Dog Class ClassLoader is jdk.internal.loader.Loader@30f39991
[loadable] Main Class Module is module manual.fewmodules.separately.withdeps.loadable
[loadable] Cat Class Module is module manual.fewmodules.separately.withdeps.loadable
[dependency] Dog Class Module is module manual.fewmodules.separately.withdeps.dependency
Meow
Woof

Всё работает.

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

Заключение

Я рассмотрел с вами более десятка примеров загрузки классов в Java 8 и Java 17. Уверен, что эти примеры не покрывают всех возможных способов загрузки, но большинство — точно. Мы рассмотрели различия classpath и modulepath, выяснили, как поддержать старые библиотеки в проектах с модулями (плагины Gradle), увидели, какие ошибки могут возникать, а также рассмотрели работу с Module API.

Надеюсь, эта статья помогла вам лучше понять, как работают загрузчики классов на стыке версий Java 8 и 9+. Как мы увидели, модульная система не совсем проста в отношении загрузчиков, и некоторые фичи даже не понятно, как поддерживать (Auto Loading).

Напоминаю, что все рассмотренные в статье примеры я загрузил в один удобный репозиторий на GitHub, где вы можете посмотреть на код, запустить его и прочитать описание каждого примера в его README.md (он будет под примером). В репозитории поддерживается сборка через Gradle, но скрипты сборки используют только classpath, имейте это в виду; я рекомендую проверенный метод загрузки через консоль (как я и делал в статье).

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

Tags:
Hubs:
Total votes 23: ↑23 and ↓0+23
Comments0

Articles

Information

Website
www.ispras.ru
Registered
Founded
Employees
501–1,000 employees
Location
Россия