Correct posture of Java class isolation loading

What is class isolation technology

As long as you write enough Java code, this must happen: the system introduces a new middleware jar package. When compiling, everything is normal, and an error is reported as soon as it runs: java.lang.NoSuchMethodError. Then, you start to find a solution by Google. Finally, you can't find the conflicting jar until you are blind in hundreds of dependency packages, After solving the problem, Tucao started to make complaints about jar, and wrote code for five minutes.

The above situation is common in the process of Java development. The reason is very simple. Different jar packages depend on different versions of some general jar packages (such as Log components). There is no problem during compilation. At runtime, an error will be reported because the loaded classes do not meet the expectations. For example, A and B rely on v1 and v2 versions of C respectively. The Log of v2 version is compared with v1 version and the error method is added. Now, two jar packages A and B and v0.1 and v0.2 versions of C are introduced into the project. maven can only select one version of C when packaging. Suppose v1 is selected. When running, all classes of A project are loaded by the same class loader by default, so no matter how many versions of C you rely on, only one version of C will be loaded into the JVM in the end. When B wants to access Log.error, it will find that there is no error method in Log at all, and then throw the exception java.lang.NoSuchMethodError. This is A typical case of such conflict.

If the version is downward compatible, it is actually easy to solve the problem of class conflict. It is over to exclude the lower version. But if the version is not downward compatible, you will fall into the dilemma of "saving your mother or your girlfriend".

In order to avoid the dilemma, class isolation technology is proposed to solve the problem of class conflict. The principle of class isolation is also very simple, that is, let each module use an independent class loader to load, so that the dependencies between different modules will not affect each other. As shown in the figure below, different modules are loaded with different class loaders. Why does this resolve class conflicts? A Java mechanism is used here: classes loaded by different class loaders are two different classes in the view of the JVM, because the unique identification of a class in the JVM is class loader + class name. In this way, we can load two different versions of C classes at the same time, even if its class name is the same. Note that class loaders here refer to instances of class loaders. It is not necessary to define two different loaders. For example, pluginclassloader A and pluginclassloader B in the figure can be different instances of the same class loader.

How to implement class isolation

We mentioned earlier that class isolation is to let the jar packages of different modules load with different class loaders. To do this, you need to enable the JVM to use a custom class loader to load the classes we write and their associated classes.

So how to achieve it? A very simple way is that the JVM provides a setting interface for the global class loader, so we can directly replace the global class loader, but this can not solve the problem of multiple custom class loaders at the same time.

In fact, the JVM provides a very simple and effective way, which I call class loading conduction rule: the JVM will select the class loader of the current class to load all the referenced classes of the class. For example, we have defined two classes, TestA and TestB. TestA will reference TestB. As long as we load TestA with a custom class loader, when TestA calls TestB at runtime, TestB will also be loaded by the JVM using TestA's class loader. By analogy, all jar package classes associated with TestA and its reference classes will be loaded by the custom class loader. In this way, as long as the main method class of the module is loaded with different class loaders, the class of each module will be loaded with the class loader of the main method class, so that multiple modules can use different class loaders respectively. This is also the core principle that OSGi and SofaArk can realize class isolation.

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

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

Override findClass

First, we define two classes. TestA will print its own class loader and then call TestB to print its class loader. We expect that the class loader MyClassLoaderParentFirst that rewrites the findClass method can enable TestB to be loaded automatically by MyClassLoaderParentFirst 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 rewrite the findClass method, which first loads the class file according to the file path and then calls defineClass to get 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");
    }

    //  Rewritten   findClass   method
    @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, write a main method to call the custom class loader to load TestA, and then print the information of the class loader by calling the main method of TestA through reflection.

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 results of the implementation are as follows:

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

The execution result is not as we expected. TestA is indeed loaded by MyClassLoaderParentFirst, but TestB is loaded by AppClassLoader. Why?

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 parental delegation:

  1. Delegate to parent loader query

  2. If the parent loader cannot query, it calls the findClass method to load

After understanding this rule, the reason for the execution result is found: the JVM does use MyClassLoaderParentFirst to load TestB, but because of the parental delegation mechanism, TestB is entrusted to the parent loader AppClassLoader of MyClassLoaderParentFirst to load.

You may also wonder why the parent loader of MyClassLoaderParentFirst is AppClassLoader? Because the main method classes we defined are loaded by the AppClassLoader built in JDK by default, according to the class loading conduction rules, the MyClassLoaderParentFirst referenced by the main class is also loaded by the AppClassLoader loaded with the main class. Since the parent class of MyClassLoaderParentFirst is ClassLoader, the default constructor of ClassLoader will automatically set the value of the parent loader to AppClassLoader.

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

Override loadClass

Rewriting the findClass method will be affected by the parental delegation mechanism, resulting in TestB being loaded by AppClassLoader, which does not meet the goal of class isolation. Therefore, we can only rewrite the loadClass method to destroy the parental delegation mechanism. The code is as follows:

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 use   JDK   Class loader loading   java.lang   Class in 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) { //ellipsis  }

}

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

The test code is as follows:

public class MyTest {

    public static void main(String[] args) throws Exception {
        //Here, take the parent loader of AppClassLoader, that is, ExtClassLoader, 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 results are as follows:

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

As you can see, by rewriting the loadClass method, we successfully loaded TestB into the JVM using MyClassLoaderCustom.

summary

Class isolation technology is born to solve dependency conflict. It destroys the parent delegation mechanism through custom class loader, and then uses class loading conduction rules to realize class isolation of different modules.

reference material

Explore Java class loader in depth

Tags: Java C++ Javascript Programming Spring

Posted on Tue, 30 Nov 2021 06:50:31 -0500 by fr0mat