Java 类加载器 ClassLoader

一、依赖冲突

做过多人协同开发的大型项目的同学可能深有感触。基于maven的pom进制可以方便的进行依赖管理,但是由于maven依赖的传递性,会导致依赖错综复杂,这样就会导致引入类冲突的问题。最典型的就是NoSuchMethodException异常了。
该如何避免依赖冲突的问题呢?首先用一个非常简单的场景来描述为什么会出现类冲突的问题。
2021-07-10-10-52-01-507086.jpeg
某个业务引用了消息中间件(例如metaq)和微服务中间件(例如dubbo),这两个中间件也同时引用了fastjson-2.0和fastjson-3.0版本,而业务自己本身也引用了fastjson-1.0版本。这三个版本表现不同之处在于classA类中方法数目不相同,根据maven依赖处理的机制,引用路径最短的fastjson-1.0会真正作为应用最终的依赖,其它两个版本的fastjson则会被忽略,那么中间件在调用method2()方法的时候,则会抛出方法找不到异常。
那如何解决包冲突的问题呢?答案就是pandora(潘多拉),通过自定义类加载器,为每个中间件自定义一个加载器,这些加载器之间的关系是平行的,彼此没有依赖关系。这样每个中间件的classloader就可以加载各自版本的fastjson。因为一个类的全限定名以及加载该类的加载器两者共同形成了这个类在JVM中的惟一标识,这也是阿里pandora实现依赖隔离的基础。
2021-07-10-10-52-01-646079.jpeg
可能到这里,又会有新的疑惑,根据双亲委托模型,App Classloader分别继承了Custom Classloader.那么业务包中的fastjson的class在加载的时候,会先委托到Custom ClassLoader。这样不就会导致自身依赖的fastjson版本被忽略吗?确实如此,所以潘多拉又是如何做的呢?
2021-07-10-10-52-01-909081.jpeg
首先每个中间件对应的ModuleClassLoader在加载中间对应的class文件的同时,根据中间件配置的export.index负责将要需要透出的class(主要是提供api接口的相关类)索引到exportedClassHashMap中,然后应用程序的类加载器会持有这个exportedClassHashMap,因此应用程序代码在loadClass的时候,会优先判断exportedClassHashMap是否存在当前类,如果存在,则直接返回,如果不存在,则再使用传统的双亲委托机制来进行类加载。这样中间件MoudleClassloader不仅实现了中间件的加载,也实现了中间件关键服务类的透出。
可以大概看下应用程序类加载的过程
Java 类加载器(ClassLoader)的实际使用场景 - 图4

二、热加载

在开发项目的时候,需要频繁的重启应用进行程序调试,但是java项目的启动少则几十秒,多则几分钟。如此慢的启动速度极大地影响了程序开发的效率,那是否可以快速的进行启动,进而能够快速的进行开发验证呢?答案也是肯定的,通过classloader可以完成对变更内容的加载,然后快速的启动。
常用的热加载方案有好几个,接下来介绍下spring官方推荐的热加载方案,即spring boot devtools。
首先需要思考下,为什么重新启动一个应用会比较慢,那是因为在启动应用的时候,JVM虚拟机需要将所有的应用程序重新装载到整个虚拟机。可想而知,一个复杂的应用程序所包含的jar包可能有上百兆,每次微小的改动都是全量加载,那自然是很慢了。那么是否可以做到,当修改了某个文件后,在JVM中替换到这个文件相关的部分而不全量的重新加载呢?而spring boot devtools正是基于这个思路进行处理的。
2021-07-10-10-52-02-266078.jpeg
如上图所示,通常一个项目的代码由以上四部分组成,即基础类、扩展类、二方包/三方包、以及自己编写的业务代码组成。上面的一排是通常的类加载结构,其中业务代码和二方包/三方包是由应用加载器加载的。而实际开发和调试的过程中,主要变化的是业务代码,并且业务代码相对二方包/三方包的内容来说会更少一些。因此可以将业务代码单独通过一个自定义的加载器Custom Classloader来进行加载,当监控发现业务代码发生改变后,重新加载启动,老的业务代码的相关类则由虚拟机的垃圾回收机制来自动回收。其工程流程大概如下。有兴趣的同学可以去看下源码,会更加清楚。
Java 类加载器(ClassLoader)的实际使用场景 - 图6
RestartClassLoader为自定义的类加载器,其核心是loadClass的加载方式,可以发现其通过修改了双亲委托机制,默认优先从自己加载,如果自己没有加载到,从从parent进行加载。这样保证了业务代码可以优先被RestartClassLoader加载。进而通过重新加载RestartClassLoader即可完成应用代码部分的重新加载。
2021-07-10-10-52-02-773934.jpeg

三、热部署

热部署本质其实与热加载并没有太大的区别,通常说热加载是指在开发环境中进行的classloader加载,而热部署则更多是指在线上环境使用classloader的加载机制完成业务的部署。所以这二者使用的技术并没有本质的区别。那热部署除了与热加载具有发布更快之外,还有更多的更大的优势就是具有更细的发布粒度。可以想像以下的一个业务场景。
2021-07-10-10-52-03-065747.jpeg
假设某个营销投放平台涉及到4个业务方的开发,需要对会场业务进行投放。而这四个业务方的代码全部都在一个应用里面。因此某个业务方有代码变更则需要对整个应用进行发布,同时其它业务方也需要跟着回归。因此每个微小的发动,则需要走整个应用的全量发布。这种方式带来的稳定性风险估且不说,整个发布迭代的效率也可想而知了。这在整个互联网里,时间和效率就是金钱的理念下,显然是无法接受的。
那么完全可以通过类加载机制,将每个业务方通过一个classloader来加载。基于类的隔离机制,可以保障各个业务方的代码不会相互影响,同时也可以做到各个业务方进行独立的发布。其实在移动客户端,每个应用模块也可以基于类加载,实现插件化发布。本质上也是一个原理。

四、加密保护

众所周知,基于java开发编译产生的jar包是由.class字节码组成,由于字节码的文件格式是有明确规范的。因此对于字节码进行反编译,就很容易知道其源码实现了。因此大致会存在如下两个方面的诉求。例如在服务端,向别人提供三方包实现的时候,不希望别人知道核心代码实现,可以考虑对jar包进行加密,在客户端则会比较普遍,那就是打包好的apk的安装包,不希望被人家反编译而被人家翻个底朝天,也可以对apk进行加密。
2021-07-10-10-52-03-212761.jpeg