JVMシリーズ:リアルタイムコンパイラに関するその他の最適化方法

この記事は、「ディープラーニングJVMシリーズ」の20番目の記事です。

前の2つの記事では、リアルタイムコンパイラの2つの特別な最適化手法について説明しました。メソッドのインライン化とエスケープ分析です。エスケープ分析の結果に基づく最適化方法には、同期の除去、スタックの割り当て、スカラーの置換の3つがあります。

さらに、クラシックコンパイラの多くの最適化方法や、Java言語、またはJava仮想マシンで実行されるすべての言語の多くの最適化など、ジャストインタイムコンパイラの多くの最適化方法があります。メソッドのインライン化と比較して、他の最適化を理解するのは難しくありません。当初は整理するつもりはなかったのですが、読んでみると前回の記事「コード品質の向上方法」と密接に関連していると感じました。リファクタリングの方法に精通しているだけでなく、とてもより深い原則を理解することは意味があります。ジャストインタイムコンパイラは、コストがかかるコードの最適化に役立ちます。通常は触れませんが、存在しないという意味ではありません。開発者がこれらの詳細に注意を払うことができれば、間違いなく可能です。コードの品質とパフォーマンスを向上させます。

したがって、この記事の主な目的は、ジャストインタイムコンパイラの最適化方法を学ぶことです。将来、読みやすさを確保することを前提として、自分でコードを作成する場合は、コンパイラの作業負荷を最小限に抑えます。

次に、コンパイラの最適化手法について学びます。すべてを紹介するのではなく、日常のコード作成で遭遇するいくつかの状況を紹介します。この記事には、コードの改善を目的とした多数のコードケースが付属しています。レベル。

余談として、最適化手法を学んでいた時、いきなりこれがリアルタイムコンパイラの最適化対策だと思いましたが、リアルタイムコンパイルがトリガーされなければ意味がありませんか?また、メソッドのインライン化とエスケープ分析について話すときは、常に大きなループを使用してリアルタイムのコンパイルをトリガーします。それが1つのコード呼び出しだけの場合は、簡単ではないでしょうか。情報を確認した結果、ようやくわかりました。実際のアプリケーションでは、私たちが書いたコードは、記事のテストコードほど単純ではありません。実際のコードはシステムの一部であり、システムは、実行できるように開発されています。実行時間が十分に長い場合、プログラム内のほとんどのメソッドはジャストインタイムコンパイルをトリガーし、ネイティブコードにコンパイルされます。それでは、この記事で紹介した最適化手法を調べてみましょう。

フィールド読み取りの最適化

まず、JMM(Javaメモリモデル)を理解する必要があります。この知識については、この記事を読むことをお勧めします。ここでは、次の概念を紹介します。

  • スレッド間の共有変数はメインメモリに保存されます。

  • 各スレッドにはプライベートローカルメモリ(ローカルメモリ)があります。ローカルメモリはJMMの抽象的な概念であり、実際には存在しません。キャッシュ、書き込みバッファ、レジスタ、およびその他のハードウェアとコンパイラの最適化をカバーします。スレッドの読み取り/書き込み共有変数のコピーがローカルメモリに保存されます。

以下に示すように:

オブジェクトのインスタンスフィールドと静的フィールドはヒープに格納され、スレッドによって共有されるデータに属します。

ジャストインタイムコンパイラは、インスタンスフィールドと静的フィールドアクセスを最適化して、メモリアクセスの総数を減らします。具体的には、制御フローに従い、フィールドストアノードが格納する値、またはフィールド読み取りノードが取得する値をキャッシュします。

ジャストインタイムコンパイラが同じフィールドの読み取りノードを検出すると、キャッシュされた値がまだ無効化されていない場合は、読み取りノードがキャッシュされた値に置き換えられます。キャッシュされた値が無効である理由の説明を次に示します。たとえば、複数のスレッドが同じフィールドで動作し、読み取りおよび書き込み操作を実行します。フィールドがvolatileによって変更された場合、スレッドのみが所有する作業メモリー内のデータは次のようになります。クリアされ、メインメモリから書き換える必要があります。詳細については、以前の記事「volatileキーワードの詳細な理解」を読むことをお勧めします。

キャッシュフィールドがノードを読み取る状況を見てみましょう。

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

上記のコードでは、インスタンスフィールドFoo.aが2回読み取られます。ジャストインタイムコンパイラは、最初の読み取り値をキャッシュし、2番目のフィールド読み取り値を置き換えてメモリアクセスを節約します。これは、日常の開発でのgetterメソッドの使用を思い出させます。同じフィールドにgetterメソッドを介して2回以上アクセスする場合は、変数を定義することをお勧めします。

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)。
  • 每个迭代循环计数器的增量为常数。
  • 循环计数器的上限(增量为正数)或下限(增量为负数)是循环无关的数值。

ループ展開の特殊なケースである完全展開があります。ループの数が固定されていて非常に少ない場合、ループステートメントは通常の代入ステートメントに置き換えられます。

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ステートメントの2つのブランチに配置することを指します。

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

この最適化については、個人的にはあまり意味がないと感じており、読みやすさの観点から日々の開発を始めるべきです。

要約する

上記の7つの最適化方法は、ネストで使用されることがあります。たとえば、フィールドストレージの最適化プロセスでは、複製と伝播の方法も使用されます。もちろん、ループ展開やループ展開など、一部の最適化はより複雑で読みにくくなります。循環判断の外挿。要約すると、私たちが学ぶ必要のある最適化方法は、最初に読みやすさを確保するために最善であり、次にジャストインタイムコンパイラの作業を省略します。

上記はリアルタイムコンパイラの最適化策のほんの一部です。ユーザーコードの品質向上については、コード品質向上の方法についての記事で多くの本やコードベースが紹介されています。後で読む時間があれば、それらを整理して紹介します。

参照

「Java仮想マシンの深い理解」

おすすめ

転載: juejin.im/post/7080131449973637127
おすすめ