转眼一本书记了一半了,看了个囫囵吞枣,但也不算全无收获。只是过于硬核,希望可以有实用价值。
这一章其实是告诉你编译器怎么优化,不是教你怎么优化自己的代码。
6.1 早期优化--Javac编译器
Java的编译期有:前期编译器,把.java编译成.class文件(javac编译器);后期运行期编译器,JIT编译器(Just In Time Compiler),用于将class文件中的字节码转变成机器码(C1,C2编译器);静态提前编译器,AOT(Ahead Of Time Compiler),可以直接把.java编译成机器码(GNU Compiler for the JAVA)。本章主要针对前期编译器。(早期优化)
1.解析与填充符号表
词法、语法分析:将语句字节流解析成标记(token)的集合,token为语句中能拆解出的最小单位(大概就是能被space分隔开的单位)。语法分析会将token序列构造成抽象语法树(AST)。
填充符号表:符号表是一组装有符号地址和符号信息的表格,类似键值对表。每个阶段都会用到符号表,javac编译器可以填写。
2.注解处理器
读取Annotation。
3.语义分析与字节码生成
标注检查:检查变量在使用时是否被声明,变量和变量值的类型是否匹配等。编译期间就会进行变量折叠。
数据及控制流分析:分析上下文逻辑是否顺畅。局部变量使用时是否已被赋值,每个方法是否有返回语句...
解语法糖:语法糖,就是一些可以帮忙偷懒的语句。翻译成正常语句。
字节码生成:把前面生成的东西转化成字节码,写入磁盘中。
语法糖
1.泛型与类型擦除
把类型模糊成一种,类似List<E>,本质上都是Object,硬转,不算是硬核泛型。而且是Java自己把类型擦掉了,就是说List<Integer>和List<String>在编译期属于一种东西,会引起混乱,尤其在重载时,分不清。
2.自动装箱、拆箱与遍历循环
// 自动装箱
List<Integer> list = [1,2,3,4];
// 遍历循环与拆箱
for(int i: list){
System.out.println(i);
}
3.条件编译
条件为常量时编译器会自己判断代码可达性。
if(true){
System.out.println("true");
}else {
// unreachable
System.out.println("false");
}
while(false){
// unreachable
System.out.println("false");
}
6.2 晚期(运行期)优化---JIT编译器
6.2.1 热点代码
即时编译器不是虚拟机必需组件,却是衡量虚拟机性能的重要组件。Java使用编译器将代码预编译成字节码,再用字节码解释器运行。
在运行过程中,被多次调用的方法,被多次执行的循环体会被标记为“热点代码”。虽然循环体就在方法里,但java自有办法看出来一个方法中的多次循环。具体怎么算多?
1.基于采样的热点探测(sample based hot spot detection):周期性检查每个线程的栈顶,如果该方法经常出现在栈顶则为热点代码,然而怎么算经常?这是不不准确的。
2.基于计数器的热点探测(counter based hot spot detectoin):具体就是用阈值。使用计数器统计方法的执行次数,超过阈值就是热点,那怎么取阈值?就在程序员选择了。计数器分两种,方法调用计数器(invocation counter)与回边计数器(back edge counter),阈值是两个计数器之和。方法调用计数器统计方法被调用的次数,如果方法未被编译,则+1,直到两计数器之和达到阈值,交给编译器编译该方法。如果一定时间(也是自设参数)为达到阈值,将该方法的方法调用计数器减半,称为衰减(counter decay),这段时间叫半衰期(counter half life time)。回边计数器用于统计循环体,每次循环结束后跳记为一次回边。回边计数器没有衰减机制。同样,两计数器和超过阈值,交给编译器编译。
6.2.2 编译过程
具体编译器的编译过程暂且跳过。 就是字节码->高级中间代码(High-level intermediate representation)->低级中间代码(low-level intermediate representation),最后扫描成既期待吗。
6.2.3 编译优化
Java虚拟机的优化措施都集中在了JIT中,所以编译的代码肯定比解释器运行快。编译器有很多方法优化代码,是JVM给程序员的福利。程序员想优化自己的代码还要去看别的书,所以这部分一直在跳过。但也看看JVM的优化方式,给自己找找灵感,也给JVM省省事。
内联(Method Inlining):方法的内联可以省去调用方法的成本,因为调用方法就要建立栈帧;另外可以为其他优化做基础。内联是编译器优化的最佳工具。简单说就是把方法中的简单语句直接拽到调用者这里,省去了调用的过程。多说无益,写个例子一层层扒去没用的代码就完了。
static class B{
int value;
final int get(){
return value;
}
}
public void foo(){
y = b.get();
z = b.get();
sum = y + z;
}
第一次优化,省去get()的调用。
public void foo(){
y = b.value;
z = b.value;
sum = y + z;
}
y,z等值,没必要再调用一次value。
public void foo(){
y = b.value;
z = y;
sum = y + z;
}
还要给z开辟内存空间,没必要。
public void foo(){
y = b.value;
sum = y + y;
}
最后就非常简单了,但这是编译器做的事,你能这么写代码吗?不可能。
1.公共子表达式消除
一个表达式如果已经求出来值赋给一个变量,那这个表达式就再也不需要被计算了。这个编译器可以做到,但程序员能做还是不要经常一个表达式写好几次,应该是这个意思。
2.数组边界检查消除
不是每次查找数组元素都一定要检查边界值,如果已知index只会在[0,array.length)范围内,就不会超过边界值。但偷懒一定会有代价。
3.方法内联
方法内联不只是简单的把方法内的代码复制过来,因为涉及到多态时选择会成为问题。具体怎么内联,就交给JVM开发人员了。
“类型继承关系分析”(Class Hierarchy Analysis),用于确定目前加载出来的:类中,是否存在子类,子类中是否存在抽象类;接口中,是否有多于一种实现。这样在内联时,如果没有遇到虚方法,直接联;如果是虚方法,在CHA中查,为了防止查不到,还设计了“逃生门”用于后退。还是很有才的。
4.逃逸分析
逃逸分析用于分析对象的动态作用域。什么是逃逸?对象在方法中被创建后,被其他线程或外部方法访问到,称为逃逸。逃逸不是一件好事,代码要低耦合。如果可以证明对象不能逃逸,就可以针对其做出一些优化:
·栈上分配:虽然对象理应被分配到堆上,但如果不逃逸,可以在栈上分配,这样方法结束,栈销毁,对象也销毁。
·同步消除: 没有逃逸,就不会被装进同步区。
·标量替换:如果该对象不会逃逸,可以将其拆分成若干标量(标量指Java中最小单位,int,char等)。
6.2.4 Java与C/C++编译器对比
说Java速度慢,曾经主要因为解释器的速度慢。现在的区别主要出于两者的编译器速度,Java使用即时编译器,C++使用静态编译器。
Java不行的地方:
1.即时编译器需要与运行抢时间,势必增长运行时间。无论怎么优化,都会在运行时感到延迟。
2.Java是类型安全语言,在运行时需要频繁检查变量空指针、数组越界、类型继承等问题,像一个操心的老母亲。
3.因为面向对象,所以多态,所以虚方法很多。在多态方面,频率远高于C++,所以在编译时需要时间,例如上文提到的内联。
4.Java支持动态扩展,因此在运行中会改变现有的类继承关系,而因为动态,所以不能看到实时全貌,所以变化时会有调整。
5.Java的对象都在堆上分配(非逃逸的栈上对象不提),只有局部变量在栈上分配。而C/C++可以在堆上栈上分配,栈上的对象易于回收。主要在C++的对象都是程序员主动回收,成败在个人。
Java行的地方:
所有速度不行的原因,都是Java行的地方。时间不是白花的。