Classloader: динамичкеская загрузка классов

Вот вам простой вопрос: как идентифицируются классы в JVM? Правильный ответ: по полному имени класса, состоящее из имени пакета и собственного имени класса. Из этого напрашивается вывод, что в программе не может существовать два класса с одинаковыми полными именами. Однако, это не так. Как и в начальной школе, где нас учили правилу “на ноль делить нельзя”, а потом в старших классах рассказали про бесконечность, так и в Java есть “маленькая ложь”.

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

Об этом и других вкусностях Classloader‘а мы сегодня и поговорим.

Теория

Одной из основных особенностей Java является модель динамической загрузки классов, которая позволяет загружать исполняемый код в JRE не перезагружая основное приложение. Любой класс, используемый в среде исполнения так или иначе был загружен каким-либо загрузчиком в Java. К началу выполнения программы, создано три основных загрузчика:

  • Bootstrap Classloader - базовый загрузчик;
  • Extension Classloader - загрузчик расширений;
  • System Classloader - системный загрузчик.

Bootstrap Classloader реализован на уровне JVM и не имеет обратной связи со средой исполнения. Данным загрузчиком загружаются классы из директории $JAVA_HOME/lib и все базовые классы. Поэтому, попытка получения загрузчика у классов java.* всегда заканчивается null’ом. Но если очень хочется, то управлять загрузкой базовых классов можно с помощью ключа -Xbootclasspath, который позволяет переопределять наборы базовых классов.

Extension Classloader загружает классы из директории $JAVA_HOME/lib/ext. В Sun JRE - это класс sun.misc.Launcher$ExtClassLoader. Управлять загрузкой расширений можно с помощью системной опции java.ext.dirs.

System Classloader реализованный уже на уровне JRE. В Sun JRE — это класс sun.misc.Launcher$AppClassLoader. Этим загрузчиком загружаются классы, пути к которым указаны в переменной окружения CLASSPATH. Управлять загрузкой системных классов можно с помощью ключа -classpath или системной опцией.

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

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

Рассмотрим процесс загрузки более детально. Пусть во время выполнения программы встретилась декларация переменной использующую класс Bagira. Тогда процесс поиска и загрузки класса будет таким:

Теперь подумаем: какой класс будет реально загружен, если в $JAVA_HOME/lib/ext и в CLASSPATH есть классы с одинаковыми полными именами? Правильно, класс из $JAVA_HOME/lib/ext, а на CLASSPATH никто не посмотрит. Хотя с ним тоже не все просто: классы загружаются в том порядке, в котором они были указаны в CLASSPATH. По этому если указать два jar-файла, к примеру A.jar и B.jar, содержащие одинаковые классы, то в память загрузится класс из A.jar, а класс из B.jar будет пропущен.

Практика

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

  • загрузчик должен явно или неявно расширять класс java.lang.ClassLoader;
  • загрузчик должен поддерживать модель делегирования загрузки, образуя иерархию. Если этого не сделать, могут возникнуть проблемы с областью видимости;
  • в классе java.lang.ClassLoader уже реализован метод непосредственной загрузки - defineClass(), который байт-код преобразует в java.lang.Class, осуществляя его валидацию;
  • механизм рекурсивного поиска также реализован в классе java.lang.ClassLoader и заботиться об это не нужно;
  • для корректной реализации загрузчика достаточно лишь переопределить метод loadClass() класса java.lang.ClassLoader.

Наш загрузчик будет загружать jar-файлы не через java.net.URLClassLoader, а “вручную”. Так будет нагляднее рассмотреть весь процесс. А грузить мы будем плагины, которые будут располагаться в папке plugins/ и интерфейс для которых нужно описать:

1
2
3
4
5
package ru.dmitriymx.cl;

public interface IPlugin {
public void run();
}

Теперь напишем наш загрузчик. Правда мы здесь немного смухлюем и загрузим сразу все классы плагина в память:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
package ru.dmitriymx.cl;

import java.io.File;
import java.io.FilenameFilter;
import java.io.IOException;
import java.io.InputStream;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.Map;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;

public class PluginLoader extends ClassLoader {
private Map<String, Class<?>> cacheClass = new HashMap<>();
private ClassLoader parent;

public PluginLoader(ClassLoader parent) {
this.parent = parent;
}

@Override
public Class<?> loadClass(String name) throws ClassNotFoundException {
Class<?> result = cacheClass.get(name);

if (result == null) {
result = parent.loadClass(name);
}

return result;
}

public void loadPlugin(String strPluginFile) throws IOException {
JarFile jarFile = new JarFile("plugins/" + strPluginFile);

Enumeration<JarEntry> jarEntries = jarFile.entries();

while (jarEntries.hasMoreElements()) {
JarEntry jarEntry = jarEntries.nextElement();
if (jarEntry.isDirectory())
continue;

if (jarEntry.getName().endsWith(".class")) {
byte[] classData = loadClassData(jarFile, jarEntry);

if (classData != null) {
Class<?> clazz = defineClass(
jarEntry.getName().replace('/', '.').substring(0,jarEntry.getName().length() - 6),
classData, 0, classData.length);
cacheClass.put(clazz.getName(), clazz);
}
}
}
}

public void loadPlugins() throws IOException {
// получаем список jar-файлов

String[] jarList = new File("plugins/").list(new FilenameFilter() {
@Override
public boolean accept(File file, String s) {
return s.toLowerCase().endsWith(".jar");
}
});

// загружаем каждый плагин

for (String strJarFile : jarList) {
loadPlugin(strJarFile);
}
}

// получаем байт-код класса

private byte[] loadClassData(JarFile jarFile, JarEntry jarEntry)
throws IOException {
long size = jarEntry.getSize();
if (size <= 0)
return null;
else if (size > Integer.MAX_VALUE) {
throw new IOException("Class size too long");
}

byte[] buffer = new byte[(int) size];
InputStream is = jarFile.getInputStream(jarEntry);
is.read(buffer);

return buffer;
}
}

Теперь напишем основное приложение, использующее наш загрузчик

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package ru.dmitriymx;

import ru.dmitriymx.cl.IPlugin;
import ru.dmitriymx.cl.PluginLoader;

public class Main {

public static void main(String[] args) throws Exception {
PluginLoader loader = new PluginLoader(Main.class.getClassLoader());

loader.loadPlugins();

IPlugin plugin = (IPlugin) loader.loadClass("plugin.test.Plugin")
.newInstance();

plugin.run();
}
}

Отлично. Осталось написать сам плагин:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package plugin.test;

import ru.dmitriymx.cl.IPlugin;

public class Plugin implements IPlugin {

public Plugin() {
System.out.println("new object...");
}

@Override
public void run() {
System.out.println("execute method run()");
}

}

Упакуем наш плагин в jar и сохраним в папке plugins/.

Да, мы маленько “сжульничали”, жестко указав в основном приложении какой класс считать за точку входа, но это никак не скажется на общей картине происходящего. Наша программа успешно будет загружать плагин и выполнять его метод run().

Оу, чуть не забыл. “…в программе могут существовать два класса с одинаковым полным именем и не конфликтовать друг с другом…”, помню-помню, сейчас продемонстрирую.

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

PluginLoader loader1 = new PluginLoader(Main.class.getClassLoader());
PluginLoader loader2 = new PluginLoader(Main.class.getClassLoader());

loader1.loadPlugin("plugin1.jar");
loader2.loadPlugin("plugin2.jar");

IPlugin plugin1 = loader1.loadClass("plugin.test.Plugin").newInctanse();
IPlugin plugin2 = loader2.loadClass("plugin.test.Plugin").newInctanse();

plugin1.run();
plugin2.run();

Вуаля, у нас работают два одинаковых класса и при этом не конфликтуют. Однако, если мы попытаемся сделать вот так:

plugin1 = plugin2;

то JVM пошлет нас куда подальше (java.lang.ClassCastException), потому как для JVM это два абсолютно разных класса.

Думаю, на этом всё. Что не понятно пишем в комментариях.


Источники: