背景
类加载就是把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>