前端优化
语法糖(Syntactic Sugar),也称糖衣语法,是由英国计算机科学家Peter J.Landin发明的一种编程术语,指的是在计算机语言中添加的某种语法,这种语法对语言的编译结果和功能并没有实际影响,但是却能更方便程序员使用该语言。通常来说使用语法糖能够减少代码量、增加程序的可读性,从而减少程序代码出错的机会。
默认构造器
我们在Java源码里没有编写任何一个构造函数,Java编译器会自动生成一个无参默认构造函数
public Main() {
super();// 即调用父类 Object 的无参构造方法,即调用 java/lang/Object."<init>":()V
}
init方法是编译器将类实例的初始化与构造函数的集成的一个初始化方法,无论构造函数位置在哪里,它总是最后一个被执行。
泛型
泛型的本质是参数化类型(Parameterized Type)或者参数化多态(Parametric Polymorphism)的应用,即可以将操作的数据类型指定为方法签名中的一种特殊参数,这种参数类型能够用在类、接口和方法的创建中,分别构成泛型类、泛型接口和泛型方法。泛型让程序员能够针对泛化的数据类型编写相同的算法,这极大地增强了编程语言的类型系统及抽象能力。
Java选择的泛型实现方式叫作“类型擦除式泛型”(Type Erasure Generics):无论何时定义一个泛型类型, 都自动提供了一个相应的原始类型( raw type )。原始类型的名字就是删去类型参数后的泛型类型名。擦除(erased) 类型变量,并替换为限定类型(无限定的变量用 Object)。原始类型用第一个限定的类型变量来替换, 如果没有给定限定就用 Object 替换。
在 Java 多态中,泛型可能会带来两个问题:
- 类型擦除与多态的冲突;
- 方法签名冲突。
看个示例:
public class Pair<T>{
protected T first;
protected T second;
public T getFirst() {
return first;
}
public void setFirst(T first) {
this.first = first;
}
public T getSecond() {
return second;
}
public void setSecond(T second) {
this.second = second;
}
}
public class SonOfPair extends Pair<LocalDate> {
@Override
public void setSecond(LocalDate second) {
if (getFirst() != null && getFirst().isBefore(second)) {
super.setSecond(second);
}
}
}
编译后查看SonOfPair
的字节码信息,可以看到setSecond方法竟然有2个,如下所示,一个方法的参数是Object,一个是子类的限定类型LocalDate。
public void setSecond(java.time.LocalDate);
descriptor: (Ljava/time/LocalDate;)V
flags: ACC_PUBLIC
Code:
stack=2, locals=2, args_size=2
0: aload_0
1: invokevirtual #2 // Method getFirst:()Ljava/lang/Object;
4: ifnull 26
7: aload_0
8: invokevirtual #2 // Method getFirst:()Ljava/lang/Object;
11: checkcast #3 // class java/time/LocalDate
14: aload_1
15: invokevirtual #4 // Method java/time/LocalDate.isBefore:(Ljava/time/chrono/ChronoLocalDate;)Z
18: ifeq 26
21: aload_0
22: aload_1
23: invokespecial #5 // Method chenly/jvm/candy/Pair.setSecond:(Ljava/lang/Object;)V
26: return
LineNumberTable:
line 13: 0
line 14: 21
line 16: 26
LocalVariableTable:
Start Length Slot Name Signature
0 27 0 this Lchenly/jvm/candy/SonOfPair;
0 27 1 second Ljava/time/LocalDate;
StackMapTable: number_of_entries = 1
frame_type = 26 /* same */
public void setSecond(java.lang.Object);
descriptor: (Ljava/lang/Object;)V
flags: ACC_PUBLIC, ACC_BRIDGE, ACC_SYNTHETIC
Code:
stack=2, locals=2, args_size=2
0: aload_0
1: aload_1
2: checkcast #3 // class java/time/LocalDate
5: invokevirtual #6 // Method setSecond:(Ljava/time/LocalDate;)V
8: return
LineNumberTable:
line 9: 0
LocalVariableTable:
Start Length Slot Name Signature
0 9 0 this Lchenly/jvm/candy/SonOfPair;
}
在Java核心技术卷I 记录(2)文章中提到因为泛型擦除导致的多态(父类引用指向子类对象,调用方法时会调用子类的实现,而不是父类的实现,这叫多态)冲突,为了解决这个问题,,Java虚拟机会创建一个桥接方法,与父类的参数相同都为Object类型,在这个方法里调用子类真正的复写的方法。我们可以从第五条命令看出来调用了子类的setSecond()方法:
5: invokevirtual #6 // Method setSecond:
再谈方法签名冲突,以上我们知道了Java虚拟机会为我们生成桥接方法,那么get方法呢?我们知道方法同名但是参数又相同在日常编写代码中是不被允许的,但是在虚拟机中,它用参数类型和返回类型确定一个方法。因此, 编译器可能产生两个仅返回类型不同的方法字节码, 虚拟机能够正确地处理这一情况。
自动装箱、拆箱与遍历循环
我们知道在集合里是没有基本类型的,但是我们借助编译器自动装箱拆箱来往集合里填充和取值
public static void main(String... args) {
List<Integer> list = Arrays.asList(1, 2, 3, 4);
int sum = 0;
for (int i : list) {
sum += i;
}
System.out.println(sum);
}
以上的代码一共包含了泛型、自动装箱、自动拆箱、遍历循环与变长参数,泛型上文介绍过来,这里就不赘述。
自动装箱拆箱是怎么做到的呢?其实是编译器在生成字节码的时候,自动生成调用装拆箱的字节码,包装类型的valueOf装箱,intValue拆箱。
可变参数String... args其实是一个String[] args,同样Java编译器在编译期间会将String... args变成String[] args。
foreach循环也是一个语法糖,会变编译成iterator形式调用
public static void main(String[] args) {
List list = Arrays.asList( new Integer[] {
Integer.valueOf(1),
Integer.valueOf(2),
Integer.valueOf(3),
Integer.valueOf(4) });
int sum = 0;
for (Iterator localIterator = list.iterator(); localIterator.hasNext(); ) {
int i = ((Integer)localIterator.next()).intValue();
sum += i;
}
System.out.println(sum);
}
后端优化
如果我们把字节码看作是程序语言的一种中间表示形式(Intermediate Representation,IR)的话,那编译器无论在何时、在何种状态下把Class文件转换成与本地基础设施(硬件指令集、操作系统)相关的二进制机器码,它都可以视为整个编译过程的后端。
即时编译器(Just In Time,JIT)
当虚拟机发现某个方法或代码块的运行特别频繁,就会把这些代码认定为“热点代码”(Hot Spot Code),为了提高热点代码的执行效率,在运行时,虚拟机将会把这些代码编译成本地机器码,并以各种手段尽可能地进行代码优化,运行时完成这个任务的后端编译器被称为即时编译器。
解释器与编译器
当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译的时间,立即运行。当程序启动后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码,这样可以减少解释器的中间损耗,获得更高的执行效率。当程序运行环境中内存资源限制较大,可以使用解释执行节约内存(如部分嵌入式系统中和大部分的JavaCard应用中就只有解释器的存在),反之可以使用编译执行来提升效率。同时,解释器还可以作为编译器激进优化时后备的“逃生门”(如果情况允许,HotSpot虚拟机中也会采用不进行激进优化的客户端编译器充当“逃生门”的角色),让编译器根据概率选择一些不能保证所有情况都正确,但大多数时候都能提升运行速度的优化手段,当激进优化的假设不成立,如加载了新类以后,类型继承结构出现变化、出现“罕见陷阱”(UncommonTrap)时可以通过逆优化(Deoptimization)退回到解释状态继续执行,因此在整个Java虚拟机执行架构里,解释器与编译器经常是相辅相成地配合工作。
即时编译器(JIT)与解释器的区别
- 解释器是将字节码解释为机器码,下次即使遇到相同的字节码,仍会执行重复的解释
- JIT 是将一些字节码编译为机器码,并存入 Code Cache,下次遇到相同的代码,直接执行,无需再编译
- 解释器是将字节码解释为针对所有平台都通用的机器码 JIT 会根据平台类型,生成平台特定的机器码
分层编译
HotSpot虚拟机中内置了两个(或三个)即时编译器,其中有两个编译器存在已久,分别被称为“客户端编译器”(Client Compiler)和“服务端编译器”(Server Compiler),或者简称为C1编译器和C2编译器(部分资料和JDK源码中C2也叫Opto编译器),第三个是在JDK 10时才出现的、长期目标是代替C2的Graal编译器。Graal编译器目前还处于实验状态。
JVM 将执行状态分成了 5 个层次:
- 0 层,解释执行(Interpreter)
- 1 层,使用 C1 即时编译器编译执行(不开启性能监控功能(profiling))
- 2 层,使用 C1 即时编译器编译执行(仅开启方法及回边次数统计等基本的 profiling)
- 3 层,使用 C1 即时编译器编译执行(带完全的 profiling) ,还会收集如分支跳转、虚方法调用版本等全部的统计信息
- 4 层,使用 C2 即时编译器编译执行,服务端编译器会启用更多编译耗时更长的优化,还会根据性能监控信息进行一些不可靠的激进优化。
对于占据大部分的不常用的代码,我们无需耗费时间将其编译成机器码,而是采取解释执行的方式运行;另一方面,对于仅占据小部分的热点代码(被多次调用的方法或者被多次执行的循环体),我们则可以将其编译成机器码,以达到理想的运行速度。 执行效率上简单比较一下 Interpreter < C1 < C2,总的目标是发现热点代码(hotspot名称的由来),优化之。
要知道某段代码是不是热点代码,是不是需要触发即时编译,这个行为称为“热点探测”(HotSpot Code Detection),探测方法有2种:
- 基于采样的热点探测(Sample Based Hot Spot Code Detection)。采用这种方法的虚拟机会周期性地检查各个线程的调用栈顶,如果发现某个(或某些)方法经常出现在栈顶,那这个方法就是“热点方法”。基于采样的热点探测的好处是实现简单高效,还可以很容易地获取方法调用关系(将调用堆栈展开即可),缺点是很难精确地确认一个方法的热度,容易因为受到线程阻塞或别的外界因素的影响而扰乱热点探测。
- 基于计数器的热点探测(Counter Based Hot Spot Code Detection)。采用这种方法的虚拟机会为每个方法(甚至是代码块)建立计数器,统计方法的执行次数,如果执行次数超过一定的阈值就认为它是“热点方法”。这种统计方法实现起来要麻烦一些,需要为每个方法建立并维护计数器,而且不能直接获取到方法的调用关系。但是它的统计结果相对来说更加精确严谨。【HotSpot使用】
逃逸分析
逃逸分析的基本原理:分析对象动态作用域,当一个对象在方法里面被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他方法中,这种称为方法逃逸;甚至还有可能被外部线程访问到,譬如赋值给可以在其他线程中访问的实例变量,这种称为线程逃逸;从不逃逸、方法逃逸到线程逃逸,称为对象由低到高的不同逃逸程度。可以使用 -XX:- DoEscapeAnalysis 关闭逃逸分析。
如果能证明一个对象不会逃逸到方法或线程之外(换句话说是别的方法或线程无法通过任何途径访问到这个对象),或者逃逸程度比较低(只逃逸出方法而不会逃逸出线程),则可能为这个对象实例采取不同程度的优化:
- 栈上分配
- 标量替换
- 同步消除
public class JIT1 {
// -XX:+PrintCompilation -XX:-DoEscapeAnalysis
public static void main(String[] args) {
for (int i = 0; i < 200; i++) {
long start = System.nanoTime();
for (int j = 0; j < 1000; j++) {
new Object();
}
long end = System.nanoTime();
System.out.printf("%d\t%d\n",i,(end - start));
}
}
}
编译过程
后台执行编译的过程中,对于客户端编译器来说,它是一个相对简单快速的三段式编译器,主要的关注点在于局部性的优化,而放弃了许多耗时较长的全局优化手段。
- 一个平台独立的前端将字节码构造成一种高级中间代码表示(High-LevelIntermediate Representation,HIR,即与目标机器指令集无关的中间表示)。HIR使用静态单分配(Static Single Assignment,SSA)的形式来代表代码值,这可以使得一些在HIR的构造过程之中和之后进行的优化动作更容易实现。在此之前编译器已经会在字节码上完成一部分基础优化,如方法内联、常量传播等优化将会在字节码被构造成HIR之前完成。
- 一个平台相关的后端从HIR中产生低级中间代码表示(Low-Level IntermediateRepresentation,LIR,即与目标机器指令集相关的中间表示),而在此之前会在HIR上完成另外一些优化,如空值检查消除、范围检查消除等,以便让HIR达到更高效的代码表示形式。
- 在平台相关的后端使用线性扫描算法(Linear Scan Register Allocation)在LIR上分配寄存器,并在LIR上做窥孔(Peephole)优化,然后产生机器代码。
方法内联
示例
private static int square(final int i) {
return i * i;
}
System.out.println(square(9));
如果发现 square 是热点方法,并且长度不太长时,会进行内联,所谓的内联就是把方法内代码拷贝、 粘贴到调用者的位置:
System.out.println(9 * 9);
当然了,它的优化不仅仅是这么简单的,在Java中多态比比皆是,对于这种可能被复写的虚方法,Java虚拟机首先引入了一种名为类型继承关系分析(Class HierarchyAnalysis,CHA)的技术,这是整个应用程序范围内的类型分析技术,用于确定在目前已加载的类中,某个接口是否有多于一种的实现、某个类是否存在子类、某个子类是否覆盖了父类的某个虚方法等信息。如果遇到虚方法,则会向CHA查询此方法在当前程序状态下是否真的有多个目标版本可供选择,如果查询到只有一个版本,那就可以假设“应用程序的全貌就是现在运行的这个样子”来进行内联,这种内联被称为守护内联(Guarded Inlining)。如果加载了导致继承关系发生变化的新类,那么就必须抛弃已经编译的代码,退回到解释状态进行执行,或者重新进行编译。假如向CHA查询出来的结果是该方法确实有多个版本的目标方法可供选择,那即时编译器还将进行最后一次努力,使用内联缓存(Inline Cache)的方式来缩减方法调用的开销。这种状态下方法调用是真正发生了的,但是比起直接查虚方法表还是要快一些。
常量传播/常量折叠
常量传播:在编译优化时,将能够计算出结果的变量直接替换为常量。 如
int x = 14;
int y = 7 - x / 2;
return y * (28 / x + 2);
传播x变量将会变成:
int x = 14;
int y = 7 - 14 / 2;
return y * (28 / 14 + 2);
持续传播,则触发常量折叠(在编译优化时,多个变量进行计算时,而且能够直接计算出结果,那么变量将由常量直接替换),会变成:(还可以再进一步的消除无用代码x及y来进行最佳化)
int x = 14;
int y = 0;
return 0;