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