揭秘类的编译和加载


还记得大学期间的第一堂Java课,虽然还未完成从高中到大学学习方式的转变,虽然吸收不了那么多的知识,但我们却掌握了各个语言入门的金钥匙:Hello_Word。再到后来,慢慢的知道了类的编译和加载,但它像一个躲在角落的孩子,默默的付出,却得不到关注。可能那会更多的关注点都浮在表面,听到的更多的是谁谁谁写了一个跑马灯、谁谁谁做一个网页、还有谁谁谁做了一个坦克大战。

等到工作后,记得带我入行的Leader跟我说过一句话:一定要把技术理解透彻,这样用着才踏实,也才能写出优雅稳健的代码。时间久了也就会发现,了解的越深入,代码就会写的越自信。

工欲善其身,必先利其器。类的编译和加载时刻伴你我左右,它为我们做了很多事情,我们很有必要去深入了解一下它的整个过程。

  • 你是否好奇编译阶段到底做了哪些事情?

  • 你是否想了解编译时注解的工作原理?

  • 你是否对语法糖感兴趣,想不想知道它编译后的样子?

  • 你是否想知道类加载时要经历有哪些步骤?

  • 你是否被类加载时的初始化搞得晕头转向,想弄清楚它的来龙去脉?

  • 你是否想知道类加载有哪些应用场景?

我想,我们应该都想过上面的问题,有的可能已经了解过,有的可能还是个问题。下面,我们来一层一层的揭开它的面纱吧。相信你看完这篇文章一定会有收获,变成自信的码代码小能手。

文章主要分为编译和加载两部分。编译部分会介绍类编译的过程,并着重讲一下离我们相对较近的编译时注解和语法糖。加载部分会介绍类加载的步骤、类加载的模型、类加载的使用场景等。

1. 类的编译

编译阶段到底做了哪些事情?

编译是一门很复杂的艺术,但简单点来讲,它无非就是将.java文件转化为.class文件的过程。

编译的过程如下图所示,源文件经过词法分析、语法分析、注解处理、语义分析、代码生成等步骤,会生成JVM能够解析的字节码文件。

avatar

下面我们来看一下每个步骤都起到什么样的作用吧。

1.1 词法分析

int a = b + c;在词法分析阶段会被解析为inta=b+c;

看到上面的案例,相信大家已经一目了然了吧。

专业点来讲,词法分析是将源代码中字符流解析为标记流的过程。其中,字符是程序编写过程中的最小单位,标记是程序编译过程中的最小单位,包括关键字、变量名、运算符等。

1.2 语法分析

语法分析的作用是检查表达式是否符合编写规范。

它会将标记流构建成抽象语法树,抽象语法树是一个结构化的语法表达形式,用来检查标记流组合在一起是否符合规范。

1.3 注解处理

解编译时注解的工作原理?

先看一个我们经常使用的Lombok@Data注解吧,它的解析就是在这阶段进行的。

  • 编译前
1
2
3
4
@Data
public class Test {
private Integer t;
}
  • 编译后
1
2
3
4
5
6
7
8
9
public class Test {
private Integer t;
public Integer getT() {
return this.t;
}
public void setT(Integer t) {
this.t = t;
}
}

通过上面的示例我们可以发现,编译后的代码中@Data注解消失了,但增加了成员变量的getset方法。

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
2
3
4
5
6
byte b = 1;
switch (b){
case 1:
System.out.println("1");
break;
}
  • 编译后
1
2
3
4
5
6
7
0: iconst_1        // 整数`1`压入操作数栈顶
1: istore_1 // 栈顶整数`1`存储到局部变量表第一位
2: iload_1 // 加载局部变量表第一位整数`1`
3: lookupswitch { // 可以看见编译后的字节码已经将`byte`转成了`int`
1: 20
default: 28
}

String

switch语句中,String编译后使用的是hashCode值。

  • 编译前
1
2
3
4
5
6
String s = "s";
switch (s){
case "s":
System.out.println("s");
break;
}
  • 编译后
1
2
3
4
5
6
String s = "s";
switch(s.hashCode()) { // 使用`String`类型的`hashCode`进行判断
case 115:
System.out.println("s");
break;
}
泛型

不同的编译器对于泛型的处理方式是不同的。通常情况下,一个编译器处理泛型的方式主要有两种:Code specialization和Code sharing。Java使用的是Code sharing机制。

  1. Code specialization:在编译时根据泛型生成不同的Code。

  2. Code sharing:所有泛型共享同一Code,通过类型检查、类型擦除、类型转换实现。

  • 编译前
1
2
3
List<String> list = new ArrayList<>();
list.add("1"); //类型检查
String s = list.get(0);
  • 编译后
1
2
3
List list = new ArrayList();            //类型擦数
list.add("1");
String s = (String)list.get(0); //类型转换

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 类加载

类加载时要经历哪些步骤?

前面铺垫了那么多,想必大家都很想了解一下,虚拟机加载字节码文件到底要经历哪些步骤呢。

虚拟机把字节码文件加载到内存,并对数据进行校验、转换、解析、初始化等,最终形成可以被虚拟机直接使用的类型,这就是虚拟机的类加载机制。类加载过程包括加载、验证、准备、解析和初始化。

avatar

我们下面来看一下在每一个步骤中具体做了哪些事情。

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()方法直接获取,故又称为系统类加载器。当应用程序没有自定义类加载器时,默认采用该类加载器。

  • 自定义类加载器:支持用户自定义类的加载方式。

avatar

2.3.1 双亲委托模型

双亲委派模型是类加载器模型,是在Java 1.2后引入的。它的工作原理的是,如果一个类加载器收到了类加载请求,它并不会自己先去加载,而是把这个请求委托给父类的加载器去执行,如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归,请求最终将到达顶层的启动类加载器,如果父类加载器可以完成类加载任务,就成功返回,倘若父类加载器无法完成此加载任务,子加载器才会尝试自己去加载,这就是双亲委派模式。

下面是双亲委派模型加载的详细过程。

  • 首先由最顶层的Bootstrap ClassLoader加载器进行加载。

  • 如果没加载到,则把任务转交给Extension ClassLoader加载器进行加载。

  • 如果也没加载到,则转交给App ClassLoader加载器进行加载。

  • 如果它也没有加载得到的话,则委托给发起者加载。

  • 如果它们都没有加载到这个类时,则抛出ClassNotFoundException异常。否则将这个找到的类生成一个类的定义,并将它加载到内存当中,最后返回这个类在内存中的Class实例对象。

想必大家也很想知道双亲委派模型的优点在哪里呢。双亲委派模型保证了Java程序的稳定运行,可以避免类的重复加载(JVM区分不同类的方式不仅仅根据类名,相同的类文件被不同的类加载器加载产生的是两个不同的类),也保证了Java的核心API不被篡改。如果没有使用双亲委派模型,而是每个类加载器加载自己的话就会出现一些问题,比如我们编写一个称为java.lang.Object类的话,那么程序运行的时候,系统就会出现多个不同的Object类。

摘个源码看看双亲委派模型的逻辑吧。

  • ClassLoader
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
// First, check if the class has already been loaded
Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
if (parent != null) {
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// ClassNotFoundException thrown if class not found
// from the non-null parent class loader
}

if (c == null) {
// If still not found, then invoke findClass in order
// to find the class.
long t1 = System.nanoTime();
c = findClass(name);

// this is the defining class loader; record the stats
sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}
}

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异常。

avatar

面对依赖冲突我们常用的解决办法就是定位冲突包,并排除不合适的版本,留下版本合适的包。这种方式虽然简单粗暴又有效,但它还是存在一些弊端的。

  • 如果项目的依赖结构错综复杂,那么排除包将是一件比较困难的事情。

  • 可能会出现某个场景下包排除成功,但切换场景后依然存在依赖冲突问题。

  • 项目中依赖包的升级有可能会导致依赖冲突问题的出现。

  • 某些场景下要求不同功能的jar包依赖不同版的包。

解决上面问题的基本思想就是资源隔离。我们可以给包定义类加载器,通过自定义类加载器来加载指定版本的包依赖,这样就做到了不同包之间资源的相互隔离,并且规避了依赖冲突。阿里的潘多拉就是一个类隔离容器,它提供了稳定的运行环境,实现了应用与包之间的隔离、包与包之间的隔离,保证了类的正确加载。

2.4.2 热加载&部署

想必大家都知道,在开发调试项目的过程中,重启一次服务大概率需要一次去洗手间的时间,这样下去的话一天应该有一半的时间都要呆在洗手间了。这种方式严重的影响了程序的开发效率,那么有没有更高效的调试方式呢,这就不得不说说热加载了。

我们先来想一想,启动服务为什么比较慢呢,那是因为每次启动服务的时候都需要把程序重新装载到虚拟机中,项目越大,服务启动时间就越长。我们开发调试的时候每次代码的改动量都很小,但重启服务却是全量加载。热加载的基本思想就是在不重启服务的情况下,快速加载到修改后的代码。

热加载的实现思路:

  1. 自定义类加载器加载业务代码。

  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的装载过程,虽然增加了源文件的安全性,但却增大了开发与维护难度,降低了程序的可移植性。

鱼与熊掌不可兼得,越安全的加密方式,越需要开发与维护成本。没有绝对安全的源代码加密方式,上述的加密方式也只能提供一定程度的安全保护,虽然类加载实现的加密方式只在内存中,但通过一定的技术手段也可以将类文件拷贝到磁盘中,从而得到未加密的文件。

小结

读到这里的同学我觉得很有必要交个朋友了。

这边文章主要分享了一下类的编译和加载,还有一些与我们开发人员息息相关的使用场景,现在回头再看看文章刚开始的几个问题吧,我想你心中应该已经有答案了吧。