写点什么

15 个问题自查真的了解 java 编译优化吗

发布于: 刚刚

​​摘要:为什么 C++的编译速度会比 java 慢很多?二者运行程序的速度差异在哪? 了解了 java 的早期和晚期过程,就能理解这个问题了。

 

本文分享自华为云社区《你真的了解java编译优化吗?15个问题考察自己是否理解》,作者:breakDraw 。

 

首先提出一个问题,为什么 C++的编译速度会比 java 慢很多?二者运行程序的速度差异在哪? 了解了 java 的早期和晚期过程,就能理解这个问题了。


这里会提 15 个问题确认是否真的理解,如果完全没这方面的概念,则好好看一下文章末尾的“jvm 编译优化笔记”章节。

早期编译过程


Q: java 早期编译过程分为哪 3 步?A:

1.词法语法解析、填充符号表

2.注解处理

3.语义分析与字节码生成。


Q: 上面的步骤中, 符号表是干吗的?A:符号表是符号地址和符号信息构成的表格。

  • 用于后面阶段做语法检查时,从表里取出信息进行对比。

  • 符号表是目标代码生成时的地址分配的依据


Q: 注解处理器做的什么事情?A: 注解处理器会扫描抽象语法树中带注解的元素, 并进行语法树的更新。重点就是他是基于语法树做更新。更新之后我们会重新走回解析与填充的过程,重新处理。


Q: 上面的 3 个步骤中, 解语法糖是哪一步?A:是第三步,在生成字节码的时候才做的语法糖处理。


Q: 什么是解语法糖?大概有哪些?A:

  • 虚拟机本身不支持这种语法, 但是会在编译阶段 把这些语法糖转为 普通的语法结构。

  • 包含自动装拆箱、 泛型强转应用。


Q: 生成字节码 class 文件的时候, final 和非 final 的局部变量,会有区别不?A:没有区别。局部变量不会在常量池中持有符号引用, 所以不会有 acesses_flasg 信息。** 因此 final 局部变量在运行期没有任何作用, 只会在编译期去校验。**


Q: a= 1 + 2 会在什么阶段进行优化?A: 会在早期编译过程的语义分析过程中,进行常量折叠, 变成 a=3 同理, 字符串+号优化成 stringBuilder.append()这个动作也是该阶段优化的。


Q: 类对象加载的过程有一堆顺序

A:字节码中体现的。

  • 在字节码生成时, 编译器针对对象 new 的过程,会生成了一个<init>方法,里面写明了成员、构造方法的调用顺序。

  • 类静态成员的调用顺序同理封装在<cinit>中。

晚期编译优化


Q:早期编译优化和晚期编译优化的区别?A:

  • 早期编译优化, 是把 java 文件转成字节码,转字节码的过程中做一些简单优化和语法糖处理。

  • 晚期编译优化,是将字节码转机器码执行的过程中,结合一些信息进行动态优化,或者应用上很多的机器码优化措施。


Q: java 程序运行的时候,是直接全部转成优化后的机器码再运行吗?A:错误。

  • 当程序刚启动时,会先马上使用解释器发挥作用,这时候没做太多优化,直接解释执行。

  • 在程序运行后, 编译器逐步发挥作用,把还没用到的代码逐步编译成机器码。

注意这里的编译器和之前提到的编译器的区别,一个是编译成字节码,另一个是编译成机器码。


Q: 有两种晚期优化编译器

  • Client Compiler ——C1 编译器

  • Server Compiler——C2 编译器

他们二者的区别是什么?

A:

  • 速度和质量的区别:

C1 编译器, 更高的编译速度,编译质量一般。

C2 编译器, 更好的编译质量,但是速度慢。

  • 优化特性的区别

C1 编译器都是一些不需要运行期信息就能做优化的操作。

C2 编译器则会根据解释器提供的监控信息,进行激进且动态的优化


Q: java 中怎么区分用 C1 还是 C2?A:关于这 2 种编译器的参数:

  • -Xint 参数: 强制使用解释模式

  • -Xcomp 参数: 强制使用编译模式( 但是如果编译无法进行时, 解释会介入)

  • 选择编译模式时,有-client、-server 还有 MixedMode(混合模式)可以选择


混合模式中, JDK7 引入了分层编译策略:第 0 层: 解释执行。 不开启性能监控。第 1 层: C1 编译, 把字节码编译为本地代码, 进行一些简单优化,加入性能监控第 2 层: C2 编译, 启动耗时较长的优化, 根据性能监控信息进行激进优化


Q: 分层优化中,如果正在运行,jvm 是怎么知道需要对哪些代码做 JIT 或者 OSR 优化?A:

1.被多次调用的方法。 会触发 JIT 编译(热点代码计数器)

2.被多次执行的循环体, 会触发 OSR 编译(栈上替换), 发生在方法执行过程中, 所以是在栈上编译并切换方法。(使用回边计数器)


Q: 哪些方法会在早期优化中做内联,哪些方法会在晚期优化中做内联?A:

  • 不能被继承重写的方法,比如私有、构造器、静态之类的方法,可以直接在早期优化中做内联优化。

  • 其他会被抽象继承实现的方法在早期无法做内联,因为他不知道实际是用哪一段代码.

  • 晚期优化中可以根据一些运行信息,判断是否总是只用某个子类方法跑,是的话做一下尝试内联,如果后面来了其他的子类就切回去。


Q: java 数组一般都会自动做边界检查,不满足就抛异常。 什么情况下会优化掉这个自动检查?A:运行期,发现传入的参数放到数组中用的时候, 肯定不会超出边界,则会优化掉这个检查动作。


看完上面的,就可以给出 C++和 java 编译和运行速度差距的原因了:

1.java 即时编译可能会影响用户体验,如果在运行中出现较大影响的延迟的话。

2. java 中虚方法比 C++要多, 因为做各种内联分析消耗的检查和优化的就越多越大

3.java 中总是要做安全检查, C++中不做,出错了我就直接崩溃了越界了

4.C++中内存释放让用户控制,无需后台弄一个垃圾回收器总是去检查和操作

5.java 好处: 即时编译能够以运行期的性能监控进行优化,这个是 C++无法做到的

jvm 编译优化学习笔记

早期


编译过程大致分为 3 类:

1.解析与填充符号表

2.注解处理

3.分析与字节码生成

关键点:

  • 词法语法解析是第一步,生成符号

  • 注解处理是第二步

  • 然后语法糖、字节码都是第三步的事情。


上述步骤的详细解释:

第一步:

-------词法分析:

就是代码转成 token 标记。例如 int a=b+2 转成 Int \a=\b+\2 这 6 个 token。


-------语法分析(注意实际上只是生成一个语法树,还没做语法的校验):

根据生成的 token,构造一个抽象语法树。


-------填充符号表:

生成一个符号地址和符号信息构成的表格。(后面第三步的阶段会用于语义分析中的标注检查, 比如名字的使用是否和说明一致,也会用于产生中间代码)符号表是目标代码生成时的地址分配的依据


第二步:

-------注解处理器:

注解处理器会扫描抽象语法树中带注解的元素,并进行语法树的更新。更新之后我们会重新走回解析与填充的过程,重新处理。这个处理器是一种插件,我们可以自己不断往其中去添加。

注意,上面这 2 步只是简单去对源文件做转换, 还不涉及任何语法相关的规则。


第三步:

-------语义分析:

判断语法树是否正确。分为 2 种检查:

1.标注检查: 检查变量是否已被声明、 赋值、等式的数据类型是否匹配标注检查中会进行常量折叠, 把 a=1+2 折叠成 3 标注检查的范围比较小,不会有太多上下文依赖。


2.数据及控制流分析对程序上下文逻辑更进一步验证这里会涉及很多交互的上下文交互依赖比如 带返回值的方法是否全路径都包含了返回、 受检异常是否被外部处理、局部变量使用前是否被赋值。

final 局部变量(或者 final 参数)和非 final 局部变量,生成的 class 文件没有区别。因为局部变量不会在常量池中持有符号引用, 所以不会有 acesses_flasg 信息。所以 class 文件不知道局部变量是不是 final,因此 final 局部对运行期没有任何影响, 只会在编译期去校验。


-------解语法糖:

虚拟机本身不支持这种语法,但是会在编译阶段 把这些语法糖转为 普通的语法结构(换句话说做了把语法糖代码变成了普通代码, 例如自动装拆箱,可能就是转成了包装方法的特定调用)


-------字节码生成:

对象的初始化顺序,实际上会在字节码生成阶段, 收敛到一个<init>方法中。 即 init 中控制了那些成员、以及构造方法的调用顺序类初始化同理,也是收敛到一个 <cinit>中 PS: 注意,默认构造器是在填充符号表阶段完成的。字符串的替换(+操作转成 sb) 是在字节码阶段生成的。完成了对语法树的遍历之后,会把最终的符号表交给 ClassWRITE 类,设计概念从一个字节码和文件

晚期


HotSpot 中, 解释器与编译器共存。当程序刚启动时,会先马上使用解释器发挥作用。在程序运行后, 编译器逐步发挥作用,把还没用到的代码逐步编译。内存资源比较少的情况下,可以用解释器来跑程序,减少编译生成的文件。如果编译器的优化出现 bug,可以通过“逆优化”回退到最初的解释器模式来运行


解释器 Interperter

编译器

有两种编译器

  • Client Compiler ——C1 编译器, 更高的编译速度

  • Server Compiler——C2 编译器, 更好的编译质量

即选择了-client 或者-server 时会用到。

  • 默认混合模式: 解释器和编译器共存, 即 MixedMode。


关于这 2 种编译器的参数:

  • -Xint 参数: 强制使用解释模式

  • -Xcomp 参数: 强制使用编译模式( 但是如果编译无法进行时, 解释会介入)


混合模式中,解释器需要收集性能信息,提供给编译阶段判断和优化, 这个性能信息有点浪费因此 JDK7 引入了分层编译策略:

  • 第 0 层: 解释执行。 不开启性能监控。

  • 第 1 层: C1 编译, 把字节码编译为本地代码,进行一些简单优化, 加入性能监控

  • 第 2 层: C2 编译, 启动耗时较长的优化,根据性能监控信息进行激进优化


CC 和 SC 编译过程的区别:

  • client Compiler 编译过程:

前端字节码-》 方法内联/常量传播(基础优化)-》 HIR(高级中间代码)-》空值检查消除/范围检查消除

-》 后端把 HIR 转成 LLR(低级中间代码)-》 线性扫描算法分配寄存器-》窥孔优化-》机器码生成-》本地代码生成

都是一些不需要运行期信息就能做优化的操作

  • serverCompiler 编译过程:

会执行所有的经典优化动作

会根据 cc 或者解释器提供的监控信息,进行激进的优化

寄存器分配器是一个全局图着色分配器

晚期优化的一些常见措施(即运行中才会做优化的步骤)


----热点代码

1. 被多次调用的方法。 会触发 JIT 编译

2. 被多次执行的循环体, 会触发 OSR 编译(栈上替换), 发生在方法执行过程中, 所以是在栈上编译并切换方法。

HotSpot 使用 计数器的热点探测法确定热点代码。* 给每个方法建立方法计数器, 在一个周期中如果超过阈值, 就触发 JIT 编译,编译后替换方法入口。* 如果一个周期内没超过,则计数器/2(半衰)* 如果没有触发时, 都是用解释方式 按照字节码内容死板地运行。

该计数器的相关参数-XX:-UserCounterDecay 关闭热度衰减-XX: CounterHalfLifeTime 设置半衰期-XX:CompileThreshold 设置方法编译阈值

回边计数器就是计算循环次数的计数器* 没有半衰* 但是当触发 OSR 编译时,会把计数器降低,避免还在运行时重复触发。* 会溢出, 并且会把方法计数器也调整到溢出。* clint 模式和 server 模式中, OSR 的阈值计算公式不同, clint= CompileThredshold * osr 比率, server=CompileThredshold * (osr 比率 - 解释器监控比率)


—冗余访问消除:

如果已经拿到了 a.value, 该方法内 a.value 一定不会变的话, 那么后续用到时就不再从 a 中取 value 了复写传播:


y=b.valuez=yc = z + y
复制代码


​变成


y = b.valuey = yc = y + y
复制代码


​无用代码消除:去掉上面的 Y=y


----公共子表达式消除

就是对一些比较长的计算公式做化简 a+(a+b)2 会优化成 a3+b*2 尽可能减少计算次数


—数组边界检查:

如果能确定某个 for 循环里的数组取值操作一定不会超出数组范围,那么在做[]取值操作时,不会做数组边界检查。


—隐式异常处理:

if(a == null) {xxx}else{throw Exception}优化成 try {xxx} catch(Exception e) {throw e}


----方法内联:

不能被继承重写的方法,比如私有、构造器、静态之类的方法,可以直接在早期优化中做内联优化。

而其他会被抽象继承实现的方法在编译器无法做内联,因为他不知道实际是用哪一段代码。

  • final 方法并不是非虚方法(为什么呢)

  • 类型继承关系分析 CHA: 如果发现虚方法,CHA 会查一下当前虚拟机内该方法是否有多个实现,如果发现只有这一种实现,那么就可以直接内联。

  • 如果后续有其他的 class 动态加载进来后,该方法有多个实现了,并且被使用到了,那么就会抛弃已编译的内联代码,回退到解释状态执行。

  • 内联缓存: 即使程序中发现该方法有多个实现, 依然对第一个使用的那个方法做内联,除非有其他重写方法被调用(即虽然你定义了,但是你很可能不用,所以我一直使用你的第一个方法,除非你真的用了多种重写方法去跑。


----逃逸分析:

分析 new 出来的对象是否不会逃逸到方法外, 如果确认只在方法内使用,外部不会有人引用他, 那么就会做优化,比如:* 不把 new 出来的对象放到堆,而是放到方法栈上,方法结束了对象直接消失。* 不需要对这种对象做加锁、同步操作了* 标量替换: 把这个对象里的最小基本类型成员拆出来作为局部变量使用。


----java 和 C++, 即时编译和静态编译的区别:

1.即时编译可能会影响用户体验,如果在运行中出现较大影响的延迟的话

2.java 中虚方法比 C++要多, 因为做各种内联分析消耗的检查和优化的就越多越大

3.java 中总是要做安全检查, C++中不做,出错了我就直接崩溃了越界了 4.C++中内存释放让用户控制, 无需后台弄一个垃圾回收器总是去检查和操作 5.java 好处: 即时编译能够以运行期的性能监控进行优化,这个是 C++无法做到的。


点击关注,第一时间了解华为云新鲜技术~

发布于: 刚刚阅读数: 2
用户头像

提供全面深入的云计算技术干货 2020.07.14 加入

华为云开发者社区,提供全面深入的云计算前景分析、丰富的技术干货、程序样例,分享华为云前沿资讯动态,方便开发者快速成长与发展,欢迎提问、互动,多方位了解云计算! 传送门:https://bbs.huaweicloud.com/

评论

发布
暂无评论
15个问题自查真的了解java编译优化吗