背景
类加载就是把class文件加载到jvm中,加载过程是需要加载器的,jvm中不止一个加载器来进行加载,主要有三种加载器,Bootstrap ClassLoader,Extension ClassLoader,App 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进行加载,失败后自己再加载呢?

上图是抽象类,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文件。

找到这个文件后,会将其读取到内存中,最终调用native方法defineClass来定义这个class文件,并返回Class对象。
如果一个URLClassLoader下有多个URL,比如指定了两个jar包,里面都含有com.test.A类的话,会按照URL的优先级来进行加载,优先级高的jar包会覆盖优先级低的jar包。所以在URLClassLoader中有findResource和findResources两个方法,前者是找到优先级最高的生效的资源,后者则是如果有多个资源,则返回多个资源。与loadClass类似的ClassLoader中也有个getResource方法,也是委托的方式来实现的。

通俗讲findResource/findClass就是当前加载器自身的scope范围内去找,getResource/loadClass默认是要先委托给parent去找。
解决jdk8 tools问题
接下来我会以几个我的项目中使用类加载器的例子,来展示如何利用类加载器机制来解决问题。jdk8中tools包是没有被默认引入的,如果要想使用jdk目录下的tools.jar中的类,则需要自行引入。一种比较丑陋的引入方式,是直接把tools打包到自己的项目的jar包中,但是这个方案也有较大的问题,因为不同的平台的tools是不同的,所以需要根据不同的平台引入不同的tools包,另外不同版本的jdk也是不同的情况。甚至jdk9之后tools包被默认加到了rt中,不需要单独引入了。
所以最好的方式是,先判断tools是否被正常加载了,如果没有的话,则从java_home/lib/tools.jar下手动加载。
// 判断当前类加载器是否是自定义的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[],然后再由类加载器将字节码加载成类,并调用特定的方法就可以了。
但是类加载需要用spring的classloader,以便于访问项目中的类,因为spring并没有使用默认的AppClassLoader,而是使用spring的classloader,叫做LaunchedURLClassLoader,这是因为spring boot独特的打包方式导致的,会将所有的依赖都打包到jar包中,并且不是shade那种把所有依赖的jar中的class都解压出来拷贝到新的jar包,而是直接将所有的依赖的jar,直接塞到最终的jar包中。如下图,BOOT-INF目录是我们自己项目的class放到classes目录,以及我们依赖的jar放到了lib目录。


而spring实现了特殊的类加载方式,可以加载jar中的jar,并且URL目录是xx.jar!/BOOT-INF。
上面是背景,回归正题,attach的时候的类加载器是AppClassLoader,是无法访问到SpringBoot加载器目录下的这些类的,怎么办?
直接用LaunchedURLClassLoader来loadClass我们的编译工具得到的byte[],是不行的,因为ClassLoader并没有一个public的load byte[]的方法(defineClass是protected)。这里的解决方式是,通过自定义一个ClassLoader,指定parent为LaunchedURLClassLoader,然后重写loadClass方法,判断是w.Exec这个类,那么就调用defineClass加载对应的byte[],这样就可以调用protected的defineClass方法了。
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。
对于这个问题,最简单的解决方案就是,直接把swapper中shade打包的时候,把jackson的包名改了。
<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的机制来注册自身作为jackson的Module,因为我们把类名改了,所以现在是wshade.com.xxx.Module了。

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

注入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()));

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

这种就不太好直接改groovy这个文件中的字符串的值了,所以这个方案成本有点高。
方案二
另一种妥协的方案,是不修改包名,指定特定的类加载器来加载GroovyScriptEngineImpl
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资源。

这里WGroovyClassLoader与其他不同,他的loadClass方法是自定义的加载顺序,并不是按照默认的双亲委派机制,而是groovy相关的类,直接用findClass在自己的resource下寻找并加载,而其他的类,则完全委托给parent加载。
这样,groovy相关的包,都从swapper.jar中找,其他的都从springCl,swapper中的其他类,则是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扫描可以自动注入的扩展模块,扫描的路径如下。

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

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

一个比较简单的解决方案就是,把WGroovyClassLoader的getResource、getResources方法给改了,改成直接调用findResource findResources方法。
方案四
还是基于方案二中的bug,另一个改造方案就是修改WGroovyClassLoader,入参中传入的springCL不赋值到this.parent,而是把他作为一个普通的delegate字段,把parent指向ext或者bootstrap classloader即可。然后重写loadClass:
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兜底加载。

方案五
方案二有bug,方案三和方案四是两种解决bug的思路。但是他们都有一个无法避免的问题,就是如果宿主jar用的groovy版本和swapper的groovy版本有冲突的话,宿主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:
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,这里不再赘述了,加载器的关系如下:

这里把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>