原文はこちら。
The original article was written by Claes Redestad (Principal Member of Technical Staff, Oracle).
https://cl4es.github.io/2019/11/20/OpenJDK-Startup-Update.html
OpenJDK 14のramp downフェーズ開始まであと数週間になったので、Java とその仲間たちの起動を高速化し、より少ないメモリ使用量などを実現するために、OpenJDK で何が行われてきたのかをチェックする時期だと考えました。
Hello World(s)
以前、Java 8 から 11、Java 8 から 12 までの Hello World の改善点についてブログを書いたことがありますので、まずは現在の状況を再確認することから始めるのが妥当だと思います。
OpenJDK Startup From 8 Through 11
https://cl4es.github.io/2018/11/29/OpenJDK-Startup-From-8-Through-11.html
Preview: OpenJDK 12 startup
https://cl4es.github.io/2018/12/28/Preview-OpenJDK-12-Startup.html
JDK 10 と 12 は、それぞれ 11 (LTS) と 13 に取って代わられているので、ここでは省きます。JDK 9 は、旧リリースモデルの下での最後のメジャーリリースなので、JDK 9 は残しておきます。
JDK のチューニングや準備をせずに、私のマシンで Hello World、Hello Lambda、Hello Concat のサンプルで今日取得した起動時の数値です。

全体的に、各「アプリ」とも以前のものから劇的に低下しています。Hello World では2倍以上高速、Hello Lambdaではほぼ4倍高速です。
JDK 14では、これらの最小限のスタートアップテストでJDK 14ではJDK 13 よりもわずかな改善を目標にしています。相対的な改善はごくわずかですが。数多くのクリーンアップと改善を行ってきましたが、相対的にも絶対的にも小さな改善であることは否定できません。しかし、JDK 13 は本当に優れているので、それでいいのです。
以前の私の投稿から数字が少しずれるのは、この実験でjava -Xshare:dump
を実行してCDSアーカイブでJDKを準備していないからです。これは、ほとんどの人が知らないか気にしていなかったトリックで、JDK 12 以降は、デフォルトの CDS アーカイブが存在するおかげで、気にする必要がなくなりました。
JEP 341: Default CDS Archives
https://openjdk.java.net/jeps/341
もし本当にミリ秒を追い求めているのであれば、チューニングして改善する方法がありますが、私たちの焦点はデフォルトの標準的な体験を改善することなので、今回はこのようにしています。
Scaling up
さて、Hello Worldが37.9msecなどで実行されているからといって、javaが起動してどんなアプリケーションでも同じように速く実行されるわけではありません。初期のブートストラップのオーバーヘッドを単純化することは素晴らしいことですが、大きなアプリケーションサーバや新しくてピカピカのマイクロサービスフレームワークをロードするときに、バックグラウンドのノイズの中に消えてしまうリスクがあります。
実験対象として、MicronautのHello Worldサンプルをダウンロードし、少々手を入れて検証を止めてほとんどのJITコンパイルを無効化し(ずるいことはしません!)、シャットダウンフックを追加しました。
micronaut-examples/hello-world-java/
https://github.com/micronaut-projects/micronaut-examples/tree/master/hello-world-java
Hello Worldに手をいれるためのPatch
https://cl4es.github.io/snippets/micronaut.patch
数回ループで実行し、平均を取りました。

JDK 8u231の2.72秒台からJDK 9.0.4の2.96秒台までに達しましたが、最近のfeature releaseでは低下していき、現在はJDK 14の最新ビルドで2.35秒を記録しています。劇的なものではありませんが、JDK 8から見ると14〜15%の減少であり、JDK 13との比較でも改善されています。さらに驚くべきは、JVM で使用されるメモリの総量が減少していることです。

JDK 8 以降、メモリ使用量が 40% 減少しました! 様々なハードウェアの数値を見ると、JDK 9 などと比較して総CPU使用量が40%減少しているなど、同様の改善が見られます。
もちろん、これをさらにチューニングすることもできます。AppCDSを適用しただけで、120MBの利用と約1.65秒まで低下しました。OpenJDKの範囲内でも範囲外でも、これをさらにチューニングする方法は他にもあります。
しかし、ここで強調しておきたいのは、チューニングを一切せずに13にアップグレードするだけで、起動時間とメモリフットプリントが大幅に改善される可能性が高いということです(いずれ14にもアップグレードします)。
ここでは、以前にブログで紹介したブートストラップの改善よりも多くのことが起きています。それを分解してみましょう…
Class loading improvements
大規模なアプリケーションの起動に数秒かかる原因の一つは、クラスのロード、リンク、バイトコードの検証に関連したオーバーヘッドです。クラスをロードしてリンクする際、ランタイムはまた、舞台裏でバイトコードを生成するために時間を費やすことがあります。
結果として得られたデータを JVM が容易に処理できる方法で保存することで、AppCDS または Dynamic CDS は、起動時間を 20~50% 短縮することができます。誰もがAppCDSやDynamic CDSをデプロイするとは限りませんし、デプロイしたとしても、アプリが使用する可能性のあるすべてのデータを含めることができるスイートスポットではないかもしれません。
Improve Launch Times On Java 13 With Application Class-Data Sharing
https://blog.codefx.org/java/application-class-data-sharing/
JEP 350: Dynamic CDS Archives
https://openjdk.java.net/jeps/350
そのため、CDS をより良いものにするための良い取り組みが行われていますが、CDS がない場合でも、クラスの読み込みなどを最適化することも可能です。そして、これまで多くの改善が行われてきました。
- micronautでは、リンク時のデフォルトメソッドの生成には、JDK 8と9で約8億から8.5億件の命令が必要で、私のマシンでは0.3秒以上かかりました。JDK 13 (JDK-8219713) とJDK 14 (JDK-8233497) での2つの異なる改善により、これを2億5千万件の命令、つまり約 0.1 秒まで下げることができました。
- [JDK-8219713] Reduce work in DefaultMethods::generate_default_methods
https://bugs.openjdk.java.net/browse/JDK-8219713 - [JDK-8233497] Optimize default method generation by data structure reuse
https://bugs.openjdk.java.net/browse/JDK-8233497
- [JDK-8219713] Reduce work in DefaultMethods::generate_default_methods
- JDK-8219579 のような改良によりバイトコード検証が高速化されました。
- [JDK-8219579] Remove redundant signature parsing from the verifier
https://bugs.openjdk.java.net/browse/JDK-8219579
- [JDK-8219579] Remove redundant signature parsing from the verifier
- Method*sとMethodHandles間の暗黙の変換を削除するなど、作業を減らすための多くの小さな改善が行われました(JDK-8233913)。
- [JDK-8233913] Remove implicit conversion from Method* to methodHandle
https://bugs.openjdk.java.net/browse/JDK-8233913
- [JDK-8233913] Remove implicit conversion from Method* to methodHandle
Compiler improvements
JVMベースのアプリケーションの起動時間は、JITコンパイルが直接的および間接的に影響を及ぼしています。起動時に、JVMはバイトコードを解釈し、アプリケーションが最も時間を費やしているように見えるバイトコードを最適化することを唯一の目的として、バックグラウンドでJITコンパイラのスレッドをぶん回します。
デフォルトでは、OpenJDKのJVMであるHotSpotは、階層化構成になっています。まず最初に高速なコンパイラC1がバイトコードをコンパイルし、インタプリタモードよりも高速な形式にします。それだけでなく、C1でコンパイルされたコードは、次の階層のJITコンパイラが可能な限り積極的に最適化するのに役立つ多くのプロファイリングカウンタを有します。デフォルトでは、次のJITコンパイラはC2で、C1よりもコンパイルに時間がかかりますが、多くの場合、何倍も速いコードを生成します。
一般には、これらすべてのコンパイルが高速になればなるほど、インタープリタやプロファイリング付きのC1コンパイルされたコードのような最適化されていないフェーズでコードを実行する時間が減ります。しかし、コードをプロファイリングモードにとどめておく時間が多ければ多いほど、最終的な結果は良くなります。そのため、いくつかのトレードオフがあります。
さて、早期にリソースをあまり使わない理由の一つは、以前のように積極的に JIT スレッドをスピンアップしないことです。これは、JDK 11 で導入された -XX:+UseDynamicNumberOfCompilerThreads
(JDK-8198756) によるものです。
[JDK-8198756] Lazy allocation of compiler threads
https://bugs.openjdk.java.net/browse/JDK-8198756
これは、コンパイルリクエストが現在の構成で処理可能な速度よりも速く積み上げられ始めた場合にのみ、より多くの C1 および C2 スレッドを起動する、ということです。つまり、起動時に実行するスレッド数が少なくなって、JVMがメモリとCPUを消費しにくくなることを意味します。
これは、各コンパイルに必要な作業を削減するC1およびC2コンパイラ自体の多くの最適化によって増幅されます。
labels in (startup) and subcomponent = compiler and status in (Resolved) and fixVersion in (9, 10, 11, 12, 13, 14)
https://bugs.openjdk.java.net/browse/JDK-8234003?jql=labels%20in%20(startup)%20and%20subcomponent%20%3D%20compiler%20and%20status%20in%20(Resolved)%20and%20fixVersion%20in%20(9%2C%2010%2C%2011%2C%2012%2C%2013%2C%2014)
これは、最適化されたハードウェア命令を使用するための改善(可能な場合)から、割り当て量の削減まで多岐にわたります。これの本当の効果はプラットフォームによって異なりますが、テストしたすべてのシステムで大幅なスピードアップが見られました。これは、使用するコンパイルスレッド数の動的な性質と相まって、全体的なリソース消費量の減少につながることは間違いありません。
What’s next?
知る人ぞ知る… 😀
高度にチューニングされた特殊なユースケースのためか、あるいは単にビルドやテストを少し速く実行するためか、は関係なく、OpenJDKが起動/ウォームアップ時にどれだけ優れたパフォーマンスを発揮するかについて、今後も注目していきたいと思います。
Moar!
先頃、この件と関連する話をしました。スライドは以下からどうぞ。
The Lean, Mean… OpenJDK?
https://cr.openjdk.java.net/~redestad/slides/lean_mean_openjdk.pdf