Java 类加载器 — Classloader 初探
classloader 顾名思义,即是类加载。虚拟机把描述类的数据从 class 字节码文件加载到内存,并对数据进行检验、转换解析和初始化,最终形成可以被虚拟机直接使用的 Java 类型,这就是虚拟机的类加载机制。
了解 java 的类加载机制,可以快速解决运行时的各种加载问题并快速定位其背后的本质原因,也是解决疑难杂症的利器。因此学好类加载原理也至关重要。
classloader 的加载过程
类从被加载到虚拟机内存到被卸载,整个完整的生命周期包括:类加载、验证、准备、解析、初始化、使用和卸载七个阶段。其中验证,准备,解析三个部分统称为连接。接下来我们可以详细了解下类加载的各个过程。
classloader 的整个加载过程还是非常复杂的,具体的细节可以参考《深入理解 java 虚拟机》进行深入了解。为了方便记忆,我们可以使用一句话来表达其加载的整个过程,“家宴准备了西式菜”,即家(加载)宴(验证)准备(准备)了西(解析)式(初始化)菜。保证你以后能够很快的想起来。
虽然 classloader 的加载过程有复杂的 5 步,但事实上除了加载之外的四步,其它都是由 JVM 虚拟机控制的,我们除了适应它的规范进行开发外,能够干预的空间并不多。而加载则是我们控制 classloader 实现特殊目的最重要的手段了。也是接下来我们介绍的重点了。
classloader 双亲委托机制
classloader 的双亲委托机制是指多个类加载器之间存在父子关系的时候,某个 class 类具体由哪个加载器进行加载的问题。其具体的过程表现为:当一个类加载的过程中,它首先不会去加载,而是委托给自己的父类去加载,父类又委托给自己的父类。因此所有的类加载都会委托给顶层的父类,即 Bootstrap Classloader 进行加载,然后父类自己无法完成这个加载请求,子加载器才会尝试自己去加载。使用双亲委派模型,Java 类随着它的加载器一起具备了一种带有优先级的层次关系,通过这种层次模型,可以避免类的重复加载,也可以避免核心类被不同的类加载器加载到内存中造成冲突和混乱,从而保证了 Java 核心库的安全。
整个 java 虚拟机的类加载层次关系如上图所示,启动类加载器(Bootstrap Classloader)负责将
扩展类加载器(Extention Classloader)负责加载 JVM 扩展类,比如 swing 系列、内置的 js 引擎、xml 解析器等,这些类库以 javax 开头,它们的 jar 包位于
应用程序加载器(Application Classloader)也叫系统类加载器,它负责加载用户路径(ClassPath)上所指定的类库。我们自己编写的代码以及使用的第三方的 jar 包都是由它来加载的。
自定义加载器(Custom Classloader)通常是我们为了某些特殊目的实现的自定义加载器,后面我们得会详细介绍到它的作用以及使用场景。
双亲委托机制看起来比较复杂,但是其本身的核心代码逻辑却是非常的清晰简单,我们着重抽取了类加载的双亲委托的核心代码如下,不过二十行左右。
classloader 的应用场景
类加载器是 java 语言的一项创新,也是 java 语言流行的重要原因这一。通过灵活定义 classloader 的加载机制,我们可以完成很多事情,例如解决类冲突问题,实现热加载以及热部署,甚至可以实现 jar 包的加密保护。接下来,我们会针对这些特殊场景进行逐一介绍。
依赖冲突
做过多人协同开发的大型项目的同学可能深有感触。基于 maven 的 pom 进制可以方便的进行依赖管理,但是由于 maven 依赖的传递性,会导致我们的依赖错综复杂,这样就会导致引入类冲突的问题。最典型的就是 NoSuchMethodError 错误。
那么当一个项目引入不同的中间件的时候,该如何避免依赖冲突的问题呢?首先我们用一个非常简单的场景来描述为什么会出现类冲突的问题。
某个业务引用了消息中间件(例如 metaq)和微服务中间件(例如 dubbo),这两个中间件也同时引用了 fastjson-2.0 和 fastjson-3.0 版本,而业务自己本身也引用了 fastjson-1.0 版本。这三个版本表现不同之处在于 classA 类中方法数目不相同,我们根据 maven 依赖处理的机制,引用路径最短的 fastjson-1.0 会真正作为应用最终的依赖,其它两个版本的 fastjson 则会被忽略,那么中间件在调用 method2()方法的时候,则会抛出方法找不到异常。或许你会说,将所有依赖 fastjson 的版本都升级到 3.0 不是就能解解决问题吗?确实这样能够解决问题,但是在实际操作中不太现实,首先,中间件团队和业务团队之间并不是一个团队,并不能做到高效协同,其次是中间件的稳定性是需要保障的,不可能因为包冲突问题,就升级版本,更何况一个中间件依赖的包可能有上百个,如果纯粹依赖包升级来解决,不仅稳定性难以保障,排包耗费的时间恐怕就让人窒息了。
那如何解决包冲突的问题呢?答案就是 pandora(潘多拉),通过自定义类加载器,为每个中间件自定义一个加载器,这些加载器之间的关系是平行的,彼此没有依赖关系。这样每个中间件的 classloader 就可以加载各自版本的 fastjson。因为一个类的全限定名以及加载该类的加载器两者共同形成了这个类在 JVM 中的惟一标识,这也是阿里 pandora 实现依赖隔离的基础。
可能到这里,你又会有新的疑惑,根据双亲委托模型,App Classloader 分别继承了 Custom Classloader.那么业务包中的 fastjson 的 class 在加载的时候,会先委托到 Custom ClassLoader。这样不就会导致自身依赖的 fastjson 版本被忽略吗?确实如此,所以潘多拉又是如何做的呢?
首先每个中间件对应的 ModuleClassLoader 在加载中间对应的 class 文件的同时,根据中间件配置的 export.index 负责将要需要透出的 class(主要是中间件 api 接口的相关类)索引到 exportedClassHashMap 中,然后应用程序的类加载器会持有这个 exportedClassHashMap,因此应用程序代码在 loadClass 的时候,会优先判断 exportedClassHashMap 是否存在当前类,如果存在,则直接返回,如果不存在,则再使用传统的双亲委托机制来进行类加载。这样中间件 MoudleClassloader 不仅实现了中间件的加载,也实现了中间件关键服务类的透出。
我们可以大概看下应用程序类加载的过程:
热加载
在开发项目的时候,我们需要频繁的重启应用进行程序调试,但是 java 项目的启动少则几十秒,多则几分钟。如此慢的启动速度极大地影响了程序开发的效率,那是否可以快速的进行启动,进而能够快速的进行开发验证呢?答案也是肯定的,通过 classloader 我们可以完成对变更内容的加载,然后快速的启动。
常用的热加载方案有好几个,接下来我们介绍下 spring 官方推荐的热加载方案,即 spring boot devtools。
首先我们需要思考下,为什么重新启动一个应用会比较慢,那是因为在启动应用的时候,JVM 虚拟机需要将所有的应用程序重新装载到整个虚拟机。可想而知,一个复杂的应用程序所包含的 jar 包可能有上百兆,每次微小的改动都是全量加载,那自然是很慢了。那么我们是否可以做到,当我们修改了某个文件后,在 JVM 中替换到这个文件相关的部分而不全量的重新加载呢?而 spring boot devtools 正是基于这个思路进行处理的。
如上图所示,通常一个项目的代码由以上四部分组成,即基础类、扩展类、二方包/三方包、以及我们自己编写的业务代码组成。上面的一排是我们通常的类加载结构,其中业务代码和二方包/三方包是由应用加载器加载的。而实际开发和调试的过程中,主要变化的是业务代码,并且业务代码相对二方包/三方包的内容来说会更少一些。因此我们可以将业务代码单独通过一个自定义的加载器 Custom Classloader 来进行加载,当监控发现业务代码发生改变后,我们重新加载启动,老的业务代码的相关类则由虚拟机的垃圾回收机制来自动回收。其工程流程大概如下。有兴趣的同学可以去看下源码,会更加清楚。
RestartClassLoader 为自定义的类加载器,其核心是 loadClass 的加载方式,我们发现其通过修改了双亲委托机制,默认优先从自己加载,如果自己没有加载到,从从 parent 进行加载。这样保证了业务代码可以优先被 RestartClassLoader 加载。进而通过重新加载 RestartClassLoader 即可完成应用代码部分的重新加载。
热部署
热部署本质其实与热加载并没有太大的区别,通常我们说热加载是指在开发环境中进行的 classloader 加载,而热部署则更多是指在线上环境使用 classloader 的加载机制完成业务的部署。所以这二者使用的技术并没有本质的区别。那热部署除了与热加载具有发布更快之外,还有更多的更大的优势就是具有更细的发布粒度。我们可以想像以下的一个业务场景。
假设某个营销投放平台涉及到 4 个业务方的开发,需要对会场业务进行投放。而这四个业务方的代码全部都在一个应用里面。因此某个业务方有代码变更则需要对整个应用进行发布,同时其它业务方也需要跟着回归。因此每个微小的发动,则需要走整个应用的全量发布。这种方式带来的稳定性风险估且不说,整个发布迭代的效率也可想而知了。这在整个互联网里,时间和效率就是金钱的理念下,显然是无法接受的。
那么我们完全可以通过类加载机制,将每个业务方通过一个 classloader 来加载。基于类的隔离机制,可以保障各个业务方的代码不会相互影响,同时也可以做到各个业务方进行独立的发布。其实在移动客户端,每个应用模块也可以基于类加载,实现插件化发布。本质上也是一个原理。
在阿里内部像阿拉丁投放平台,以及 crossbow 容器化平台,本质都是使用 classloader 的热加载技术,实现业务细粒度的开发部署以及多应用的合并部署。
加密保护
众所周期,基于 java 开发编译产生的 jar 包是由.class 字节码组成,由于字节码的文件格式是有明确规范的。因此对于字节码进行反编译,就很容易知道其源码实现了。因此大致会存在如下两个方面的诉求。例如在服务端,我们向别人提供三方包实现的时候,不希望别人知道核心代码实现,我们可以考虑对 jar 包进行加密,在客户端则会比较普遍,那就是我们打包好的 apk 的安装包,不希望被人家反编译而被人家翻个底朝天,我们也可以对 apk 进行加密。
jar 包加密的本质,还是对字节码文件进行操作。但是 JVM 虚拟机加载 class 的规范是统一的,因此我们在最终加载 class 文件的时候,还是需要满足其 class 文件的格式规范,否则虚拟机是不能正常加载的。因此我们可以在打包的时候对 class 进行正向的加密操作,然后,在加载 class 文件之前通过自定义 classloader 先进行反向的解密操作,然后再按照标准的 class 文件标准进行加载,这样就完成了 class 文件正常的加载。因此这个加密的 jar 包只有能够实现解密方法的 classloader 才能正常加载。
我们可以贴一下简单的实现方案:
这样整个 jar 包的安全性就有一定程度的提高,至于更高安全的保障则取决于加密算法的安全性了以及如何保障加密算法的密钥不被泄露的问题了。这有种套娃的感觉,所谓安全基本都是相对的。并且这些方法也不是绝对的,例如可以通过对 classloader 进行插码,对解密后的 class 文件进行存储;另外大多数 JVM 本身并不安全,还可以修改 JVM,从 ClassLoader 之外获取解密后的代码并保存到磁盘,从而绕过上述加密所做的一切工作,当然这些操作的成本就比单纯的 class 反编译就高很多了。所以说安全保障只要做到使对方破解的成本高于收益即是安全,所以一定程度的安全性,足以减少很多低成本的攻击了。
总结
本文对 classloader 的加载过程和加载原理进行了介绍,并结合类加载机制的特征,介绍了其相应的使用场景。由于篇幅限制,并没有对每种场景的具体实现细节进行介绍,而只是阐述了其基本实现思路。或许大家觉得 classloader 的应用有些复杂,但事实上只要大家对 class 从哪里加载,搞清楚 loadClass 的机制,就已经成功了一大半。正所谓万变不离其宗,抓住了本质,其它问题也就迎刃而解了。
评论 (0)