程序编码优化-JAVA篇 编写高质量代码:改善java程序的151个建议
之前一篇博客介绍了C语言中一些基础的编码优化,实际上涉及到编译优化,所有语言进行编译时,相应的编译器都可以进行对应的优化;
1. 字段访问相关优化
基于逃逸分析的优化方式:进行锁消除、栈上分配、标量替换等;标量替换:将对象本身拆散为一个个字段,把原本对象字段的访问,替换为一个个局部变量的访问; 若对象没有逃逸,则:
static int bar(int x) {
Foo foo = new Foo();
foo.a = x;
return foo.a;
}
static int bar(int x) {
int a = x;
return a;
}
即使JIT有这种逃逸分析的功能,但是有时因为内联不够彻底而被即时编译器当成是逃逸的,无法进行标量替换,所以此时需要程序员优化字段访问:
字段读取优化
static int bar(Foo o,int x){
int y = o.a+x;
return o.a+y;
}
static int bar(Foo o,int x){
int t = o.a;
int y = t+x;
return t+y;
}
字段存储优化
class Foo {
int a = 0;
void bar() {
a = 1;
int t = a;
a = t + 2;
}
}
// 优化为
class Foo {
int a = 0;
void bar() {
a = 1;
int t = 1;//少一次访存
a = t + 2;
}
}
// 进一步优化为
class Foo {
int a = 0;
void bar() {
a = 3;
}
}
死代码消除
int bar(int x, int y) {
int t = x*y;
t = x+y;
return t;
}
涉及两个存储局部变量的操作:
int bar(int x, int y) {
return x+y;
}
int bar(boolean f, int x, int y) {
int t = x*y;
if (f)
t = x+y;
return t;
}
优化为:
int bar(boolean f, int x, int y) {
int t;
if (f)
t = x+y;
else
t = x*y;
return t;
}
//精简数据流:
int bar(int x) {
if (false)
return x;
else
return -x;
}
总结起来:字段访问优化主要是减少访存操作;
2. 循环优化
这个优化在优化程序性能中也有提到。
循环无关代码外提
循环中中值不变的表达式,如果不改变程序予以,将这些循环无关代码提出循环外;
int foo(int x, int y, int[] a) {
int sum = 0;
for (int i = 0; i < a.length; i++) {
sum += x * y + a[i];
}
return sum;
}
// 对应的字节码
int foo(int, int, int[]);
Code:
0: iconst_0
1: istore 4
3: iconst_0
4: istore 5
6: goto 25
// 循环体开始
9: iload 4 // load sum
11: iload_1 // load x
12: iload_2 // load y
13: imul // x*y
14: aload_3 // load a
15: iload 5 // load i
17: iaload // a[i]
18: iadd // x*y + a[i]
19: iadd // sum + (x*y + a[i])
20: istore 4 // sum = sum + (x*y + a[i])
22: iinc 5, 1 // i++
25: iload 5 // load i
27: aload_3 // load a
28: arraylength // a.length
29: if_icmplt 9 // i < a.length
// 循环体结束
32: iload 4
34: ireturn
优化为:
int fooManualOpt(int x, int y, int[] a) {
int sum = 0;
int t0 = x * y;
int t1 = a.length;
for (int i = 0; i < t1; i++) {
sum += t0 + a[i];
}
return sum;
}
循环展开
int foo(int[] a) {
int sum = 0;
for (int i = 0; i < 64; i++) {
sum += (i % 2 == 0) ? a[i] : -a[i];
}
return sum;
}
int foo(int[] a) {
int sum = 0;
for (int i = 0; i < 64; i += 2) { // 注意这里的步数是2
sum += (i % 2 == 0) ? a[i] : -a[i];
sum += ((i + 1) % 2 == 0) ? a[i + 1] : -a[i + 1];
}
return sum;
}
在C2中,只有计数循环才能被展开,要满足四个条件:
- 维护一个循环计数器,基于计数器的循环出口只有一个
- 循环计数器类型为int、short或char
- 每个迭代循环计数器增量为常数
- 循环计数器上限或下限是循环无关的数值
循环外提
int foo(int[] a) {
int sum = 0;
for (int i = 0; i < a.length; i++) {
if (a.length > 4) {
sum += a[i];
}
}
return sum;
}
//优化为:
int foo(int[] a) {
int sum = 0;
if (a.length > 4) {
for (int i = 0; i < a.length; i++) {
sum += a[i];
}
} else {
for (int i = 0; i < a.length; i++) {
}
}
return sum;
}
// 进一步优化为:
int foo(int[] a) {
int sum = 0;
if (a.length > 4) {
for (int i = 0; i < a.length; i++) {
sum += a[i];
}
}
return sum;
}
循环剥离
将循环的前几个迭代或者后几个迭代剥离出循环的优化方式。一般来说,循环的前几个迭代或者后几个迭代都包含特殊处理。通过将这几个特殊的迭代剥离出去,可以使原本的循环体的规律性更加明显,从而触发进一步的优化。
int foo(int[] a) {
int j = 0;
int sum = 0;
for (int i = 0; i < a.length; i++) {
sum += a[j];
j = i;
}
return sum;
}
int foo(int[] a) {
int sum = 0;
if (0 < a.length) {
sum += a[0];
for (int i = 1; i < a.length; i++) {
sum += a[i - 1];
}
}
return sum;
}
3. 向量化
如何优化如下代码:
void foo(byte[] dst, byte[] src) {
for (int i = 0; i < dst.length - 4; i += 4) {
dst[i] = src[i];
dst[i+1] = src[i+1];
dst[i+2] = src[i+2];
dst[i+3] = src[i+3];
}
... // post-loop
}
会产生4条读指令以及4条4条写指令;可以优化为:
void foo(byte[] dst, byte[] src) {
for (int i = 0; i < dst.length - 4; i += 4) {
dst[i:i+3] = src[i:i+3];
}
... // post-loop
}
SIMD指令
上面的byte数组,四个数组元素合起来才4字节,如果换成int、long,合起来是16字节、32字节;但是x86_64上通用寄存器大小为64位(8字节),无法暂存这些数据,需要借助XMM寄存器,byte数组向量化读取、写入操作同样适用XMM寄存器;
XMM寄存器由SSE指令集引入,一开始为128位,11年X86上的CPU开始支持AVX指令集,XMM寄存器升级为256位,并更名为YMM寄存器;后又将YMM寄存器升级至512位,更名为ZMM寄存器;
SSE及AVX指令都涉及一个概念:单指令流多数据流(SIMD);SIMD指令:PADDB、PADDW、PADDD以及PADDQ,分别实现byte、short、int、long的向量加法;
void foo(int[] a, int[] b, int[] c) {
for (int i = 0; i < c.length; i++) {
c[i] = a[i] + b[i];
}
}
内存的右边是高位,寄存器的左边是高位,上面这段代码经过向量化优化后,使用PADDD来实现:
c[i:i+3] = a[i:i+3]+b[i:i+3],可以看做是CPU指令级别并行
c.length/4是理论值,现实中C2还考虑缓存行对齐因素,能够应用向量化加法的仅有数组中间部分元素;
使用SIMD的hotspot intrinsic
SIMD虽然高效,使用麻烦,主要因为不同CPU支持的SIMD指令可能不同,越新的SIMD指令,支持的寄存器长度越大,功能越强;几乎所有x86_64支持SSE指令集,绝大部分支持AVX指令集;
但是JAVA虚拟机执行的java字节码是平台无关的,首先被解释执行,而后返回执行的部分才会被java虚拟机编译为机器码;进行编译时,已经知道java虚拟机的目标CPU,可以知道其所支持的指令集;
Java字节码的平台无关性引发另一个问题,Java程序无法像C++程序那样,直接使用Intel提供的,被替换为具体SIMD指令的intrinsic方法;HotSpot提供的替代方案:Java层面的intrinsic方法,这些intrinsic语义比单个SIMD指令复杂,运行过程,hotspot虚拟机根据当前体系架构来决定是否对该intrinsic方法的调用替换为另一种高效的实现,否则使用原本的java实现;
由于开发成本及维护成本高,这种intrinsic数量少,如System.arraycopy和Arrays.copyOf、Arrays.equals及Java9的Arrays.compare和Arrays.mismatch以及String.indexOf、StringLatin1.inflate。
这些intrinsic只能做点点覆盖,不少情况,并不会用到intrinsic,又存在向量化优化机会,这时候需要借助编译器中的自动向量化;
自动向量化
JIT的自动向量化针对能够展开的计数循环,进行向量优化,即JIT能够自动展开优化成使用PADDD指令的向量加法; 自动向量化条件:
- 循环变量增量为1
- 循环变量不能为long类型,C2无法将循环识别为计数循环
- 循环迭代之间最好不要有数据依赖,如a[i]=a[i-1]
- 不能有分支跳转
- 不要手工进行循环展开
自动向量化条件较为苛刻,C2支持的整数向量化操作不多,只有向量加法、向量减法、按位与、或、异或以及批量移位、批量乘法;C2还支持向量点积的自动向量化(两两香橙再求和);
为了解决intrinsic以及自动向量化覆盖面过窄的问题,openJDK尝试引入开发人员可控的向量化抽象;
HotSpot运用向量优化的方式有两种:1)使用HotSpot intrinsic,在调用特定方法时候替换为使用了SIMD指令的高效实现,属于点覆盖;2)依赖即时编译器进行自动向量化,在循环展开优化之后将不同迭代的运算合并为向量运算。自动向量化的触发条件较为苛刻,因此也无法覆盖大多数用例。
注解
- RetentionPolicy.SOURCE:注解只保留在源文件,当Java文件编译成class文件的时候,注解被遗弃;
- RetentionPolicy.CLASS:注解被保留到class文件,但jvm加载class文件时候被遗弃,这是默认的生命周期;
- RetentionPolicy.RUNTIME:注解不仅被保存到class文件中,jvm加载class文件之后,仍然存在;
这3个生命周期分别对应于:Java源文件(.java文件) ---> .class文件 ---> 内存中的字节码;
生命周期:source<class<runtime;一般如果需要在运行时主动获取注解信息,只能用runtime注解;编译时进行一些预处理操作,比如生成一些辅助代码,用class注解;只做一些检查工作,如@override和@supresswarnings,可用source注解;
这里很重要的一点是编译多个Java文件时的情况:假如要编译A.java源码文件和B.class文件,其中A类依赖B类,并且B类上有些注解希望让A.java编译时能看到,那么B.class里就必须要持有这些注解信息才行
4. 性能测试中的坑
普通测试方法,影响因素:java虚拟机堆空间的自适配、即时编译;还有一些指令优化、计数循环优化(把i为int改为long,即可避免这个优化);操作系统和硬件系统带来的影响,一个较为常见的例子便是电源管理策略,许多机器特别是笔记本,会动态配置CPU频率,而CPU频率直接影响到性鞥测试的数据,短时间的性能测试未必可靠;
OpenJdk开源项目JMH,内置许多方法提供了标准测试;
Java 中的native方法的链接方式主要有两种。一是按照 JNI 的默认规范命名所要链接的 C 函数,并依赖于Java 虚拟机自动链接。另一种则是在 C 代码中主动链接;(第二种还是要依赖第一种)
JNI 中的引用可分为局部引用和全局引用。这两者都可以阻止垃圾回收器回收被引用的 Java对象。不同的是,局部引用在native方法调用返回之后便会失效。传入参数以及大部分JNIAPI函数的返回值都属于局部引用。