java_类加载的真相

20 min read,created at 2025-05-18
javaclassloader

背景

类加载就是把class文件加载到jvm中,加载过程是需要加载器的,jvm中不止一个加载器来进行加载,主要有三种加载器,Bootstrap ClassLoaderExtension ClassLoaderApp ClassLoader分别来加载jre中的类,扩展类,用户类。

类加载器+类名,可以唯一确定jvm中的一个类,即Class对象,如果这个类已经存在了那么会直接返回,不会再读取一遍class文件,这样可以提高效率很合理。不同的类加载器,主要是为了区分不同的class文件的位置,比如Bootstrap ClassLoader主要是加载jdk的核心类的目录下的class文件,Extension ClassLoader主要是加载jdk的扩展目录下的class文件,App ClassLoader主要是加载classpath下的class文件。

为了防止用户自己定义的类与jdk的类冲突,所以java类加载机制采用了双亲委派模型。这个模型我们也可以叫他委托模型,即ClassLoader会先尝试委托给父类加载器即this.parent,如果父类加载器无法加载,那么才会自己加载。这样如果我们的classpath下定义一个java.lang.String,我们使用的时候,会用App ClassLoader去加载,此时App ClassLoader会委托给Ext ClassLoader去加载,Ext ClassLoader会委托给Bootstrap ClassLoader去加载,后者加载成功,就直接返回了,返回的String是在jdk目录的,而不是classpath下的。这样就很好的保护了核心类,防止被篡改。

这里有两个疑问:

  • jvm如何实现一个类加载器+类名,唯一确定的类,只会加载一次,后续会用缓存,不会重复加载的呢?
  • jvm如何实现的先让parent进行加载,失败后自己再加载呢?

image

上图是抽象类,ClassLoader类中最重要的方法loadClass的默认实现,流程就是先从缓存中找到自己是否已经加载过这个类findLoadedClass方法,如果没有的话,会看this.parent.loadClass是否成功。如果没有parent的话,默认的parent其实是BootStrapClassLoader。如果也没有记载成功,则会调用自身的findClass方法,这个方法默认是空的,需要子类去实现。

URLClassLoader

最常见的ClassLoader的实现类就是URLClassLoader,因为类加载器主要是用来区分不同的class文件加载路径的,URLClassLoader就是指定特定的URL路径去加载的,App ClassLoader就是URLClassLoader的子类。例如new URLClassLoader(new URL[]{new URL("file:/D:/test.jar")})就可以加载D:/test.jar中的class文件了。在URLClassLoader中,findClass就是逐个从URL路径中寻找有没有对应的class文件。

image

找到这个文件后,会将其读取到内存中,最终调用native方法defineClass来定义这个class文件,并返回Class对象。

如果一个URLClassLoader下有多个URL,比如指定了两个jar包,里面都含有com.test.A类的话,会按照URL的优先级来进行加载,优先级高的jar包会覆盖优先级低的jar包。所以在URLClassLoader中有findResourcefindResources两个方法,前者是找到优先级最高的生效的资源,后者则是如果有多个资源,则返回多个资源。与loadClass类似的ClassLoader中也有个getResource方法,也是委托的方式来实现的。

image

通俗讲findResource/findClass就是当前加载器自身的scope范围内去找,getResource/loadClass默认是要先委托给parent去找。

解决jdk8 tools问题

接下来我会以几个我的项目中使用类加载器的例子,来展示如何利用类加载器机制来解决问题。jdk8tools包是没有被默认引入的,如果要想使用jdk目录下的tools.jar中的类,则需要自行引入。一种比较丑陋的引入方式,是直接把tools打包到自己的项目的jar包中,但是这个方案也有较大的问题,因为不同的平台的tools是不同的,所以需要根据不同的平台引入不同的tools包,另外不同版本的jdk也是不同的情况。甚至jdk9之后tools包被默认加到了rt中,不需要单独引入了。

所以最好的方式是,先判断tools是否被正常加载了,如果没有的话,则从java_home/lib/tools.jar下手动加载。

Attach.java
// 判断当前类加载器是否是自定义的WClassLoader
if (!Attach.class.getClassLoader().toString().startsWith(WClassLoader.class.getName())) {
    // 如果不是的话,则检查jdk版本是否是1.8
    String jdkVersion = System.getProperty("java.version");
    if (jdkVersion.startsWith("1.")) {
        if (jdkVersion.startsWith("1.8")) {
            try {
                // 如果是1.8则用自定义的类加载器加载当前类
                // 这个类加载器中会指定当前jar包路径 + tools.jar路径
                // parent指向AppClassLoader的parent,即ext classloader
                // 这样与原来的loader对齐
                WClassLoader customClassLoader = new WClassLoader(
                        new URL[]{toolsJarUrl(), currentUrl()},
                        ClassLoader.getSystemClassLoader().getParent()
                );
                // 重新用WClassLoader加载当前类
                Class<?> mainClass = Class.forName("w.Attach", true, customClassLoader);
                Method mainMethod = mainClass.getMethod("main", String[].class);
                // 并运行main方法,此时会重新进入到这段代码,但是第一行的判断
                // 就是由WClassLoader加载的,会跳过这段
                mainMethod.invoke(null, (Object) args);
                return;
            } catch (Exception e) {
                e.printStackTrace();
                System.exit(-1);
            }
        } else {
            Global.error(jdkVersion + " is not supported");
            return;
        }
    }
}
// 这里可以使用tools中的类了
List<VirtualMachineDescriptor> jps = VirtualMachine.list();

思考一下上面代码,为什么不能直接换成:

if (javaVersion.startsWith("1.8")) {
    // urlClassLoader来加载tools.jar
    URLClassLoader urlClassLoader = new URLClassLoader(
        new URL[]{toolsJarURL()}
    );
}
List<VirtualMachineDescriptor> jps = VirtualMachine.list();

这样肯定是不行的,有一条很重要的定律:当前类的函数中,用到的其他类,都是由当前类的加载器来加载的。所以上面VirtualMachineDescriptor这个类是用Attach这个类的类加载器即App classloader去加载的,不会用自定义的urlClassLoader,因而我们需要用前面重新加载类,并重新调用main方法的方式。

用Spring的classloader运行自定义代码

小工具使用attch的方式,注入到宿主jvm进程,并且希望传入一段代码来进行运行。传入的代码可以用一些编译框架把源码编译成字节码byte[],然后再由类加载器将字节码加载成类,并调用特定的方法就可以了。

但是类加载需要用springclassloader,以便于访问项目中的类,因为spring并没有使用默认的AppClassLoader,而是使用springclassloader,叫做LaunchedURLClassLoader,这是因为spring boot独特的打包方式导致的,会将所有的依赖都打包到jar包中,并且不是shade那种把所有依赖的jar中的class都解压出来拷贝到新的jar包,而是直接将所有的依赖的jar,直接塞到最终的jar包中。如下图,BOOT-INF目录是我们自己项目的class放到classes目录,以及我们依赖的jar放到了lib目录。

image

image

spring实现了特殊的类加载方式,可以加载jar中的jar,并且URL目录是xx.jar!/BOOT-INF

上面是背景,回归正题,attach的时候的类加载器是AppClassLoader,是无法访问到SpringBoot加载器目录下的这些类的,怎么办?

直接用LaunchedURLClassLoaderloadClass我们的编译工具得到的byte[],是不行的,因为ClassLoader并没有一个public的load byte[]的方法(defineClassprotected)。这里的解决方式是,通过自定义一个ClassLoader,指定parentLaunchedURLClassLoader,然后重写loadClass方法,判断是w.Exec这个类,那么就调用defineClass加载对应的byte[],这样就可以调用protecteddefineClass方法了。

ExecClassLoader
class ExecClassLoader extends ClassLoader {
    public ExecClassLoader(ClassLoader parent) {
        super(parent);
    }
    @Override
    public Class<?> loadClass(String name) throws ClassNotFoundException {
        if (!name.equals(EXEC_CLASS)) {
            return super.loadClass(name);
        }
        try {
            byte[] bytes = WCompiler.compileWholeClass("package w; public class Exec { public void exec() {} }");
            return defineClass(EXEC_CLASS, bytes, 0, bytes.length);
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }
}

然后使用这个新的类加载器加载w.Exec这个“虚空”类,并反射调用exec方法。

Class<?> c = new ExecClassLoader(springClassLoader()).loadClass("w.Exec");
Object inst = c.newInstance();
inst.getClass().getDeclaredMethod("exec").invoke(inst);

解决jackson问题

swapper中有json序列化需求,引入了jackson库。这会导致如果宿主jar也使用了jackson会导致两种情况:

  • 如果非spring fat jar,那用户类和swapper都是AppClassLoader加载的,只能有一个jackson的类生效的,如果版本存在冲突,可能导致应用崩溃。
  • 如果是spring jar,那初次加载,LaunchedURLClassLoader会委派给AppClassLoader导致使用的swapper中依赖的jackson,项目如果不兼容这个版本就崩溃了。当然还有可能是项目已经加载过jackson后,我们又attach的,这种情况,倒是不会有冲突,因为已经项目加载过,会用缓存中正确的结果,而swapper中,则使用AppCl能加载到的位置,就只有自己内部的jackson

对于这个问题,最简单的解决方案就是,直接把swappershade打包的时候,把jackson的包名改了。

pom.xml
<plugin>
	<groupId>org.apache.maven.plugins</groupId>
	<artifactId>maven-shade-plugin</artifactId>
	<version>3.5.0</version>
	<configuration>
		<relocations>
			<relocation>
				<pattern>com.fasterxml.jackson</pattern>
				<shadedPattern>wshade.com.fasterxml.jackson</shadedPattern>
			</relocation>
		</relocations>
	</configuration>
	<executions>
		<execution>
			<phase>package</phase>
			<goals>
				<goal>shade</goal>
			</goals>
		</execution>
	</executions>
</plugin>

但是这个改完,不见得完事大吉,对于改包名的,尤其要关注是否有影响SPI。例如为了支持java.time的序列化,我们还引入了jsr310这个依赖,他里面使用了SPI的机制来注册自身作为jacksonModule,因为我们把类名改了,所以现在是wshade.com.xxx.Module了。

image

这里就需要自己加一个SPI的配置在项目中。

image

注入Groovy REPL

attach后还希望注入一个groovy的repl来执行groovy代码,与Exec类似,都是运行一段自定义代码,但是不同的是Exec是一个简单的自定义类,直接用ClassLoader#defineClass就加载了。而groovy是一些复杂的依赖jar组成的。我们就没办法简单的用ExecClassLoader来创建GroovyEngineImpl类了,就需要用URLClassLoader到特定的目录下去加载groovy.jar了。

方案一

一种比较实用的思路,就是首先和jackson的思路一样,修改了groovy的包名,然后把各种SPI的文件名都给更新了。这样在swapper中使用的GroovyEngineImpl就是新的包名,不会和宿主jar包中的groovy冲突。

然后为了能访问spring项目中的类,需要把GroovyEngineImpl的加载器指向springClassLoder

GroovyScriptEngineImpl engine = 
    new GroovyScriptEngineImpl(new GroovyClassLoader(springClassLoader()));

image

这个方案需要把以上6个包名都重写,并且还有个比较麻烦的事情,是groovy中有较多指定的字符串变量作为URL来加载resource的,比如

image

这种就不太好直接改groovy这个文件中的字符串的值了,所以这个方案成本有点高。

方案二

另一种妥协的方案,是不修改包名,指定特定的类加载器来加载GroovyScriptEngineImpl

WGroovyClassLoader.java
class WGroovyClassLoader extends URLClassLoader {
    public WGroovyClassLoader(ClassLoader parent) throws Exception {
        super(new URL[] { currentUrl() }, parent);
    }
    @Override
    public Class<?> loadClass(String name) throws ClassNotFoundException {
        if (name.startsWith("org.apache.groovy") || name.startsWith("org.codehaus.groovy") || name.startsWith("groovy") || name.startsWith("w.core.GroovyBundle")) {
            Class<?> c = findLoadedClass(name);
            if (c != null) return c;
            return findClass(name);
        }
        return super.loadClass(name);
    }
}

注意看这个类加载器,继承URLClassLoader并且指定了url为只从swapper.jar加载,这里的parent会指定为springClassLoader

这是目前的ClassLoader与对应的Resource资源。

image

这里WGroovyClassLoader与其他不同,他的loadClass方法是自定义的加载顺序,并不是按照默认的双亲委派机制,而是groovy相关的类,直接用findClass在自己的resource下寻找并加载,而其他的类,则完全委托给parent加载。

这样,groovy相关的包,都从swapper.jar中找,其他的都从springClswapper中的其他类,则是springCl再向上委托给AppCl加载的。

代码中,同样也需要更换类加载器,反射重新调用自己,的过程。

{
    static WGroovyClassLoader cl;
    static GroovyScriptEngineImpl engine;
    static {
        if (GroovyBundle.class.getClassLoader().toString().startsWith(WGroovyClassLoader.class.getName())) {
            try {
                engine = new GroovyScriptEngineImpl(new GroovyClassLoader());
                Global.info("Groovy Engine Initialization finished");
                if (SpringUtils.isSpring()) {
                    engine.put("ctx", SpringUtils.getSpringBootApplicationContext());
                }
            } catch (Exception e) {
                Global.error("Could not load Groovy Engine", e);
            }
        } else {
            try {
                cl = new WGroovyClassLoader(Global.getClassLoader());
            } catch (Exception e) {
                Global.error("Could not init Groovy Classloader", e);
            }
        }
    }
    public static Object eval(String script) throws Exception {
        if (cl != null) {
            Thread.currentThread().setContextClassLoader(cl);
            Class<?> bundle = cl.loadClass(GroovyBundle.class.getName());
            return bundle.getDeclaredMethod("eval", String.class).invoke(null, script);
        }
        return engine.eval(script);
    }
}

方案三

方案二有个bug,当我们的宿主jar也引入了jsr223或其他Groovy-Module的时候可能会报错,因为Groovy运行时,有个必要的MetaClassRegisterImpl类,构造方法中,会进行ExtensionModuleScanner#scan扫描可以自动注入的扩展模块,扫描的路径如下。

image

就会扫描到jsr223下的这个文件

image

同时如果hostjar中也引入了不同版本的jsr223,通过getResources也是能获取到的,因为getResources也是委托的形式,到parent中也会查找一遍。这样就会加载多个不同版本的jsr223,多个版本就会导致报错,如下:

image

一个比较简单的解决方案就是,把WGroovyClassLoadergetResource、getResources方法给改了,改成直接调用findResource findResources方法。

方案四

还是基于方案二中的bug,另一个改造方案就是修改WGroovyClassLoader,入参中传入的springCL不赋值到this.parent,而是把他作为一个普通的delegate字段,把parent指向ext或者bootstrap classloader即可。然后重写loadClass:

WGroovyClassLoader
class WGroovyClassLoader extends URLClassLoader {
    private final ClassLoader delegate;
    public WGroovyClassLoader(ClassLoader delegate) throws Exception {
        super(new URL[] { currentUrl() }, ClassLoader.getSystemClassLoader().getParent());
        this.delegate = delegate;
    }
    @Override
    public Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
        if (name.startsWith("w.") && !name.equals(GroovyBundle.class.getName())) {
            return delegate.loadClass(name);
        }
        try {
            Class<?> c = findLoadedClass(name);
            if (c != null) return c;
            c = findClass(name);
            return c;
        } catch (ClassNotFoundException e) {
            return delegate.loadClass(name);
        }
    }
}

整体的关系就变成了下面这样,这里把SpringCL作为WGroovyCL的一个普通属性,在loadClass中打破了委派关系,变成由springCL加载w开头的类,然后由当前类加载器在swapper.jar中优先查找并加载,找不到的由springCL兜底加载。

image

方案五

方案二有bug,方案三和方案四是两种解决bug的思路。但是他们都有一个无法避免的问题,就是如果宿主jar用的groovy版本和swappergroovy版本有冲突的话,宿主jar加载类的时候,会先委托给app classloader,然后就使用到我们的swapper中的类了。

为了实现真正的隔离,又不改包名,那么可以把groovy的几个jar包放到swapper.jar!/W-INF/lib!下,来效仿springboot的打包方式。这里写一个JarInJarClassLoader专门加载swapper.jar/W-INF/lib!/下的jar包,他的parent直接指向Ext或者NUll,来保证这个类加载器只能加载swapper.jar!/W-INF/lib!下的jar包。

这里继续用WGroovyClassLoader来加载GroovyBundle,把他的parent指向JarInJarClassLoader,这样GroovyBundle启动的时候可以调用jarinjar中的groovy类。然后还需要把delegate指向springCL

WGroovyClassLoader.java
public static class WGroovyClassLoader extends URLClassLoader {
    private final ClassLoader delegate;
    public WGroovyClassLoader(ClassLoader parent, ClassLoader delegate) throws Exception {
        super(new URL[] { currentUrl() }, parent);
        this.delegate = Global.getClassLoader();
    }
    @Override
    public Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
        // For entrypoint class, must load it by self
        if (name.equals(GroovyBundle.class.getName())) {
            Class<?> c = findLoadedClass(name);
            if (c != null) return c;
            return findClass(name);
        }
        try {
            // For groovy, need to load it by parent(jarInJarClassLoader)
            return getParent().loadClass(name);
        } catch (ClassNotFoundException e) {
            // Else load it by delegate(Global.getClassLoader())
            return delegate.loadClass(name);
        }
    }
}

JarInJarClassLoader的实现参考JarInJarClassLoader.java,这里不再赘述了,加载器的关系如下:

image

这里把groovy相关的三个依赖都打包到W-INF/lib!/下的方式如下:

<plugin>
	<groupId>org.apache.maven.plugins</groupId>
	<artifactId>maven-dependency-plugin</artifactId>
	<version>3.3.0</version>
	<executions>
		<execution>
			<id>copy-dependencies</id>
			<phase>prepare-package</phase>
			<goals>
				<goal>copy-dependencies</goal>
			</goals>
			<configuration>
				<outputDirectory>${project.build.directory}/classes/W-INF/lib</outputDirectory>
				<includeGroupIds>org.apache.groovy</includeGroupIds>
				<overWriteReleases>false</overWriteReleases>
				<overWriteSnapshots>false</overWriteSnapshots>
				<overWriteIfNewer>true</overWriteIfNewer>
			</configuration>
		</execution>
	</executions>
</plugin>
<plugin>
	<groupId>org.apache.maven.plugins</groupId>
	<artifactId>maven-shade-plugin</artifactId>
	<version>3.5.0</version>
	<configuration>
		<artifactSet>
			<excludes>
				<exclude>org.apache.groovy:*</exclude>
			</excludes>
	    </artifactSet>
	</configuration>
	<!-- 省略 其他配置 -->
</plugin>