JAVA相关编译知识
总览
语言进化史
整体流程
下面这张图是高级语言经过哪些步骤最终才能在机器平台上运行的整体流程图。
编译器整体流程
前端编译可以简单理解为就是将java文件转换为class字节码文件;后端编译可以理解为clas字节码转换为目标机器平台的机器语言。
起始阶段刚开始的字符流。对应于java来说就是java文件。
词法分析
将java源程序按照不同类别输出成不同的token。
主要任务:从左到右逐行扫描源程序的字符,识别出各个单词确定单词的类型,将识别出的单词转换为统一的机内表示(token 词法单元)
token:<种别码,属性值>
种别码区分不同的单词类型,属性值确定代表该种别码的值。
eg:
语法分析
根据不同的单词组合来判断是不是某个短语。(根据输出的token序列来判断是不是某个动作例如赋值,变量声明等操作)
主要作用:从语法分析器输出的token序列中识别出短语(操作),并进行构造语法分析树。
eg:
赋值语句
变量声明
语义分析
高级语言中的语句可以分为声明语句和可执行语句。
对于声明语句,语义分析主要任务:
~1. 收集标识符属性信息,存放到符号表的数据结构中,并使用字符串表进行记录标识符符号的长度和名字。
也就是下面这张图:
~2. 语义检查
eg:
变量未声明就使用
变量重复声明
运算分量类型不匹配,前提是在经过自动类型转换还是不匹配(int当boolean使用)
操作符与操作数之间类型不匹配
函数参数类型,个数不匹配
函数返回类型不匹配
…
中间代码生成
这部分用来生成语法结构树/语法树。
目标代码生成器
主要作用:
1.目标代码生成以源程序的中间表示形式作为输入,并把它映射到目标语言。
2.目标代码生成是为了程序程序中使用的变量合理分配寄存器。
代码优化
分为两部分:
1.机器无关代码优化器(中间代码层面进行优化)
2.机器相关代码优化器(目标语言层面进行优化)
编译相关知识
编译种类
1.混编(编译器和解释器混合执行)
2.单独编译器
3.单独解释器
热点探测技术
1.基于采样的热点探测技术
2.基于计数器的热点探测技术
3.基于trace的方式
第一种基于栈顶的判断当一段时间后栈顶还是这个方法的话记认为是热点代码。缺陷是如果方法本身就是耗时的但是并不是由于多次调用而停留在栈顶就会导致统计数据不是最好的,优点是通过栈顶来判断的因此可以看到调用栈中的其他方法上一个方法等信息。
第二种基于计数器的方式为每个方法都添加一个调用次数计数器和回边计数器。通过判断他们两个是否达到一定数值来判断是不是热点代码。缺点是需要为每个方法建立维护计数器不能够得到虚拟机栈中的调用信息;优点是统计的结果更加准确。
第三种基于trace的方式进行判断的,比如davlik就是这种热点代码探测技术
编译什么
被调用多次的方法或者是循环执行很多次的代码
不管是方法还是循环体,编译的时候都是根据方法体进行编译的。
这里说下第二种循环体编译,当某个方法的循环体执行了很多次(注意不是空循环)当达到设定的阈值时会进行提交一个编译整个方法的请求而不是单独只编译这个循环体。由于每次循环的时候都会进行一次判断看是不是已经编译过如果编译过会直接替换循环的入口地址指向本地代码,这种在方法执行过程中(也就是栈帧已经建立的前提下)进行替换的操作也叫栈上替换。
而第一种基于方法的则是在每次调用方法时才进行的判断(这个时候方法栈帧还未建立)而不是像循环体一样在每次碰到循环时(栈帧已经建立)都进行判断这时候循环体是已经在栈中运行的,所以第一种方法不是栈上替换。
什么时候进行检测,什么时候提交编译请求?
根据第一种方法调用多次来进行判断的热点代码的条件来分析的话,每次方法调用时都进行一次检测,如果说方法第一次运行,那么肯定是没有编译为本地代码的,这时候方法调用计数器加一然后用解释器继续运行。假设条件是两次调用次数零次回边次数,那么方法第二次被调用时发现还是没有编译为本地代码继续加一这时候在进行一次检测发现达到热点代码要求了发送一个编译指令,继续解释器运行。当编译完成后替换该方法的入口地址为本地方法的入口地址。第三次调用该方法时就会自动通过入口地址来执行编译后的本地代码。
这里注意下方法的调用次数并不是绝对的调用次数,这个调用次数会随着时间的流失进行减少一半的操作。也就叫做方法调用计数器热度的衰减。这段时间的流失叫做方法统计的半衰周期。
根据第二种的话,每次碰到回边指令都会进行一次循环次数加一然后判断是不是达到热点代码的条件进行发送编译本地代码的指令(编译循环体所在的这个方法的指令)。当循环次数达到了条件则会进行栈上替换入口地址为本地代码地址。方法在运行过程中进行的替换所以叫栈上替换。
收到编译请求,之后的操作?
一,和刚开始的编译流程一样,收到字节码之后也会进行一系列分析然后进行优化接着构造出语法分析树(高级中间代码表示形式与源代码相关的中间表示HIR)。
二,再接着后端编译器收到这个高级中间代码的表示形式会在进行一次优化操作,并转换生成低级中间代码表示(与目标机器指令集相关的中间表示LIR)。
三,后端编译器使用线性扫描算法进行分配寄存器,在做优化,最后产生机器代码。
这里放出一张图:
引自《深入理解JAVA虚拟机》
优化措施总结
编译优化技术总结:
一,方法内联:
也就是将方法进行复制到另外一个方法中。比如a方法调用了b方法, 那么使用了这个优化技术之后就会将b方法中的方法体提取到a方法里面。
好处:首先减少了调用方法新创建方法栈帧的性能开销,同时也是后面进行其他优化技术的前提
方法内联的实现:首先编译期间可以确定调用版本的方法才会进行内联。而java中大多数都是虚方法在运行期间才可以确定调用方法的版本,c和c++都是给方法默认加上final关键字来保障可以内联。
因此针对虚方法也要进行相关内联的操作需要进行额外的操作。java采用了在虚拟机中解决这个问题。虚拟机引入了一种类型继承关系分析的技术,缩写也叫cha。
该技术用于确定在目前已加载的类中,某个接口是否有多于一种的实现、某个类是否存在子类、某个子类是否覆盖了父类的某个虚方法等信息。如果对于只有一种版本的实现直接进行内联;如果有多种方法版本的实现的话,那么编译器也会进行内联缓存(也就是在方法调用的入口处保存调用者的信息,如果下次还是这个调用者那么直接使用缓存即可,如果下一次调用的时候发现不是这个调用者那么退回到解释执行。判断是不是同一个调用着我也就是类型判断比起在虚方法表中查找效率也会更高一些)。
二,逃逸分析:名字听着高大上,其实就是看创建的对象会不会逃逸。既然都说是逃逸了那么实例变量或者静态变量这种生命周期和类一样的也就不需要逃。所以逃逸分析指的是分析局部变量是否能够被其他线程或者其他方法访问到(因为他的生命周期随着方法调用结束栈帧被回收就结束了,如果方法调用后还没有结束局部变量的声明周期就认为它逃逸了。)
举例:
**二,1 栈上分配 **
对象是在堆中创建的,对于在方法中定义的对象那么也会在堆中进行创建而这部分的回收操作却是比较耗费性能的。对于方法中创建的对象来说方法调用完毕就应该被回收掉而不应该等到gc时才去回收。所以这部分对象可以在栈中进行创建随着方法调用结束也会随着栈帧被回收而回收。这种优化措施可以支持方法逃逸因为如果采用参数的方式进行传递那么两个方法会共用同一个局部变量表中的信息不会再去创建。但是不支持线程逃逸因为栈是线程私有的。
二,2标量替换。标量:不可再被分解的变量比如八大数据类型。聚合量:对象就是聚合量其由很多标量进行构成。对于创建对象来说会将使用对象的变量直接替换为标量,不进行创建对象的开销。
二,3同步消除。对于没有被多线程访问需求的变量或者方法
会进行同步代码快的消除。
逃逸分析需要编译时基于数据流和分析流进行分析看是否会逃逸来进行对应的优化措施,但是很可能编译时的分析耗费的性能比优化的性能还高,所以逃逸分析目前还不是很成熟。
三,公共子表达式消除:如果一个表达式E之前已经被计算过了,并且从先前的计算到现在E中所有变量的值都没有发生变化,那么E的这次出现就称为公共子表达式。
解释:如果同一个表达式已经计算过,并且之后没有对表达式的变量进行修改操作,之后再次碰到这个表达式的时候就不需要再次进行计算了直接使用上一次计算过的结果来用。
四,数组边界检查消除:java中每次都进行读取或操作数组中的元素时都要进行对访问的下标进行判断其是否大于等于0并且没有超出数组的最大长度的限制。
这部分的检查也可以在编译时就可以确定不需要每次都在运行时检查降低运行时效率,比如编译期间是可以拿到数组得长度的在碰到编译访问读取数组的时候就可以进行判断。
五,隐性异常消除:代码优化减少判空等操作,对于发生异常的时候虚拟机会注册一个异常(进程相关的异常)当碰到java代码异常的时候从用户态切换到内核态查找之前注册的异常在进程异常表中位置并进行相应处理,处理完之后在切换到用户态进行处理,切换的性能消耗也是挺高的,但是虚拟机聪明会进行数据分析当异常经常触发就不会再抛出进程异常。基于运行时的数据采集进行最优的处理优化策略 。