JVM系列之:关于即时编译器的其他一些优化手段

简介: JVM系列之:关于即时编译器的其他一些优化手段

1.jpg


本文为《深入学习 JVM 系列》第二十篇文章


在前面两篇文章讲述了即时编译器的两种特殊优化技术:方法内联和逃逸分析,其中基于逃逸分析结果又有三种优化方式:同步消除、栈上分配以及标量替换。


除此之外,即时编译器还有很多优化手段,其中有不少经典编译器的优化手段,也有许多针对 Java 语言,或者说针对运行在 Java 虚拟机上的所有语言进行的优化。相较于方法内联,其他优化手段理解起来并不困难。一开始没打算整理这块内容,但是看了一下,觉得和之前的《如何提升代码质量》一文息息相关,除了要熟悉重构的手段,了解更深层次的原理非常有意义。即时编译器可以帮助我们优化代码,这是有代价的,虽然我们平时不怎么接触,但并不代表不存在,开发人员如果能够注意这些细节,无疑可以提高自己的代码质量,代码性能也会有所提升。


所以本文的核心主旨是:学习即时编译器的优化手段,未来自己写代码时,在保证可读性的前提下,尽量减少编译器的工作量。


接下来我们就学习一下编译器的优化手段,不会介绍全部,仅仅介绍一些日常写代码遇得见的情况,文章将伴随大量代码案例,旨在提升代码水平。


说点题外话,在学习优化手段时,突然想到这些是即时编译器的优化措施,那如果没有触发即时编译,那不就没意义了吗?而且在讲述方法内联和逃逸分析时,一直都是用大循环来触发即时编译,如果只是单次代码调用,那不就没戏了吗?查了一下资料,最终还是想通了,实际应用中,我们写的代码不像文章中的测试代码,那么简单,实际代码是属于系统中的一部分,而系统开发出来就是要运行的,只要系统运行时间够长,程序中绝大部份方法都会触发即时编译,被编译成本地代码。所以还是好好学习一下文章中介绍的优化手段吧。


字段读取优化


首先我们需要了解 JMM(Java内存模型),关于这些知识推荐阅读本文,我们这里引入以下几个概念:


  • 线程之间的共享变量存储在主内存(Main Memory)中;
  • 每个线程都有一个私有的本地内存(Local Memory),本地内存是JMM的一个抽象概念,并不真实存在,它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。本地内存中存储了该线程以读/写共享变量的拷贝副本。


如下图所示:


1.jpg


对象的实例字段和静态字段存在堆中,属于线程共享的数据。


即时编译器会优化实例字段以及静态字段访问,以减少总的内存访问数目。具体来说,它将沿着控制流,缓存各个字段存储节点将要存储的值,或者字段读取节点所得到的值。


当即时编译器遇到对同一字段的读取节点时,如果缓存值还没有失效,那么它会将读取节点替换为该缓存值。这里说明一下缓存值为何会失效,比如说多个线程操作同一个字段,进行读写操作,如果该字段被 volatile 修饰,那么线程独自拥有的工作内存中数据会被清除掉,需要从主内存重新加载。更多细节推荐阅读我之前的文章《深入了解volatile关键字》。


下面我们来看一下缓存字段读取节点的情况。


static int bar(Foo o, int x) {
  int y = o.a + x;
  return o.a + y;
}
复制代码


在上面这段代码中,实例字段 Foo.a 将被读取两次。即时编译器会将第一次读取的值缓存起来,并且替换第二次字段读取操作,以节省一次内存访问。这让我联想到日常开发中对于 getter 方法的使用,如果大于两次通过 getter 方法访问同一个字段,则最好定义一个变量。


static int bar(Foo o, int x) {
  int t = o.a;
  int y = t + x;
  return t + y;
}
复制代码


如果字段读取节点被替换成一个常量,那么它将进一步触发更多优化。


static int bar(Foo o, int x) {
  o.a = 1;
  if (o.a >= 0)
    return x;
  else
    return -x;
}
复制代码


上述代码很简单,bar 方法经过编译器优化后,可以直接替换为具体结果 true。所以开发人员完全可以避免让编译器费事,写代码的时候检查一下代码逻辑。


小结一下:


被 volatile 修饰的字段,编译器会在字段访问前后插入内存屏障节点,保证了不同线程对这个变量进行操作时的可见性。同样这也意味着该操作会组织编译器的字段读取优化。同理,加锁、解锁操作也会阻止此种优化手段。


字段存储优化


除了字段读取优化之外,即时编译器还将消除冗余的存储节点。如果一个字段先后被存储了两次,而且这两次存储之间没有对第一次存储内容的读取,那么即时编译器可以将第一个字段存储给消除掉。


class Foo {
  int a = 0;
  void bar() {
    a = 1;
    a = 2;
  }
}
复制代码


不会有人这样写代码吧,上述代码比较简单,编译器会进行冗余存储消除优化。


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 a = a;
    a = a + 2;
  }
}
// 进一步优化为
class Foo {
  int a = 0;
  void bar() {
    a = 3;
  }
}
复制代码


上述代码除了用到冗余存储消除,还有复写传播,没有必要使用一个额外的变量 t,它与变量 a是完全相等的。


因为 volatile 除了保证了变量的可见性,还禁止指令重排序,那么即时编译器也不能将冗余的存储操作消除掉。


虽然重复给同一个变量赋值多次看起来很蠢,但实际上并不少见,比如说两个存储之间隔着许多其他代码,或者因为方法内联的缘故,将两个存储操作(如构造器中字段的初始化以及随后的更新)纳入同一个编译单元里。


死代码消除


除了字段存储优化之外,局部变量的死存储(dead store)同样也涉及了冗余存储。这是死代码消除(dead code eliminiation)的一种。不过,由于 Sea-of-Nodes IR 的特性,死存储的优化无须额外代价。


int bar(int x, int y) {
  int t = x*y;
  t = x+y;
  return t;
}
复制代码


除了消除冗余存储,甚至变量 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;
}
复制代码


上述代码中,如果布尔型变量为 true,那么变量 t会被赋值两次。如果优化之后,代码可以改为:


int bar(boolean f, int x, int y) {
  if (f)
    return x+y;
  return x*y;
}
复制代码


另一种死代码消除则是不可达分支消除。不可达分支就是任何程序路径都不可到达的分支。在即时编译过程中,我们经常因为方法内联、常量传播以及基于 profile 的优化等,生成许多不可达分支。


int bar(int x) {
  if (getFlag())
    return x;
  else
    return -x;
}
复制代码


比如上述代码,如果 getFlag 方法一直返回 false,那么就会一直走 else 分支,那么就会被优化为:


int bar(int x) {
  return -x;
}
复制代码


我们来看一种特殊情况,编译器无法进行优化。


int bar(int x, int y) {
  int t = x/y;
  t = x+y;
  return t;
}
复制代码


上述代码无法优化为直接返回 x+y,因为 x/y 有除0异常,所以编译器没法优化掉这个除法。


公共子表达式消除


公共子表达式消除是一项非常经典的、普遍应用于各种编译器的优化技术,该项技术比较简单。如果一个表达式重复出现多次,且表达式中的变量值都没有发生改变,那么该表达式可称为公共子表达式。


public static void main(String[] args) {
    int a = 2, b = 3, c = 4;
    int d = (c * b) * 12 + a + (a + b * c);
  }
复制代码


javac 编译器并不会对上述代码做任何优化,我们查看字节码文件内容如下:


stack=4, locals=5, args_size=1
0: iconst_2
1: istore_1
2: iconst_3
3: istore_2
4: iconst_4
5: istore_3
6: iload_3
7: iload_2
8: imul //c*b
9: bipush        12 //将常量12推送至栈顶
11: imul  //(c * b) * 12
12: iload_1
13: iadd  //(c * b) * 12 + a
14: iload_1
15: iload_2
16: iload_3
17: imul  //b*c
18: iadd  //a+b*c
19: iadd  //(c * b) * 12 + a + (a + b * c)
20: istore        4
22: return
复制代码


这些指令都比较简单,不懂的指令可以参考本文


编译器检测到代码中 c*bb*c 是一样的表达式,而且在计算期间 b 与 c 未发生改变。因此上述代码变为:


int temp = b * c;
int d = temp * 12 + a + (a + temp);
复制代码


这时候,即时编译器还会进行另一种优化——代数化简(数学中对代数方程进行简化),继续优化为:


int temp = b * c;
int d = temp * 13 + 2 * a;
复制代码


不过话说回来,平时写代码时应该不会这样写,也就不必让编译器优化了。


在代码中,循环都扮演着非常重要的角色。为了提升循环的运行效率,即时编译器也提供了不少面向循环的编译优化方式,如循环无关代码外提,循环展开等。


循环无关代码外提


所谓的循环无关代码(Loop-invariant Code),指的是循环中值不变的表达式。如果能够在不改变程序语义的情况下,将这些循环无关代码提出循环之外,那么程序便可以避免重复执行这些表达式,从而达到性能提升的效果。


public static 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;
  }
复制代码


对应字节码为:


public static int foo(int, int, int[]);
    descriptor: (II[I)I
    flags: (0x0009) ACC_PUBLIC, ACC_STATIC
    Code:
      stack=4, locals=5, args_size=3
         0: iconst_0
         1: istore_3
         2: iconst_0
         3: istore        4
         //循环开始
         5: iload         4
         7: aload_2
         8: arraylength //获取数组长度,压入栈顶
         9: if_icmpge     29
        12: iload_3
        13: iload_0
        14: iload_1
        15: imul
        16: aload_2
        17: iload         4
        19: iaload
        20: iadd
        21: iadd
        22: istore_3
        23: iinc          4, 1
        26: goto          5
        //循环结束
        29: iload_3
        30: ireturn
复制代码


上述代码中,循环体中的表达式 x*y,以及循环判断条件中的 a.length 均属于循环不变代码。前者是一个整数乘法运算,而后者则是内存访问操作,读取数组对象a的长度。(数组的长度存放于数组对象的对象头中,可通过 arraylength 指令来访问。)


首先表达式 x*y 又属于公共子表达式,按理来说应该声明一个变量来消除它,为了减少计算量,把变量放在循环外声明。


然后来处理循环条件中的 a.length,每次循环时会去访问一下内存,按理说将其单独定义为一个变量更好,但是我们在写循环代码时早已习惯这种方式,所以我个人认为不必强制要求代码中声明变量来存储 a.length,可读性更高一些。


综上,我们得到如下代码:


public static int foo(int x, int y, int[] a) {
    int sum = 0;
    int temp = x*y;
    for (int i = 0; i < a.length; i++) {
      sum += temp + a[i];
    }
    return sum;
  }
复制代码


我们看一下下面这段代码,思考一下 Object 对象声明的位置应该在哪呢?是在循环体内,还是循环体外。


Object o;
for (int i = 0; i < 2000; i++) {
  o = new Object();
  o.hashCode();
}
复制代码


上述格式是我常写的格式,把变量放在循环外声明,自以为循环外申明变量内存占用会小一些。


但关于变量声明在循环体内还是循环体外一直存在争论,后来学习了字节码后,再结合网友们的讨论,得出如下结论:变量声明优先考虑在循环体内定义。


循环展开


编译器还有一项非常重要的循环优化是循环展开(Loop Unrolling)。它指的是在循环体中重复多次循环迭代,并减少循环次数的编译优化。


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) {
    sum += a[i];
    sum += -a[i + 1];
  }
  return sum;
}
复制代码


在 C2 中,只有计数循环(Counted Loop)才能被展开。所谓的计数循环需要满足如下四个条件:


  • 维护一个循环计数器,并且基于计数器的循环出口只有一个(但可以有基于其他判断条件的出口,比如说循环体内的if-break语句)。
  • 循环计数器的类型为 int、short 或者 char(既不能是 byte、long,更不能是 float 或者 double)。
  • 每个迭代循环计数器的增量为常数。
  • 循环计数器的上限(增量为正数)或下限(增量为负数)是循环无关的数值。


循环展开有一种特殊情况,那便是完全展开(Full Unroll)。当循环的次数是固定值而且非常小时,循环语句会被替换为普通的赋值语句。


int foo(int[] a) {
  int sum = 0;
  for (int i = 0; i < 4; i++) {
    sum += a[i];
  }
  return sum;
}
//替换为
int foo(int[] a) {
  int sum = 0;
  sum += a[0];
  sum += a[1];
  sum += a[2];
  sum += a[3];
  return sum;
}
复制代码


关于这种优化手段,个人觉得仅做了解即可,日常开发没必要在乎这些,代码可读性不怎么高。


循环判断外提


循环判断外提指的是将循环中的 if 语句外提至循环之前,并且在该 if 语句的两个分支中分别放置一份循环代码。


int foo(int[] a) {
  int sum = 0;
  for (int i = 0; i < a.length; i++) {
    if (a.length > 4) {
      sum += a[i];
    }else {
      sum += 2* a[i] - 1;
    }
  }
  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++) {
      sum += 2* a[i] -1;
    }
  }
  return sum;
}
复制代码


关于这种优化,个人感觉意义不大,日常开发还是从可读性的角度出发吧。


总结


以上介绍的七种优化手段,有时候会嵌套使用,比如说字段存储优化过程中还会使用到复写传播的手段,当然也有某些优化比较复杂,可读性不高,比如说循环展开和循环判断外提。综上,我们需要学习的优化手段最好先保证良好的可读性,在此基础上省略即时编译器的工作。


上文介绍的只是即时编译器的某些优化措施,关于提高用户代码质量,在如何提升代码质量一文提到了不少书籍和代码库,后续有时间阅读,会做整理介绍的。


参考文献


《深入理解Java虚拟机》


目录
相关文章
|
10月前
|
监控 算法 Java
Java虚拟机(JVM)垃圾回收机制深度剖析与优化策略####
本文作为一篇技术性文章,深入探讨了Java虚拟机(JVM)中垃圾回收的工作原理,详细分析了标记-清除、复制算法、标记-压缩及分代收集等主流垃圾回收算法的特点和适用场景。通过实际案例,展示了不同GC(Garbage Collector)算法在应用中的表现差异,并针对大型应用提出了一系列优化策略,包括选择合适的GC算法、调整堆内存大小、并行与并发GC调优等,旨在帮助开发者更好地理解和优化Java应用的性能。 ####
234 27
|
9月前
|
存储 设计模式 监控
快速定位并优化CPU 与 JVM 内存性能瓶颈
本文介绍了 Java 应用常见的 CPU & JVM 内存热点原因及优化思路。
909 166
|
7月前
|
监控 Java 编译器
聊聊JVM如何优化
JVM的优化是一个复杂而细致的过程,涉及内存管理、垃圾回收、即时编译、线程调度等多个方面。通过合理配置JVM参数、选择合适的垃圾回收器、优化线程调度和使用专业的监控工具,可以大幅提升Java应用的性能和稳定性。掌握这些优化技巧,能够帮助开发者在高并发、高负载的生产环境中保持系统的高效运行。
330 13
|
7月前
|
存储 设计模式 监控
如何快速定位并优化CPU 与 JVM 内存性能瓶颈?
如何快速定位并优化CPU 与 JVM 内存性能瓶颈?
163 0
如何快速定位并优化CPU 与 JVM 内存性能瓶颈?
|
10月前
|
存储 监控 算法
Java虚拟机(JVM)垃圾回收机制深度解析与优化策略####
本文旨在深入探讨Java虚拟机(JVM)的垃圾回收机制,揭示其工作原理、常见算法及参数调优方法。通过剖析垃圾回收的生命周期、内存区域划分以及GC日志分析,为开发者提供一套实用的JVM垃圾回收优化指南,助力提升Java应用的性能与稳定性。 ####
|
5月前
|
Arthas 存储 算法
深入理解JVM,包含字节码文件,内存结构,垃圾回收,类的声明周期,类加载器
JVM全称是Java Virtual Machine-Java虚拟机JVM作用:本质上是一个运行在计算机上的程序,职责是运行Java字节码文件,编译为机器码交由计算机运行类的生命周期概述:类的生命周期描述了一个类加载,使用,卸载的整个过类的生命周期阶段:类的声明周期主要分为五个阶段:加载->连接->初始化->使用->卸载,其中连接中分为三个小阶段验证->准备->解析类加载器的定义:JVM提供类加载器给Java程序去获取类和接口字节码数据类加载器的作用:类加载器接受字节码文件。
465 55
|
6月前
|
Arthas 监控 Java
Arthas memory(查看 JVM 内存信息)
Arthas memory(查看 JVM 内存信息)
441 6
|
11月前
|
缓存 Prometheus 监控
Elasticsearch集群JVM调优设置合适的堆内存大小
Elasticsearch集群JVM调优设置合适的堆内存大小
1792 1
|
7月前
|
存储 缓存 算法
JVM简介—1.Java内存区域
本文详细介绍了Java虚拟机运行时数据区的各个方面,包括其定义、类型(如程序计数器、Java虚拟机栈、本地方法栈、Java堆、方法区和直接内存)及其作用。文中还探讨了各版本内存区域的变化、直接内存的使用、从线程角度分析Java内存区域、堆与栈的区别、对象创建步骤、对象内存布局及访问定位,并通过实例说明了常见内存溢出问题的原因和表现形式。这些内容帮助开发者深入理解Java内存管理机制,优化应用程序性能并解决潜在的内存问题。
317 29
JVM简介—1.Java内存区域
|
7月前
|
缓存 监控 算法
JVM简介—2.垃圾回收器和内存分配策略
本文介绍了Java垃圾回收机制的多个方面,包括垃圾回收概述、对象存活判断、引用类型介绍、垃圾收集算法、垃圾收集器设计、具体垃圾回收器详情、Stop The World现象、内存分配与回收策略、新生代配置演示、内存泄漏和溢出问题以及JDK提供的相关工具。
JVM简介—2.垃圾回收器和内存分配策略