您的位置 首页 java

程序编码优化-JAVA篇

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)依赖即时编译器进行自动向量化,在循环展开优化之后将不同迭代的运算合并为向量运算。自动向量化的触发条件较为苛刻,因此也无法覆盖大多数用例。

注解
  1. RetentionPolicy.SOURCE:注解只保留在源文件,当Java文件编译成 class文件 的时候,注解被遗弃;
  2. RetentionPolicy.CLASS:注解被保留到class文件,但 jvm 加载class文件时候被遗弃,这是默认的生命周期;
  3. 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函数的返回值都属于局部引用。

文章来源:智云一二三科技

文章标题:程序编码优化-JAVA篇

文章地址:https://www.zhihuclub.com/186629.shtml

关于作者: 智云科技

热门文章

网站地图