揭秘类的编译和加载
还记得大学期间的第一堂Java课,虽然还未完成从高中到大学学习方式的转变,虽然吸收不了那么多的知识,但我们却掌握了各个语言入门的金钥匙:Hello_Word。再到后来,慢慢的知道了类的编译和加载,但它像一个躲在角落的孩子,默默的付出,却得不到关注。可能那会更多的关注点都浮在表面,听到的更多的是谁谁谁写了一个跑马灯、谁谁谁做一个网页、还有谁谁谁做了一个坦克大战。
等到工作后,记得带我入行的Leader跟我说过一句话:一定要把技术理解透彻,这样用着才踏实,也才能写出优雅稳健的代码。时间久了也就会发现,了解的越深入,代码就会写的越自信。
工欲善其身,必先利其器。类的编译和加载时刻伴你我左右,它为我们做了很多事情,我们很有必要去深入了解一下它的整个过程。
你是否好奇编译阶段到底做了哪些事情?
你是否想了解编译时注解的工作原理?
你是否对语法糖感兴趣,想不想知道它编译后的样子?
你是否想知道类加载时要经历有哪些步骤?
你是否被类加载时的初始化搞得晕头转向,想弄清楚它的来龙去脉?
你是否想知道类加载有哪些应用场景?
我想,我们应该都想过上面的问题,有的可能已经了解过,有的可能还是个问题。下面,我们来一层一层的揭开它的面纱吧。相信你看完这篇文章一定会有收获,变成自信的码代码小能手。
文章主要分为编译和加载两部分。编译部分会介绍类编译的过程,并着重讲一下离我们相对较近的编译时注解和语法糖。加载部分会介绍类加载的步骤、类加载的模型、类加载的使用场景等。
1. 类的编译
编译阶段到底做了哪些事情?
编译是一门很复杂的艺术,但简单点来讲,它无非就是将.java文件转化为.class文件的过程。
编译的过程如下图所示,源文件经过词法分析、语法分析、注解处理、语义分析、代码生成等步骤,会生成JVM能够解析的字节码文件。
下面我们来看一下每个步骤都起到什么样的作用吧。
1.1 词法分析
int a = b + c;
在词法分析阶段会被解析为int
、a
、=
、b
、+
、c
、;
。
看到上面的案例,相信大家已经一目了然了吧。
专业点来讲,词法分析是将源代码中字符流解析为标记流的过程。其中,字符是程序编写过程中的最小单位,标记是程序编译过程中的最小单位,包括关键字、变量名、运算符等。
1.2 语法分析
语法分析的作用是检查表达式是否符合编写规范。
它会将标记流构建成抽象语法树,抽象语法树是一个结构化的语法表达形式,用来检查标记流组合在一起是否符合规范。
1.3 注解处理
解编译时注解的工作原理?
先看一个我们经常使用的Lombok
的@Data
注解吧,它的解析就是在这阶段进行的。
- 编译前
1 | @Data |
- 编译后
1 | public class Test { |
通过上面的示例我们可以发现,编译后的代码中@Data
注解消失了,但增加了成员变量的get
与set
方法。
JDK1.6中新增了插入式注解处理器,允许在编译时读取、添加、修改抽象语法树中的任何元素,将注解的处理扩展到编译阶段。Lombok
就是一个注解处理器,在编译阶段将运行时注解处理成目标方法。当然@AllArgsConstructor
、@Slf4j
也是一样的实现方式,有兴趣的同学可以去看看加有类似注解的类编译后的代码。
1.4 语义分析
语法分析后,编译器获得了源代码的抽象语法树。抽象语法树虽然能够保证结构的正确性,但却无法保证逻辑的正确性。语义分析的主要目的就是对抽象语法树进行逻辑检查。
标注检查:主要用来检查变量使用前是否已被声明、变量与赋值之间的数据类型是否匹配等。
数据及控制流检查:主要用来检查局部变量在使用前是否有赋值、方法是否都有返回值等。
解语法糖:将语法糖解析为JVM能够识别的语法。
1.4.1 语法糖
你是否对语法糖感兴趣,想不想知道它编译后的样子?
语法糖,一听名字就知道这种语法用起来甜甜的。语法糖的存在主要是为了方便开发人员使用,但其实Java虚拟机并不支持这些语法。语法糖在编译阶段就会被还原成简单的基础语法。
语法糖主要有switch
、泛型
、自动装箱与拆箱
、方法变长参数
、数值字面量
、for-each
、枚举
、内部类
、final
等。我们以Switch
和泛型
为例来看一下语法糖解析后的样子吧。剩下语法糖有兴趣的同学可以自己研究一下,我们可以通过反编译软件来查看编译后的代码,也可以通过javap
命令来分析字节码。
switch
Java中的swith支持基本数据类型,比如int、byte、char等,并从JDK1.7开始支持String类型。但对于JVM来说,switch只支持整型,任何类型在编译阶段都需要转换成整型。
byte
在switch
语句中,byte
编译后直接使用int
类型。
- 编译前
1 | byte b = 1; |
- 编译后
1 | 0: iconst_1 // 整数`1`压入操作数栈顶 |
String
在switch
语句中,String
编译后使用的是hashCode
值。
- 编译前
1 | String s = "s"; |
- 编译后
1 | String s = "s"; |
泛型
不同的编译器对于泛型的处理方式是不同的。通常情况下,一个编译器处理泛型的方式主要有两种:Code specialization和Code sharing。Java使用的是Code sharing机制。
Code specialization:在编译时根据泛型生成不同的Code。
Code sharing:所有泛型共享同一Code,通过类型检查、类型擦除、类型转换实现。
- 编译前
1 | List<String> list = new ArrayList<>(); |
- 编译后
1 | List list = new ArrayList(); //类型擦数 |
1.5 代码生成
最后一步就是根据生成的抽象语法树生成符合java虚拟机规范的字节码。
小结
相信看到这里,你对整个编译过程已经有了初步的了解了吧。
注解处理、语法糖解析等步骤跟我们开发人员的相关性还是比较大的,了解它们背后的机制会方便我们写出更加稳健的程序。
2. 类的加载
我们先思考一个问题,是不是类编译后的字节码文件就能够直接在计算机上运行了呢?
大家都知道,程序只有被计算机识别,并且加载到内存才能运行,那么字节码能够直接被计算机识别吗,答案是不能的。字节码是面向JVM的编码格式,只有将字节码翻译成机器码才能在计算机上运行。也就是说我们编写的源文件会先编译成JVM能够识别的字节码文件,字节码文件再经过JVM翻译成机器码并加载到内存中才能运行。
有人可能会问,为什么要经过字节码这么一个中间步骤,不直接将源文件编译成机器码呢。这就是JVM的平台无关性和语言无关性,也是Java可以迅速崛起并风光无限的一个重要原因。
平台无关性:运行在不同平台上的虚拟机,载入同一种编码格式的字节码文件。
语言无关性:虚拟机只与字节码文件绑定,任何语言编译成字节码文件都可以虚拟机上运行。
简单点来讲,类加载就是指将字节码解析为机器码并加载到内存的过程。
2.1 字节码文件格式
既然类加载的是字节码文件,我们就先来简单的看一下字节码文件的格式吧。
字节码文件是以8字节为基础单位的二进制流,各个数据项严格按照格式紧凑排列,中间没有任何分隔符。字节码的数据结构由无符号数和表组成,其中,u1、u2、u4、u8都是无符号数,分别表示1个字节、2个字节、4个字节、8个字节,表是由无符号数和表组成的复合数据结构。
字节码文件的编码格式能够被JVM识别。关于字节码文件更详细的内容就不展开讲了,有兴趣的读者可以查阅相关文献做深入了解。
类型 | 名称 | 数量 |
---|---|---|
u4 | magic |
1 |
u2 | minor_version |
1 |
u2 | major_version |
1 |
u2 | constant_pool_count |
1 |
cp_info | constant_pool |
1 |
u2 | access_flags |
1 |
u2 | this_class |
1 |
u2 | super_class |
1 |
u2 | interfaces_count |
1 |
u2 | interfaces |
interfaces_count |
u2 | fields_count |
1 |
field_info | fields |
fields_count |
u2 | methods_count |
1 |
method_info | methods |
1 |
u2 | attribute_count |
1 |
attribute_info | attribute |
attribute_count |
2.2 类加载
类加载时要经历哪些步骤?
前面铺垫了那么多,想必大家都很想了解一下,虚拟机加载字节码文件到底要经历哪些步骤呢。
虚拟机把字节码文件加载到内存,并对数据进行校验、转换、解析、初始化等,最终形成可以被虚拟机直接使用的类型,这就是虚拟机的类加载机制。类加载过程包括加载、验证、准备、解析和初始化。
我们下面来看一下在每一个步骤中具体做了哪些事情。
2.2.1 加载
加载是指将字节码文件加载到内存并转化为Class对象的过程。
什么情况下会触发类的加载呢?
使用静态字段、调用静态方法。
创建类。
子类加载前会先加载父类。
反射调用。
虚拟机启动时加载主类。
加载的过程是什么样的呢?
通过全限定名获取类的二进制字节流。
将字节流所代表的结构转化为Class对象。
2.2.2 验证
验证的目的是保证字节流中包含的信息符合当前虚拟机的要求,主要包括以下几方面。
文件格式验证:验证字节流是否符合Class文件格式规范。
元数据验证:对字节码描述的信息进行语义分析,保证符合Java语言规范。
字节码验证:通过数据流和控制流分析,保证语意合法。
符号引用验证:保证能成功解析。
2.2.3 准备
准备阶段为类变量分配内存并设初始值。
如果类变量只被static修饰,初始值通常情况下是数据类型默认的零值。
如果类变量同时被static和final修饰,该变量会被标记为ConstantValue,初始值为指定的值。
2.2.4 解析
解析阶段会将常量池中的符号引用解析为直接引用。
2.2.5 初始化
初始化是为类的静态变量赋予正确的初始值。
初始化是执行
2.3 类加载器
字节码文件是通过类加载器来加载的,它分为以下几类。
启动类加载器:一般由C++实现,是虚拟机的一部分。该类加载器主要职责是将JAVA_HOME路径下的\lib目录中能被虚拟机识别的类库加载到虚拟机内存中。
扩展类加载器:由Java实现,独立于虚拟机的外部。该类加载器主要职责将JAVA_HOME路径下的\lib\ext目录中的所有类库,开发者可直接使用扩展类加载器。 该加载器是由sun.misc.Launcher$ExtClassLoader实现的。
应用类加载器:该加载器是由sun.misc.Launcher$AppClassLoader实现,该类加载器负责加载用户类路径上所指定的类库。开发者可通过ClassLoader.getSystemClassLoader()方法直接获取,故又称为系统类加载器。当应用程序没有自定义类加载器时,默认采用该类加载器。
自定义类加载器:支持用户自定义类的加载方式。
2.3.1 双亲委托模型
双亲委派模型是类加载器模型,是在Java 1.2后引入的。它的工作原理的是,如果一个类加载器收到了类加载请求,它并不会自己先去加载,而是把这个请求委托给父类的加载器去执行,如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归,请求最终将到达顶层的启动类加载器,如果父类加载器可以完成类加载任务,就成功返回,倘若父类加载器无法完成此加载任务,子加载器才会尝试自己去加载,这就是双亲委派模式。
下面是双亲委派模型加载的详细过程。
首先由最顶层的Bootstrap ClassLoader加载器进行加载。
如果没加载到,则把任务转交给Extension ClassLoader加载器进行加载。
如果也没加载到,则转交给App ClassLoader加载器进行加载。
如果它也没有加载得到的话,则委托给发起者加载。
如果它们都没有加载到这个类时,则抛出ClassNotFoundException异常。否则将这个找到的类生成一个类的定义,并将它加载到内存当中,最后返回这个类在内存中的Class实例对象。
想必大家也很想知道双亲委派模型的优点在哪里呢。双亲委派模型保证了Java程序的稳定运行,可以避免类的重复加载(JVM区分不同类的方式不仅仅根据类名,相同的类文件被不同的类加载器加载产生的是两个不同的类),也保证了Java的核心API不被篡改。如果没有使用双亲委派模型,而是每个类加载器加载自己的话就会出现一些问题,比如我们编写一个称为java.lang.Object类的话,那么程序运行的时候,系统就会出现多个不同的Object类。
摘个源码看看双亲委派模型的逻辑吧。
- ClassLoader
1 | protected Class<?> loadClass(String name, boolean resolve) |
2.4 类加载的应用场景
磨刀不误砍柴工,了解完字节码文件结构、类加载过程和类加载器模型后,想必大家都比较关心,类加载到底可以应用到哪些场景呢?
类加载器模型允许我们灵活的定义类加载器,通过这种方式,我们可以解决很多问题,例如解决依赖冲突、实现热加载、热部署、加密源代码等。
2.4.1 依赖冲突
做大型项目的同学应该经常会遇到依赖冲突问题。依赖冲突是由于Maven依赖的传递性,导致项目的Classpath下出现了不同版本的包,而Maven则会根据包引用的最短路径来选择包,这就有可能会导致ClassNotFoundException、NoSuchMethodError等异常的出现。
如下图所示,项目依赖A.jar包和B.jar包,A.jar包依赖V1版本的C.jar包,B.jar包依赖V2版本的C.jar包,如果对于项目来说,V1版本的C.jar引用路径最短,那么项目的Classptah下则会加载V1版本的C.jar包,此时项目依赖的B.jar调用类D的方法method2时就会报NoSuchMethodError异常。
面对依赖冲突我们常用的解决办法就是定位冲突包,并排除不合适的版本,留下版本合适的包。这种方式虽然简单粗暴又有效,但它还是存在一些弊端的。
如果项目的依赖结构错综复杂,那么排除包将是一件比较困难的事情。
可能会出现某个场景下包排除成功,但切换场景后依然存在依赖冲突问题。
项目中依赖包的升级有可能会导致依赖冲突问题的出现。
某些场景下要求不同功能的jar包依赖不同版的包。
解决上面问题的基本思想就是资源隔离。我们可以给包定义类加载器,通过自定义类加载器来加载指定版本的包依赖,这样就做到了不同包之间资源的相互隔离,并且规避了依赖冲突。阿里的潘多拉就是一个类隔离容器,它提供了稳定的运行环境,实现了应用与包之间的隔离、包与包之间的隔离,保证了类的正确加载。
2.4.2 热加载&部署
想必大家都知道,在开发调试项目的过程中,重启一次服务大概率需要一次去洗手间的时间,这样下去的话一天应该有一半的时间都要呆在洗手间了。这种方式严重的影响了程序的开发效率,那么有没有更高效的调试方式呢,这就不得不说说热加载了。
我们先来想一想,启动服务为什么比较慢呢,那是因为每次启动服务的时候都需要把程序重新装载到虚拟机中,项目越大,服务启动时间就越长。我们开发调试的时候每次代码的改动量都很小,但重启服务却是全量加载。热加载的基本思想就是在不重启服务的情况下,快速加载到修改后的代码。
热加载的实现思路:
自定义类加载器加载业务代码。
监听业务代码的变化,业务代码变化后使用自定义类加载器重新加载。
热部署与热加载的本质没有太大区别,都是基于类加器实现的。热部署的粒度是项目,热加载的粒度是类。目前常用的方案有spring-loaded、spring-boot-devtools、JRebel等,有兴趣深入了解的同学可以查阅相关源码。
2.4.3 源码加密
源码是大家辛苦劳动的成果,也是整个公司的核心竞争力,谁都不愿意轻易的将源码拱手相送。字节码作为中间产物,极大的推动了Java的发展,但这种统一的标准也留下了安全隐患。现在的反编译软件越来越多,可以轻易的对源代码进行分析。此时,对源码进行加密显的尤为重要。
混淆器
Java混淆编译器是在类编译阶段完成的,在字节码的生成过程中,对编译器生成的中间代码进行混淆。它的基本思路是替换类名、方法名、变量名等,使得反编译出来的代码晦涩难懂。常用的混淆器有JODE、RetroGuard、ProGuard等。
然而,混淆后的程序的逻辑不变,修改反编译软件依然能够解析出代码逻辑。混淆处理只是一种视觉错误上的加密,并没有从根本上对程序进行加密,尤其是一些重要的算法,很容易被盗用或者攻破。所以不能简单地依赖混淆技术来保证源代码的安全。
文件加密
为了不让源码轻易的被盗用,我们可以使用常用的加密工具对源代码文件进行加密,比如PGP(Pretty Good Privacy)、GPG(GNU Privacy Guard)等。
然而,用户在运行程序前需要对文件进行解密,用户在解密后就得到了一份没加密的源代码,这种方式只能降低非相关人员窃取源码的几率,并不能从根本上解决安全问题。
类加载器
类加载器实现源代码加解密的基本思想是,首先对源代码文件进行加密,然后自定义类加载器,在类加载的时候对加密的字节码文件进行解密。
JCE提供了加解密功能,并且包含了秘钥的生成方式。它没有规定具体的加解密算法,只是提供了一个框架,使用者可以指定加解密算法的具体实现。目前有很多种加解密算法,比如DES (Data Encryption Standard)、AES(Advanced Encryption Standard)、Blowfish等。
由于解密后的类只存在于内存中,并不会保存到磁盘,所以类加载器实现源文件加解密相对于上述两种方式会更加安全。虽然该方法很好地保护了源文件,但它却存在一个明显的问题,不能对解密的类文件进行加密,整个解密过程会完全暴露出来,用反编译器反编译它们,即可得到解密类文件的源码。
类加载器扩展
针对解密文件不能加密的问题,有人会选择直接将解密文件打包成机器指令,增加反编译的难度。也有人会选择将解密文件也进行加密,直到运行的时候再解秘。这种方式需要修改JVM的装载过程,虽然增加了源文件的安全性,但却增大了开发与维护难度,降低了程序的可移植性。
鱼与熊掌不可兼得,越安全的加密方式,越需要开发与维护成本。没有绝对安全的源代码加密方式,上述的加密方式也只能提供一定程度的安全保护,虽然类加载实现的加密方式只在内存中,但通过一定的技术手段也可以将类文件拷贝到磁盘中,从而得到未加密的文件。
小结
读到这里的同学我觉得很有必要交个朋友了。
这边文章主要分享了一下类的编译和加载,还有一些与我们开发人员息息相关的使用场景,现在回头再看看文章刚开始的几个问题吧,我想你心中应该已经有答案了吧。