Java 程序在运行的时候,主要就是执行字节码指令,一般这些指令会按照顺序解释执行,这种就是解释执行。
那些被频繁调用的代码,比如调用次数很高或者在 for 循环里的那些代码,如果按照解释执行,效率是非常低的。这些代码称为热点代码。为了提高热点代码的执行效率,在运行时,虚拟机将会把这些代码编译成与本地平台相关的机器码,并进行各种层次的优化。完成这个任务的编译器,就称为即时编译器(Just In Time Compiler),简称 JIT 编译器。
在 HotSpot 虚拟机中,内置了两个 JIT,分别为 C1 编译器和 C2 编译器:
- C1:适用于执行时间较短或对启动性能有要求的程序
- 是一个简单快速的编译器,主要的关注点在于局部性的优化,适用于执行时间较短或对启动性能有要求的程序
- 例如,GUI 应用对界面启动速度就有一定要求,C1 也被称为 Client Compiler。
- C2:适用于执行时间较长或对峰值性能有要求的程序
- 是为长期运行的服务器端应用程序做性能调优的编译器,适用于执行时间较长或对峰值性能有要求的程序。根据各自的适配性,这种即时编译也被称为 Server Compiler。
热点代码
热点代码,就是那些被频繁调用的代码,比如调用次数很高或者在 for 循环里的那些代码。这些再次编译后的机器码会被缓存起来,以备下次使用,但对于那些执行次数很少的代码来说,这种编译动作就纯属浪费。
CodeCache
JVM 参数 -XX:ReservedCodeCacheSize
,默认值 240M
用来限制 CodeCache 的大小。也就是说,JIT 编译后的代码都会放在 CodeCache 里。如果这个空间不足,JIT 就无法继续编译,编译执行会变成解释执行,性能会降低一个数量级。同时,JIT 编译器会一直尝试去优化代码,从而造成了 CPU 占用上升。
保存在方法区。
热点探测
在 HotSpot 虚拟机中的热点探测是 JIT 优化的条件,热点探测是基于计数器的热点探测,采用这种方法的虚拟机会为每个方法建立计数器统计方法的执行次数,如果执行次数超过一定的阈值就认为它是“热点方法”,虚拟机为每个方法准备了两类计数器:方法调用计数器(Invocation Counter)和回边计数器(Back Edge Counter)。在确定虚拟机运行参数的前提下,这两个计数器都有一个确定的阈值,当计数器超过阈值溢出了,就会触发 JIT 编译。
方法调用计数器
用于统计方法被调用的次数,方法调用计数器的默认阈值在客户端模式下是 1500 次,在服务端模式下是 10000 次(我们用的都是服务端,java –version 查询),可通过 -XX: CompileThreshold
来设定
回边计数器(C1)
用于统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令称为“回边”(Back Edge),该值用于计算是否触发 C1 编译的阈值,在不开启分层编译的情况下,在服务端模式下是 10700。可通过 -XX: OnStackReplacePercentage=N
来设置。
默认阈值的计算方法:
-
回边计数器阈值 = 方法调用计数器阈值(CompileThreshold)×(OSR 比率(OnStackReplacePercentage)-解释器监控比率(InterpreterProfilePercentage)/100
-
其中
OnStackReplacePercentage
默认值为140,InterpreterProfilePercentage
默认值为33,如果都取默认值,那Server 模式虚拟机回边计数器的阈值为 10700. -
即:回边计数器阈值=10000 × (140-33) = 10700
栈上编译:
建立回边计数器的主要目的是为了触发 OSR(On StackReplacement)编译,即栈上编译。在一些循环周期比较长的代码段中,当循环达到回边计数器阈值时,JVM 会认为这段是热点代码,JIT 编译器就会将这段代码编译成机器语言并缓存,在该循环时间段内,会直接将执行代码替换,执行缓存的机器语言。
分层编译
在 Java7 之前,需要根据程序的特性来选择对应的 JIT,虚拟机默认采用解释器和其中一个编译器配合工作。Java7 引入了分层编译,这种方式综合了 C1 的启动性能优势和 C2 的峰值性能优势,我们也可以通过参数“-client”“-server” 强制指定虚拟机的即时编译模式。
-XX: CompileThreshold, -XX: OnStackReplacePercentage
指定的阈值将失效,将根据当前待编译的方法数以及编译线程数来动态调整。
通过 java -version
查看到当前系统使用的编译模式
编译模式:
- 默认 mixed mode:分层编译
- interpreted mode:只有解释器;通过
java -Xint
指定 - compiled mode:只有 JIT;通过
java -Xcomp
指定
5 个层次:
- 第 0 层:程序解释执行,默认开启性能监控功能(Profiling),如果不开启,可触发第二层编译;
- 第 1 层:可称为 C1 编译,将字节码编译为本地代码,进行简单、可靠的优化,不开启 Profiling;
- 第 2 层:也称为 C1 编译,开启 Profiling,仅执行带方法调用次数和循环回边执行次数 profiling 的 C1 编译;
- 第 3 层:也称为 C1 编译,执行所有带 Profiling 的 C1 编译;
- 第 4 层:可称为 C2 编译,也是将字节码编译为本地代码,但是会启用一些编译耗时较长的优化,甚至会根据性能监控信息进行一些不可靠的激进优化。
编译优化技术
方法内联
方法内联的优化行为就是把目标方法的代码复制到发起调用的方法之中,避免发生真实的方法调用。JVM 会自动识别热点方法,并对它们使用方法内联进行优化。但如果这个方法体太大了,JVM 将不执行内联操作。如果循环太少,也不会触发方法内联。
JVM 参数:
-XX:CompileThreshold
设置热点方法的阈值-XX:FreqInlineSize
经常执行的方法,默认情况下,方法体大小小于 325 字节的都会进行内联-XX:MaxInlineSize
不是经常执行的方法,默认情况下,方法大小小于 35 字节才会进行内联- 显示优化信息
-XX:+PrintCompilation
在控制台打印编译过程信息-XX:+UnlockDiagnosticVMOptions
解锁对 JVM 进行诊断的选项参数。默认是关闭的,开启后支持一些特定参数对 JVM 进行诊断-XX:+PrintInlining
将内联方法打印出来
提高方法内联:
- 通过设置 JVM 参数来减小热点阈值或增加方法体阈值,以便更多的方法可以进行内联,但这种方法意味着需要占用更多地内存
- 在编程中,避免在一个方法中写大量代码,习惯使用小方法体
- 尽量使用 final、private、static 关键字修饰方法,编码方法因为继承,会需要额外的类型检查
锁消除
在非线程安全的情况下,尽量不要使用线程安全容器,比如 StringBuffer
。由于 StringBuffer
中的 append
方法被 Synchronized
关键字修饰,会使用到锁,从而导致性能下降。但是锁消除技术会使代码的执行效率和无锁时一样。
在局部方法中创建的对象只能被当前线程访问,无法被其它线程访问,这个变量的读写肯定不会有竞争,这个时候 JIT 编译会对这个对象的方法锁进行锁消除。
JVM 参数:-XX:+EliminateLocks
开启锁消除,JDK 1.8 默认开启
标量替换
逃逸分析证明一个对象不会被外部访问,如果这个对象可以被拆分的话,当程序真正执行的时候可能不创建这个对象,而直接创建它的成员变量来代替。将对象拆分后,可以分配对象的成员变量在栈或寄存器上,原本的对象就无需分配内存空间了。这种编译优化就叫做标量替换(前提是需要开启逃逸分析)。
JVM 参数:-XX:+EliminateAllocations
开启标量替换, JDK 1.8 默认开启
栈上分配
如果是逃逸分析出来的对象可以在栈上分配的话,那么该对象就不需要垃圾回收,如果是频繁的调用此方法则可以得到很大的性能提高。采用了逃逸分析后,满足逃逸的对象在栈上分配。
逃逸分析:
分析对象动态作用域,当一个对象在方法中定义后,它可能被外部方法所引用。
比如:调用参数传递到其他方法中,这种称之为方法逃逸。甚至还有可能被外部线程访问到,例如:赋值给其他线程中访问的变量,这个称之为线程逃逸。
从不逃逸到方法逃逸到线程逃逸,称之为对象由低到高的不同逃逸程度。
如果确定一个对象不会逃逸出线程之外,那么让对象在栈上分配内存可以提高 JVM 的效率。
栈上编译
汇编计数器触发栈上编译,上面已经讲过了。