×
Community Blog Implementing Java Class Isolation Loading

Implementing Java Class Isolation Loading

This article analyzes the causes of JAR package conflicts and the implementation principles of class isolation and shares two methods for implementing custom class loaders.

By Xiao Hansong (Xiaokai)

During Java development, if different JAR packages depend on different versions of some common JAR packages, errors may occur during running because the loaded class is not the same as expected. How can we avoid this situation? This article analyzes the causes of JAR package conflicts and the implementation principles of class isolation and shares two methods for implementing custom class loaders.

1

1. What Is Class Isolation?

If you write enough Java code, this will definitely happen. The system has introduced a new middleware JAR package. Everything is normal during compiling, but an error will be reported as soon as it runs: java.lang.NoSuchMethodError. Then, you begin to find a solution and find a conflicting JAR package in hundreds of dependency packages. After solving the problem, you start to be frustrated with the middleware because it has so many different versions of JAR packages. You only wrote your code for five minutes but spent the whole day arranging the packages.

The situation above is common in the Java development process. The reason is simple. Different JAR packages depend on different versions of some common JAR packages, such as log components. Therefore, there is no problem when compiling, but an error is reported at runtime because the loaded class does not match the expectation. For example, A and B depend on V1 and V2 of C, respectively. Compared with V1, the log class of V2 adds the error method. Now, the project also introduces two JAR packages, A and B, as well as V0.1 and V0.2 of C. When packaging, Maven can only select one version of C, assuming V1 is selected. By default, all classes of a project are loaded with the same class loader, so no matter how many versions of C you rely on, eventually, only one version of C will be loaded into the Java Virtual Machine (JVM). When B attempts to access the log.error, it finds is no error method of the log. Then, it throws an exception: java.lang.NoSuchMethodError. This is a typical case of class conflict.

2

If the version is backward compatible, the class conflict problem can be solved easily. You only need to exclude the lower version. However, if the version is not backward compatible, you will have a dilemma.

Some people have proposed the class isolation technology to solve the class conflict problem and avoid the dilemma. The principle of class isolation is also very simple. It uses an independent class loader to load each module, so dependencies between different modules do not affect each other. As shown in the following figure, different modules are loaded with different class loaders. Why does this solve the class conflict? Here, a Java mechanism is used. Classes loaded by different class loaders are considered as two different classes in the JVM because the unique identifier of a class in the JVM is the class loader + class name. This way, we can load the classes of two different versions of C at the same time, even if their class names are the same. Note: The class loader refers to an instance of the class loader, and it is unnecessary to define two different class loaders. For example, in the figure, PluginClassLoaderA and PluginClassLoaderB can be different instances of the same class loader.

3

2. Implementing Class Isolation

As mentioned earlier, class isolation allows the JAR packages of different modules to be loaded by different class loaders. To achieve this, it is necessary to enable the JVM to load the classes we write and their associated classes using a custom class loader.

How can we achieve this? A very simple solution is that the JVM provides a setting interface for the global class loader to directly replace the global class loader. However, this cannot solve the problem that multiple custom class loaders exist at the same time.

The JVM provides a simple and effective way. I call it the class loading conduction rule: The JVM will select the class loader of the current class to load all of the referenced classes of the class. For example, we have defined two classes, TestA and TestB. TestA will reference TestB. As long as we use a custom class loader to load TestA, when TestA calls TestB, TestB will also be loaded by the JVM using the class loader of TestA. Then, TestA and all JAR package classes that are associated with its reference classes will be loaded by the custom class loader. This way, as long as we let the main method class of the module load using a different class loader, then each module will load using the class loader of the main method class. This allows multiple modules to use different class loaders separately. This is also the core principle of Open Service Gateway Initiative (OSGi) and SOFAArk to implement class isolation.

After understanding the implementation principle of class isolation, let's start with rewriting the class loader. To implement our class loader, first, we should let the custom class loader inherit java.lang.ClassLoader, and then override the class loading method. Here, we have two choices, one is to override findClass(String name), and the other is to override loadClass(String name). So, which one should we choose? What is the difference between the two choices?

Next, we will try to override these two methods to implement the custom class loader.

2.1 Override FindClass

First, we define two classes. TestA will print its class loader and then call TestB to print its class loader. We expect the class loader MyClassLoaderParentFirst, which overrides the findClass method, will automatically load TestB after loading TestA.

public class TestA {

    public static void main(String[] args) {
        TestA testA = new TestA();
        testA.hello();
    }

    public void hello() {
        System.out.println("TestA: " + this.getClass().getClassLoader());
        TestB testB = new TestB();
        testB.hello();
    }
}

public class TestB {

    public void hello() {
        System.out.println("TestB: " + this.getClass().getClassLoader());
    }
}

Then, we need to override the findClass method, which loads the class file based on the file path, and then calls defineClass to obtain the Class object.

public class MyClassLoaderParentFirst extends ClassLoader{

    private Map<String, String> classPathMap = new HashMap<>();

    public MyClassLoaderParentFirst() {
        classPathMap.put("com.java.loader.TestA", "/Users/hansong/IdeaProjects/OhMyJava/CodeRepository/target/classes/com/java/loader/TestA.class");
        classPathMap.put("com.java.loader.TestB", "/Users/hansong/IdeaProjects/OhMyJava/CodeRepository/target/classes/com/java/loader/TestB.class");
    }

    // The findClass method is overridden
    @Override
    public Class<? > findClass(String name) throws ClassNotFoundException {
        String classPath = classPathMap.get(name);
        File file = new File(classPath);
        if (! file.exists()) {
            throw new ClassNotFoundException();
        }
        byte[] classBytes = getClassData(file);
        if (classBytes == null || classBytes.length == 0) {
            throw new ClassNotFoundException();
        }
        return defineClass(classBytes, 0, classBytes.length);
    }

    private byte[] getClassData(File file) {
        try (InputStream ins = new FileInputStream(file); ByteArrayOutputStream baos = new
                ByteArrayOutputStream()) {
            byte[] buffer = new byte[4096];
            int bytesNumRead = 0;
            while ((bytesNumRead = ins.read(buffer)) ! = -1) {
                baos.write(buffer, 0, bytesNumRead);
            }
            return baos.toByteArray();
        } catch (FileNotFoundException e) {
            e.printStackTrace();
        } catch (IOException e) {
            e.printStackTrace();
        }
        return new byte[] {};
    }
}

Finally, you can write the main method to call the custom class loader to load TestA, and then call the main method of TestA through reflection to print the information of the class loader.

public class MyTest {

    public static void main(String[] args) throws Exception {
        MyClassLoaderParentFirst myClassLoaderParentFirst = new MyClassLoaderParentFirst();
        Class testAClass = myClassLoaderParentFirst.findClass("com.java.loader.TestA");
        Method mainMethod = testAClass.getDeclaredMethod("main", String[].class);
        mainMethod.invoke(null, new Object[]{args});
    }

The execution result is listed below:

TestA: com.java.loader.MyClassLoaderParentFirst@1d44bcfa
TestB: sun.misc.Launcher$AppClassLoader@18b4aac2

The execution result is not the same as expected. TestA was loaded by MyClassLoaderParentFirst, but TestB was still loaded by AppClassLoader. Why does this happen?

To answer this question, we first need to understand a class loading rule: The JVM calls the ClassLoader.loadClass method when triggering class loading. This method implements the parent-parent delegation mechanism:

  • Delegate the parent loader to query
  • If the parent loader cannot query, we can call the findClass method to load it.

After understanding this rule, we find the reason for the execution result: The JVM uses MyClassLoaderParentFirst to load TestB, but because of the parent-parent delegation mechanism, TestB is entrusted to the AppClassLoader, the parent loader of MyClassLoaderParentFirst, for loading.

You may also wonder why the parent loader of MyClassLoaderParentFirst is AppClassLoader. The main method class we define is loaded by the AppClassLoader that comes with Java Development Kit (JDK) by default. According to the class loading conduction rules, MyClassLoaderParentFirst is referenced by the main class and loaded by the AppClassLoader that loads the main class. Since the parent class of MyClassLoaderParentFirst is ClassLoader, the default construction method of ClassLoader automatically sets the value of the parent loader to AppClassLoader.

protected ClassLoader() {
    this(checkCreateClassLoader(), getSystemClassLoader());
}

2.2. Override the LoadClass

Overriding the findClass method is affected by the parent-parent delegation mechanism, causing AppClassLoader to load TestB to be loaded, which does not meet the class isolation target. Therefore, we can only override the loadClass method to destroy the parent-parent delegation mechanism. The code is shown below:

public class MyClassLoaderCustom extends ClassLoader {

    private ClassLoader jdkClassLoader;

    private Map<String, String> classPathMap = new HashMap<>();

    public MyClassLoaderCustom(ClassLoader jdkClassLoader) {
        this.jdkClassLoader = jdkClassLoader;
        classPathMap.put("com.java.loader.TestA", "/Users/hansong/IdeaProjects/OhMyJava/CodeRepository/target/classes/com/java/loader/TestA.class");
        classPathMap.put("com.java.loader.TestB", "/Users/hansong/IdeaProjects/OhMyJava/CodeRepository/target/classes/com/java/loader/TestB.class");
    }

    @Override
    protected Class<? > loadClass(String name, boolean resolve) throws ClassNotFoundException {
        Class result = null;
        try {
            // Here we need to use the class loader of JDK to load the classes included in the java.lang package.
            result = jdkClassLoader.loadClass(name);
        } catch (Exception e) {
            // Ignore
        }
        if (result ! = null) {
            return result;
        }
        String classPath = classPathMap.get(name);
        File file = new File(classPath);
        if (! file.exists()) {
            throw new ClassNotFoundException();
        }

        byte[] classBytes = getClassData(file);
        if (classBytes == null || classBytes.length == 0) {
            throw new ClassNotFoundException();
        }
        return defineClass(classBytes, 0, classBytes.length);
    }

    private byte[] getClassData(File file) { // Omit }

}

Note: We have overridden the loadClass method, which means that all of the classes (including the classes in the java.lang package) will be loaded through MyClassLoaderCustom. However, the target of class isolation does not include these classes that come with JDK, so we use ExtClassLoader to load JDK classes. The relevant code is: result = jdkClassLoader.loadClass(name).

The test code is listed below:

public class MyTest {

    public static void main(String[] args) throws Exception {
        // Here we take the parent loader of AppClassLoader, that is, ExtClassLoader, which is taken as the jdkClassLoader of MyClassLoaderCustom.
        MyClassLoaderCustom myClassLoaderCustom = new MyClassLoaderCustom(Thread.currentThread().getContextClassLoader().getParent());
        Class testAClass = myClassLoaderCustom.loadClass("com.java.loader.TestA");
        Method mainMethod = testAClass.getDeclaredMethod("main", String[].class);
        mainMethod.invoke(null, new Object[]{args});
    }
}

The execution result is listed below:

TestA: com.java.loader.MyClassLoaderCustom@1d44bcfa
TestB: com.java.loader.MyClassLoaderCustom@1d44bcfa

After overriding the loadClass method, we have successfully loaded TestB into the JVM using MyClassLoaderCustom.

3. Summary

The class isolation technology was created to solve dependency conflicts. It destroys the parent-parent delegation mechanism by customizing the class loader and then implements class isolation for different modules using the class loading conduction rules.

Reference

Explore the Java Class Loader (article in Chinese)

0 0 0
Share on

HansongXiao

2 posts | 0 followers

You may also like

Comments

HansongXiao

2 posts | 0 followers

Related Products