本文章转载于:https://blog.csdn.net/qq_20009015/article/details/105190415

前端编译器:

将java文件编译成class文件的编译器。
java很多提高效率和友好度的语法糖都是依赖前端编译器实现的。
javac就是一个典型的前端编译器。

执行过程如下:

  1. 准备阶段:
    初始化插入式注解处理器

  2. 解析与填充符号表阶段:
    词法 语法分析,将源代码转变为标记集合,构建抽象语法树
    填充符号表,产生符号地址和符号信息。

  3. 插入式注解处理器的执行阶段:
    会影响javac的行为,比如说添加一些编译期的校验等。

  4. 分析与字节码的生成:
    检查静态信息,数据流检查,解语法糖,字节码生成

词法解析:

将源代码的字符流转为标记(token)集合的过程,token是编译时的最小单位,关键字 变量名 运算符等都可以作为标记。

语法分析:

根据标记序列构造抽象语法树,抽象语法树是一种描述程序代码语法结构的树形表示,树的每一个节点代表程序代码中的一个语法结构,比如说
包,类型,修饰符,运算符等。

生成语法树之后,后续的操作都是对语法树进行操作。

填充符号表:

符号表是一组符号地址和符号信息构成的数据结构,当对符号名进行地址分配时,符号表是地址分配的直接依据。

注解处理器:

jdk5 时候提出了注解,原本设计上和普通java代码一样, 是程序运行期间发挥作用。但是在jdk6之后,设计了一组 插入式注解处理器的api,
可以提前至编译期对代码中的特定注解进行处理,当这些插件工作时,允许读取修改 添加抽象语法树中的任意元素。
如果插入式注解处理器对语法树进行过修改,那么编译器就会重新回到解析以及填充符号表的阶段。
比如说lombok的实现原理,就是通过插入式注解处理器来在编译期对特定的注解处理,生成get set方法等。

语义分析与字节码生成

抽象语法树能表示一个结构正确的代码,但是无法保证代码是符合逻辑的。
语义分析则是对结构上正确的程序进行检查,比如说类型检查,控制流检查,数据流检查。
比如说 int a=1; bool b=true; char c=a+b; 这种 ,就可以在语义分阶段被检查出来,
在IDE上看到报红下划线的错误,就是语义分析得出来的结果。

检查主要有:

  1. 标注检查
    检查类型是否匹配,变量使用前是否声明等
  2. 数据和控制流分析
    检查局部变量使用前是否赋值
    方法的每条路径是否都有返回值
    所有必检异常是否都被处理了

解语法糖:

语法糖,在计算机语言中添加某种语法,对编译和功能没影响,但是更方便程序员使用该语言。
java常见的语法糖有泛型,变长参数,自动装箱拆箱,等等,java虚拟机其实并不支持这些语法,这些语法会在编译期被还原回原始的样子。
泛型 obejct ,变长参数 数组参数, 自动装箱 拆箱 integer.valueof() 等

字节码生成;

将之前步骤所生成的信息,语法树 符号表等 转化成字节码指令。
除此之外,添加实例构造器()方法和 ()方法,这个动作是将{}里面的代码块都收集起来 放到()方法里面
或者将static{} 里面的代码都收集汇集到一起 放到()方法里面去。

java语法糖

语法糖是编程语言提高的一种能提高效率 或者减少编码出错机会的语法,由前端编译器进行解语法糖还原。

泛型:

java的泛型也是语法糖,实现方式叫类型擦除式泛型,编译期后就会被还原。
而C#使用的是具现化式泛型,这种泛型在源码里面,编译后,运行期 都是切实存在的
比如说List 和List 就是两个不同的类型,在运行时生成,都有各自的类型数据和虚方法表。
但是在java的泛型,只在程序源码中存在,在编译后的字节码文件中,都已经被还原为原来的裸类型,并在使用的地方加入了强制类型转换。
对于java来说,List和List在运行期就是同一个类型。

缺点:

1.性能低于具现式的泛型,因为擦除式泛型不支持基本类型 比如说int long ,要使用的时候就必须是要Intger Long
会造成不必要的拆箱装箱操作。
2.很多功能不支持,比如说创建泛型对象 new E(),判断一个实例是否属于泛型类型 xx instanceof E ,这些在C#中都支持。

优点:

3.运行时无法获取到类型信息,运行时要用到类型,就只能自己传入一个class类型作为参数进去。
4.泛型无法重载,比如说List 和List 无法作为方法重载的参数,
会被认为同一个参数类型List ,方法重载是静态分派,以静态类型判断走哪个方法,泛型擦除之后就无法判断了。

1.兼容jdk1.5以前的代码
2.不需要修改虚拟机,只需要新增javac编译器做个转换,影响范围小。

泛型历史:

设计成擦除式泛型是因为要兼容以前的jdk1.5之前无泛型的代码,也就是以前没有的限制不能以后突然冒出来。
比如说没有泛型之前,一个ArrayList列表,里面同时可以放interger又可以放String, 编译运行都不会报错。
现在的ArrayList 则必须指定里面元素类型了。
如果要兼容以前的,那么这个T 就必须是属于可有可无的,在编译期就会被擦除,变成ArrayListt,这样就和以前的一样了。
试想,如果T必须存在,那么以前的那种 一个ArrayList列表,里面同时可以放interger又可以放String的代码,如何自处? T到底是什么类型?

解决方法有两种,一个另起炉灶搞一套新的支持泛型的容器,另一个就是像现在的java这样。
c#使用了第一种方案,所以有两套容器,支持泛型的容器和老容器。
如果java也使用第一种方案,就会出现ArrayList,ArrayList 两套容器, 采用第二种方案之后,现在这两个容器其实是同一个。
比如说 ArrayList a 在编译之后就变成了ArrayList ,这个也就是裸类型,但是在元素方法的地方 加上了强制类型转换
(integer)a.get(0);

自动装箱/拆箱

将基本类型转为包装类型叫装箱,包装类型转基本类型叫拆箱。
Integer a=123;
其实发生了自动装箱,编译后是Integer a=Integer.valueof(123);

条件编译:
if条件为常量时候,必定为false的那段代码注定不会执行,会在编译器被消除掉。

插入式注解处理器:
lombok就是使用的插入式注解处理器实现的动态加get set方法。

插入式器的抽象类为javax.annotation.processing.AbstractProcessor。
子类必选实现process() ,这是javac编译器在执行注解处理器代码时候要调用的。
(javac编译器本身也是用java写的。)

代码如下:

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
@SupportedAnnotationType("*") //表示要支持的注解 *表示所有
@SupportedSourceVersion(SourceVesion.RELESASE_6) //支持的jdk版本
public class NameCheckProccessor extends AbstractProcessor{
@Override
public void init(ProcessingEnvironment processingEnv){
super.init(processingEnv);
//processingEnv 是注解处理器框架提供的一个上下文环境 要创建新的代码,向编译器输出信息 等都需要用到这个对象
}

@Override
public boolean process(Set<? extends TypeElement> annotations,RoundEnvironment roundEnv){
//annotations为要处理的注解集合
// roundEnv为该轮次的抽象语法树节点
//返回值为true 告诉编译器自己有改动语法树 需要重新解析
//返回false 则不需要

//对 roundEnv.getRootElements()进行循环遍历,查找自己要的节点 ,比如说 方法 ,类,字段 等。
// processingEnv的getMassager()方法可以返回一个Massager对象,该对象上有用于向编译器输出信息的方法

//比如说输出警告,massager.printMessage(Waring,"xxx ",e); e为当前元素elements

//jdk提供了一个抽象类,ElementScannerX X为jdk版本号, 继承这个类,可以以Visitor模式来访问抽象语法树上的元素。
比如说
for(Element e: roundEnv.getRootElements()){
XXXXElementScanner6(e); //XXXXElementScanner6为抽象类ElementScannerX的实现子类
}

}

ElementScannerX的抽象方法有用于访问类的visitType(TypeElemt e ,void p);
访问方法的 visitExecutable(ExecutableElemt e ,void p ); 方法变量的 visitVariable(VariableElemt e ,void p );

后端编译器:

将Class文件编译为与本地机器上可以识别的二进制指令的编译器。
主要有解释执行的解释器和编译执行的编译器。

目前主流的虚拟机都是解释器和编译器并存的运行架构,解释器和编译器各有优势。

当程序需要迅速启动时,解释器可以先发挥作用,省去编译的时间,立即运行。

当程序启动后,随着时间推移,编译器发挥作用,将越来越多的的代码编译成本地代码,获得更高的执行效率。

解释器使用内存比编译器略小,解释一行执行一行,而编译器需要将代码都编译成本地指令之后才执行。

解释器还可以充当编译器激进优化失败之后的备用方案。

hotspot虚拟机中有两个即时编译器,分别是客户端编译器和服务端编译器。

一般都是混合使用, -Xint 命令 强制使用解释器, java -Xcomp 优先使用编译器,编译无法进行的时候使用解释器。

即时编译需要占用程序运行时间,要编译出优化程度越高的代码,花费的时间就越长。

而且解释器需要采集性能监控信息给编译器,这对解释器效率也会有影响。

为了均衡在程序的启动响应速度和运行效率之间达成平衡,引入了分层编译的策略。

第0层,纯解释执行,而且解释器不开性能监控

第1层,使用客户端编译器将代码编译成本地代码,使用简单稳定的优化,不开启性能监控

第2层,使用客户端编译器,开启方法回边统计次数统计等有限的性能监控。

第3层,使用客户端编译器,开启方法回表统计次数和分支跳转,虚方法调用版本等性能监控

第4层,使用服务端编译器,使用更长时间的编译优化,根据性能监控进行进行激进不可靠的优化。

以上5层,在运行期间,虚拟机会根据运行参数和版本 进行调整。
实现分层编译之后,解释器 客户端编译器和服务端编译器 就可以都用上,客户端编译器获得更快的编译速度,服务端编译器获取更
优化的编译代码,解释执行时也不需要性能监控。 服务端编译器采用高复杂度的优化算法的时候,客户端编译器可以先用简单优化来为
它争取更多的时间。

编译对象与触发条件
会被即时编译器编译的代码是热点代码,热点代码是指
1.被多次调用的方法
2.被多次执行的循环体。

这两种情况下,编译的对象都是整个方法体。
如果是循环体触发的即时编译,叫栈上替换编译请求,这种编译方式,编译发生在方法执行过程中,叫栈上替换,
即方法的栈帧还在栈上,方法就被替换了。

那么如何知道代码是热点代码,就需要热点探测。
热点探测有两种:
1.基于采样的热点探测
周期性统计各个线程所在栈顶的方法出现的频率,如果某个方法经常出现在栈顶,说明经常被调用,就是热点方法。
优点是实现较简单,缺点是统计可能不准,会受线程阻塞影响等。

2.基于计数器的热点探测
为每个方法都建立一个计数器,统计方法的执行次数,一段时间内超过某个阈值就认为是热点方法,默认是10000次。
如果时间到了未到达阈值,就会将计数器减半,这个动作是在垃圾收集的时候顺便进行的,也可以通过设置命令-XX: -useCounterDecay
来关闭热度衰减,这样计数器调用的就是绝对次数,时间长了大部分代码都会被编译成本地代码。
-XX:CounterHalfLifeTime 设置半衰周的时间 单位为秒。

hotspot为每个方法建立了方法调用计数器和回边计数器(统计内部循环执行的代码)。 流程如下:
java方法入口,判断是否有已编译的版本,如果有,执行编译后的本地代码版本。
如果没有,那么方法调用计数器+1, 判断两个计数器之和是否超过阈值,
是,向编译器提交编译请求,然后用解释器执行,并不会等到编译器编译完成。
否,则直接用解释器执行。

回边计数器统计方法中循环体执行的次数。字节码上遇到控制流向后跳转的指令就叫回边,但是空循环不是回边,因为是自己跳自己,并没有向后跳。

当解释器遇到一个回边指令时,会先查找将要执行的代码片段是否有已经编译好的版本,如果有的话,就会优先执行已编译的版本,
否则的话 回边指数器+1,然后判断两个计数器之和 是否满足阈值,如果满足,那么提交一个栈上替换编译请求,然后调小回边计数器的值以便当前继续在解释器中执行,然后使用调用解释器执行。这样的话,等到编译器编译好了,就可以使用编译版本执行了。

与方法调用计数器不同,回边计数器没有计数器衰减的过程,如果回边计数器满了,会把方法调用计数器也调满,这样下次就会触发标准编译过程。

编译过程:
无论方法调用产生的标准编译还是栈上替换编译请求,虚拟机在编译还没完成之前,都是用的解释器执行,后台有个编译线程,进行编译工作。
编译有三阶段:
一阶段:将字节码转为高级中间码,与目标机器指令集无关,这部分完成基础优化 如方法内联,常量传播等,特别是方法内联,是后续很多优化的基础。
二阶段:转为低级中间码,与目标指令集相关,这部分会完成空值检查消除,范围检查消除等
三阶段:寄存器分配,机器码生成。

提前编译器:
即时编译是在程序运行时将字节码编译成本地码,会占用用户程序的时间,提前编译是提前就将字节码编译成本地机器码。
优点就是不需要在运行时再去编译了。
但是缺点也很明显,缺少了运行时的性能数据采集支持,编译出来的代码优化程度不高,而且提前编译,会极大的延长启动时间。
提前编译也通常要求程序不能在外部动态加载新的字节码。

提前编译器有两种:
1.和c/c++一样,在程序运行之前就把程序代码翻译成机器码。
2.将原本即时编译器在运行时要做的编译工作提前做好并保存起来,下次运行到这部分代码,则可以直接使用,也叫编译缓存。

第一种,解决即时编译器要占用程序运行时间和运算资源。缺点就是增大了启动时间,在启动程序阶段就要将全部字节码编译成本地机器码。
第二种,本质上是给即时编译器做缓存,也叫动态提前编译或者即时编译缓存,这种提前编译的代码因为不能采用激进优化,性能要低于即时编译。

即时编译的优点:
1.性能分析
在运行过程中可以获取到信息,比如说某个抽象类通常是什么类型,条件判断通知会走哪个分支,方法调用使用哪个版本 等等
这些数据在静态分析是无法得到,但是在运行时就可以发现它们有非常明显的偏好性,那么就可以针对性的激进优化。

2.激进优化
即时编译的时候提供的性能信息 ,可以支持一些正确性很大但是无法保证绝对正确的优化,如果失败了就采用解释器兜底。
但是提前编译无法使用这种优化,必须保证百分百正确。
比如说在java中,大部分方法都是虚方法,只有静态方法 私有方法 构造方法不是虚方法, 如果java虚拟机遇到虚方法就去查方法表的话,就会很慢了。通常会通过类继承关系来做去虚拟化,这样可以保证虚方法也可以内联,进而可以优化。

3.链接时优化
在提前编译,静态编译是将不同的类库进行分别编译 优化。
但是在即时编译器中,所有的类都已经加载到了内存里面,可以关联优化,比如说调用某个库的方法,也可以进行方法内联等。

版权声明:本文为CSDN博主「公众号:程序员与王子喵 欢迎关注」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/qq_20009015/article/details/105190415