概述
ClassLoader
顾名思义,即是类加载器。
虚拟机把class字节码文件加载到内存,并对数据进行检验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这就是虚拟机的类加载机制。
JVM启动的时候,并不会一次性加载所有的class文件,而是根据需要去动态加载。了解Java的类加载机制,可以快速解决运行时的各种加载问题并快速定位其背后的本质原因,也是解决疑难杂症的利器。因此学好类加载原理也至关重要。
.class文件
.class
文件是字节码格式文件,是.java文件编译生成的文件,该类文件可以被Java虚拟机识别加载。
ClassLoader的加载过程
类从被加载到虚拟机内存到被卸载,整个完整的生命周期包括:类加载
、验证
、准备
、解析
、初始化
、使用
和卸载
七个阶段。其中验证,准备,解析三个部分统称为连接。接下来我们可以详细了解下类加载的各个过程。
ClassLoader的整个加载过程还是非常复杂的,具体的细节可以参考《深入理解java虚拟机》进行深入了解。为了方便记忆,我们可以使用一句话来表达其加载的整个过程,“家宴准备了西式菜”,即家(加载)宴(验证)准备(准备)了西(解析)式(初始化)菜。保证你以后能够很快的想起来。
虽然ClassLoader的加载过程有复杂的5步,但事实上除了加载之外的四步,其它都是由JVM虚拟机控制的,我们除了适应它的规范进行开发外,能够干预的空间并不多。而加载则是我们控制ClassLoader实现特殊目的最重要的手段了。也是接下来我们介绍的重点了。
注:ClassLoader加载字节码文件生成的是类变量(Class),而不是具体的实例化变量。后续为类变量分配内存,初始化也只针对static变量以及静态代码块。
ClassLoader双亲委托机制
ClassLoader的双亲委托机制
是指多个类加载器之间存在父子关系的时候,某个Class类具体由哪个加载器进行加载的问题。
其具体的过程表现为:当一个类加载的过程中,它首先不会去加载,而是委托给自己的父类去加载,父类又委托给自己的父类。因此所有的类加载都会委托给顶层的父类,即Bootstrap Classloader
进行加载,然后父类自己无法完成这个加载请求,子加载器才会尝试自己去加载。
使用双亲委派模型,Java类随着它的加载器一起具备了一种带有优先级的层次关系,通过这种层次模型,可以避免类的重复加载,也可以避免核心类被不同的类加载器加载到内存中造成冲突和混乱,从而保证了Java核心库的安全。
整个Java虚拟机的类加载层次关系如下:
- 启动类加载器(Bootstrap ClassLoader):负责将 <JAVA_HOME>/lib 目录下并且被虚拟机识别的类库加载到虚拟机内存中。我们常用基础库,例如java.util.,java.io.,java.lang.**等等都是由根加载器加载。可以通过启动jvm时指定-Xbootclasspath路径来改变Bootstrap ClassLoader的加载目录,比如:java -Xbootclasspath/a:path
- 扩展类加载器(Extention ClassLoader):负责加载JVM扩展类,比如swing系列、内置的js引擎、xml解析器等,这些类库以javax开头,它们的jar包位于 <JAVA_HOME>/lib/ext 目录中。可以通过-D java.ext.dirs 指定目录。
- 应用程序类加载器(Application ClassLoader):负责加载用户路径 ClassPath 上所指定的类库。我们自己编写的类以及使用的第三方的jar包都是由它来加载的。
- 自定义类加载器(Custom ClassLoader):通常是我们为了某些特殊目的实现的自定义加载器。
双亲委托机制看起来比较复杂,但是其本身的核心代码逻辑却是非常的清晰简单,我们着重抽取了类加载的双亲委托的核心代码如下,不过二十行左右。
Bootstrap ClassLoader
创建一个类Stu,获取它的类加载器。
package 类加载器;
public class ClassLoaderDemo {
public static void main(String[] argv){
ClassLoader classLoader = Stu.class.getClassLoader();
System.out.println(classLoader);
System.out.println(classLoader.getParent());
System.out.println(classLoader.getParent().getParent());
/* 获取String的类加载器*/
System.out.println(String.class.getClassLoader());
}
}
class Stu{}
输出:
sun.misc.Launcher$AppClassLoader@18b4aac2
sun.misc.Launcher$ExtClassLoader@4b1210ee
null
null
结果看出,自己编写类的 ClassLoader 是 AppClassLoader
。
但是,为什么String的类加载器是null,理论上应该是Bootstrap ClassLoader。为什么ExtClassLoader的父加载器也是null,理论上应该也是Bootstrap ClassLoader。
Bootstrap ClassLoader是由C/C++编写的,它本身是虚拟机的一部分,所以它并不是一个JAVA类,也就是无法在java代码中获取它的引用。
JVM启动时通过Bootstrap类加载器加载rt.jar等核心jar包中的class文件,之前的int.class,String.class都是由它加载。然后,JVM初始化sun.misc.Launcher并创建Extension ClassLoader和AppClassLoader实例。
自定义类加载器
不管是 Bootstrap ClassLoader 还是 ExtClassLoader 等,这些类加载器都只是加载指定的目录下的jar包或者资源。如果在某种情况下,我们需要动态加载一些东西呢?比如从D盘某个文件夹加载一个class文件,或者从网络上下载class主内容然后再进行加载,这就需要自定义类加载器。
步骤:
- 编写一个类,继承抽象类
ClassLoader
。 - 重写
findClass()
。 - 在
findClass()
方法中调用defineClass()
。
protected final Class<?> defineClass(String name, byte[] b, int off, int len)
这个方法在编写自定义ClassLoader的时候非常重要,它能将class二进制内容转换成Class对象,如果不符合要求的会抛出各种异常。
注: 一个ClassLoader创建时如果没有指定parent,那么它的parent默认就是AppClassLoader。
上面说的是,如果自定义一个ClassLoader,默认的parent父加载器是AppClassLoader,因为这样就能够保证它能访问系统内置加载器加载成功的class文件。
DiskClassLoader
自定义DiskClassLoader,继承ClassLoader,默认加载路径为D:\lib下的jar包和资源。
package 类加载器;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
public class DiskClassLoader extends ClassLoader {
private String mLibPath;
public DiskClassLoader(String path) {
// TODO Auto-generated constructor stub
mLibPath = path;
}
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
// TODO Auto-generated method stub
String fileName = getFileName(name);
File file = new File(mLibPath,fileName);
try {
FileInputStream is = new FileInputStream(file);
ByteArrayOutputStream bos = new ByteArrayOutputStream();
int len = 0;
try {
while ((len = is.read()) != -1) {
bos.write(len);
}
} catch (IOException e) {
e.printStackTrace();
}
byte[] data = bos.toByteArray();
is.close();
bos.close();
return defineClass(name,data,0,data.length);
} catch (IOException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
return super.findClass(name);
}
//获取要加载 的class文件名
private String getFileName(String name) {
// TODO Auto-generated method stub
int index = name.lastIndexOf('.');
if(index == -1){
return name+".class";
}else{
return name.substring(index+1)+".class";
}
}
}
findClass()
中定义了查找 class字节码文件的逻辑,并生成字节数组,然后获取的字节数组通过defineClass()
生成了Class对象。
Test.java
编写一个测试用的类文件,Test.java。
package 类加载器;
public class Test {
public void say(){
System.out.println("Say Hello");
}
}
然后将它编译过年class文件Test.class放到 D:\lib 。
ClassLoaderTest
package 类加载器;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
public class ClassLoaderTest {
public static void main(String[] args) {
// TODO Auto-generated method stub
//创建自定义classloader对象。
DiskClassLoader diskLoader = new DiskClassLoader("D:\\lib");
try {
//加载class文件
Class c = diskLoader.loadClass("类加载器.Test");
if(c != null){
try {
Object obj = c.newInstance();
Method method = c.getDeclaredMethod("say",null);
//通过反射调用Test类的say方法
method.invoke(obj, null);
} catch (InstantiationException | IllegalAccessException
| NoSuchMethodException
| SecurityException |
IllegalArgumentException |
InvocationTargetException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
} catch (ClassNotFoundException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
}
运行结果:
Say Hello
Class c = diskLoader.loadClass("类加载器.Test");
调用父类的方法 loadClass()
,父类的findClass()
没有找到,再调用自定义类加载器 DiskClassLoader 的findClass()
,最终找到需要的类。
可以看到,Test类的say方法正确执行,也就是我们写的 DiskClassLoader 加载自定义类成功。
突破了JDK系统内置的类加载器限制后,我们可以自定义ClassLoader,可以按照业务进行定制化。
常见用法是将Class文件按照某种加密手段进行加密,然后编写自定义ClassLoader进行相应的解密,这样我们就可以在程序中加载特定的类,降低了程序被窃取的风险,提高了程序安全性。
代码参考:https://frank909.blog.csdn.net/article/details/54973413
ContextClassLoader 线程上下文类加载器
contextClassLoader
只是类Thread的一个成员变量,通过setContextClassLoader()方法设置,通过getContextClassLoader()获取。
public class Thread implements Runnable {
/* The context ClassLoader for this thread */
private ClassLoader contextClassLoader;
public void setContextClassLoader(ClassLoader cl) {
SecurityManager sm = System.getSecurityManager();
if (sm != null) {
sm.checkPermission(new RuntimePermission("setContextClassLoader"));
}
contextClassLoader = cl;
}
public ClassLoader getContextClassLoader() {
if (contextClassLoader == null)
return null;
SecurityManager sm = System.getSecurityManager();
if (sm != null) {
ClassLoader.checkClassLoaderPermission(contextClassLoader,
Reflection.getCallerClass());
}
return contextClassLoader;
}
}
每个Thread都有一个相关联的ClassLoader,默认是AppClassLoader。并且子线程默认使用父线程的ClassLoader,除非子线程特别设置。
注意:AppClassLoader加载不了父线程当中已经加载的类对象内容。
如果子线程需要加载父线程已经加载的类对象,需要重新设置setContextClassLoader()。
Thread.currentThread().setContextClassLoader(diskLoader1);
ClassLoader的应用场景
类加载器是Java语言的一项创新,也是Java语言流行的重要原因这一。通过灵活定义ClassLoader的加载机制,我们可以完成很多事情,例如解决类冲突问题
,实现热加载以及热部署
,甚至可以实现jar包的加密保护
。接下来,我们会针对这些特殊场景进行逐一介绍。
依赖冲突
做过多人协同开发的大型项目的同学可能深有感触。基于maven的pom进制可以方便的进行依赖管理,但是由于maven依赖的传递性,会导致我们的依赖错综复杂,这样就会导致引入类冲突的问题。最典型的就是NoSuchMethodException异常了。
先我们用一个非常简单的场景来描述为什么会出现类冲突的问题。
![](https://i-blog.csdnimg.cn/blog_migrate/d789b0a9162610ee7fa49b1682318454.png)
某个业务引用了消息中间件、微服务中间件,这两个中间件也同时引用了fastjson-2.0和fastjson-3.0版本,而业务自己本身也引用了fastjson-1.0版本。这三个版本表现不同之处在于classA类中方法数目不相同,我们根据maven依赖处理的机制,引用路径最短的fastjson-1.0会真正作为应用最终的依赖,其它两个版本的fastjson则会被忽略,那么中间件在调用method2()方法的时候,则会抛出方法找不到异常。
或许你会说,将所有依赖fastjson的版本都升级到3.0不是就能解解决问题吗?确实这样能够解决问题,但是在实际操作中不太现实,首先,中间件团队和业务团队之间并不是一个团队,并不能做到高效协同,其次是中间件的稳定性是需要保障的,不可能因为包冲突问题,就升级版本,更何况一个中间件依赖的包可能有上百个,如果纯粹依赖包升级来解决,不仅稳定性难以保障,排包耗费的时间恐怕就让人窒息了。
那如何解决包冲突的问题呢?答案就是pandora(潘多拉),通过自定义类加载器,为每个中间件自定义一个加载器,这些加载器之间的关系是平行的,彼此没有依赖关系。这样每个中间件的 classloader 就可以加载各自版本的 fastjson。因为一个类的全限定名以及加载该类的加载器两者共同形成了这个类在JVM中的惟一标识,这也是阿里pandora实现依赖隔离的基础。
![](https://i-blog.csdnimg.cn/blog_migrate/2c6d82d0b0a3f04014cd1527bae0aa71.png)
热加载
在开发项目的时候,我们需要频繁的重启应用进行程序调试,但是java项目的启动少则几十秒,多则几分钟。如此慢的启动速度极大地影响了程序开发的效率,那是否可以快速的进行启动,进而能够快速的进行开发验证呢?答案也是肯定的,通过 classloader 我们可以完成对变更内容的加载,然后快速的启动。
常用的热加载方案有好几个,接下来我们介绍下spring官方推荐的热加载方案,即spring boot devtools
。
首先我们需要思考下,为什么重新启动一个应用会比较慢,那是因为在启动应用的时候,JVM虚拟机需要将所有的应用程序重新装载到整个虚拟机。可想而知,一个复杂的应用程序所包含的jar包可能有上百兆,每次微小的改动都是全量加载,那自然是很慢了。那么我们是否可以做到,当我们修改了某个文件后,在JVM中替换到这个文件相关的部分而不全量的重新加载呢?而 spring boot devtools 正是基于这个思路进行处理的。
![](https://i-blog.csdnimg.cn/blog_migrate/e364d27def1edfc815bf597b8fc0da6b.png)
如上图所示,通常一个项目的代码由以上四部分组成,即基础类、扩展类、二方包/三方包、以及我们自己编写的业务代码组成。上面的一排是我们通常的类加载结构,其中业务代码和二方包/三方包是由应用加载器加载的。而实际开发和调试的过程中,主要变化的是业务代码,并且业务代码相对二方包/三方包的内容来说会更少一些。
因此我们可以将业务代码单独通过一个自定义的加载器 CustomClassloader 来进行加载 ,当监控发现业务代码发生改变后,我们重新加载启动,老的业务代码的相关类则由虚拟机的垃圾回收机制来自动回收。其工程流程大概如下。有兴趣的同学可以去看下源码,会更加清楚。
![](https://i-blog.csdnimg.cn/blog_migrate/97fa8c9b6d47206119e320dc04db8d4e.png)
RestartClassLoader
为自定义的类加载器,其核心是loadClass的加载方式,我们发现其通过修改了双亲委托机制,默认优先从自己加载,如果自己没有加载到,从从parent进行加载。这样保证了业务代码可以优先被RestartClassLoader加载。进而通过重新加载 RestartClassLoader 即可完成应用代码部分的重新加载。
![](https://i-blog.csdnimg.cn/blog_migrate/7b31844736fa97a1914ef3b64f71bd13.png)
热部署
热部署本质其实与热加载并没有太大的区别,通常我们说热加载是指在开发环境中进行的 classloader 加载,而热部署则更多是指在线上环境使用 classloader 的加载机制完成业务的部署。所以这二者使用的技术并没有本质的区别。那热部署除了与热加载具有发布更快之外,还有更多的更大的优势就是具有更细的发布粒度。我们可以想像以下的一个业务场景。
![](https://i-blog.csdnimg.cn/blog_migrate/25f8eeddda2dd0b0bc015786aa146921.png)
假设某个营销投放平台涉及到4个业务方的开发,需要对会场业务进行投放。而这四个业务方的代码全部都在一个应用里面。因此某个业务方有代码变更则需要对整个应用进行发布,同时其它业务方也需要跟着回归。因此每个微小的发动,则需要走整个应用的全量发布。这种方式带来的稳定性风险估且不说,整个发布迭代的效率也可想而知了。这在整个互联网里,时间和效率就是金钱的理念下,显然是无法接受的。
那么我们完全可以通过类加载机制,将每个业务方通过一个 classloader 来加载。基于类的隔离机制,可以保障各个业务方的代码不会相互影响,同时也可以做到各个业务方进行独立的发布。其实在移动客户端,每个应用模块也可以基于类加载,实现插件化发布。本质上也是一个原理。
加密保护
众所周期,基于 java 开发编译产生的 jar包 是由 .class 字节码组成,由于字节码的文件格式是有明确规范的。因此对于字节码进行反编译,就很容易知道其源码实现了。因此大致会存在如下两个方面的诉求。例如在服务端,我们向别人提供三方包实现的时候,不希望别人知道核心代码实现,我们可以考虑对 jar包进行加密,在客户端则会比较普遍,那就是我们打包好的 apk的安装包,不希望被人家反编译而被人家翻个底朝天,我们也可以对 apk进行加密。
jar包加密的本质,还是对字节码文件进行操作。但是 JVM虚拟机加载 class的规范是统一的,因此我们在最终加载class文件的时候,还是需要满足其class文件的格式规范,否则虚拟机是不能正常加载的。因此我们可以在打包的时候对class进行正向的加密操作,然后,在加载class文件之前通过自定义 classloader 先进行反向的解密操作,然后再按照标准的class文件标准进行加载,这样就完成了class文件正常的加载。因此这个加密的jar包只有能够实现解密方法的 classloader 才能正常加载。
![](https://i-blog.csdnimg.cn/blog_migrate/e9a59f1d76c3107450f2a2e06e97f1fe.png)
我们可以贴一下简单的实现方案:
![](https://i-blog.csdnimg.cn/blog_migrate/5c477a4ce54da13a8d656a6eb87384c2.png)
这样整个jar包的安全性就有一定程度的提高,至于更高安全的保障则取决于加密算法的安全性了以及如何保障加密算法的密钥不被泄露的问题了。这有种套娃的感觉,所谓安全基本都是相对的。并且这些方法也不是绝对的,例如可以通过对classloader进行插码,对解密后的class文件进行存储;另外大多数JVM本身并不安全,还可以修改JVM,从ClassLoader之外获取解密后的代码并保存到磁盘,从而绕过上述加密所做的一切工作,当然这些操作的成本就比单纯的class反编译就高很多了。所以说安全保障只要做到使对方破解的成本高于收益即是安全,所以一定程度的安全性,足以减少很多低成本的攻击了。
Spring自动注入遇到的坑:不同类加载器加载进同一个class,类型无法匹配
https://www.jianshu.com/p/a06ce873495b
总结
- ClassLoade r用来加载class字节码文件,生成类对象。
- 系统内置的 ClassLoader 通过双亲委托机制来加载指定路径下的 class 和资源。
- ContextClassLoader 与线程相关,可以获取和设置类加载器。
- 灵活运行自定义ClassLoader,可以解决类冲突问题,实现热加载以及热部署,甚至可以实现jar包的加密保护。
本文对classloader的加载过程和加载原理进行了介绍,并结合类加载机制的特征,介绍了其相应的使用场景。由于篇幅限制,并没有对每种场景的具体实现细节进行介绍,而只是阐述了其基本实现思路。或许大家觉得classloader的应用有些复杂,但事实上只要大家对class从哪里加载,搞清楚loadClass的机制,就已经成功了一大半。正所谓万变不离其宗,抓住了本质,其它问题也就迎刃而解了。
参考文章:
https://frank909.blog.csdn.net/article/details/54973413
https://blog.csdn.net/lsy0903/article/details/113869116