前端编译器和后端编译器
本文章转载于:https://blog.csdn.net/qq_20009015/article/details/105190415
前端编译器:
将java文件编译成class文件的编译器。
java很多提高效率和友好度的语法糖都是依赖前端编译器实现的。
javac就是一个典型的前端编译器。
执行过程如下:
准备阶段:
初始化插入式注解处理器解析与填充符号表阶段:
词法 语法分析,将源代码转变为标记集合,构建抽象语法树
填充符号表,产生符号地址和符号信息。插入式注解处理器的执行阶段:
会影响javac的行为,比如说添加一些编译期的校验等。分析与字节码的生成:
检查静态信息,数据流检查,解语法糖,字节码生成
词法解析:
将源代码的字符流转为标记(token)集合的过程,token是编译时的最小单位,关键字 变量名 运算符等都可以作为标记。
语法分析:
根据标记序列构造抽象语法树,抽象语法树是一种描述程序代码语法结构的树形表示,树的每一个节点代表程序代码中的一个语法结构,比如说
包,类型,修饰符,运算符等。
生成语法树之后,后续的操作都是对语法树进行操作。
填充符号表:
符号表是一组符号地址和符号信息构成的数据结构,当对符号名进行地址分配时,符号表是地址分配的直接依据。
注解处理器:
jdk5 时候提出了注解,原本设计上和普通java代码一样, 是程序运行期间发挥作用。但是在jdk6之后,设计了一组 插入式注解处理器的api,
可以提前至编译期对代码中的特定注解进行处理,当这些插件工作时,允许读取修改 添加抽象语法树中的任意元素。
如果插入式注解处理器对语法树进行过修改,那么编译器就会重新回到解析以及填充符号表的阶段。
比如说lombok的实现原理,就是通过插入式注解处理器来在编译期对特定的注解处理,生成get set方法等。
语义分析与字节码生成
抽象语法树能表示一个结构正确的代码,但是无法保证代码是符合逻辑的。
语义分析则是对结构上正确的程序进行检查,比如说类型检查,控制流检查,数据流检查。
比如说 int a=1; bool b=true; char c=a+b; 这种 ,就可以在语义分阶段被检查出来,
在IDE上看到报红下划线的错误,就是语义分析得出来的结果。
检查主要有:
- 标注检查
检查类型是否匹配,变量使用前是否声明等 - 数据和控制流分析
检查局部变量使用前是否赋值
方法的每条路径是否都有返回值
所有必检异常是否都被处理了
解语法糖:
语法糖,在计算机语言中添加某种语法,对编译和功能没影响,但是更方便程序员使用该语言。
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 | //表示要支持的注解 *表示所有 |
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