エムスリーテックブログ

エムスリー(m3)のエンジニア・開発メンバーによる技術ブログです

Java Quiz 解答・解説

こんにちは、CTO の矢崎 (@saiya_moebius) です。

JJUG というイベントにて Java Quiz の配布をいたしました。設問はシンプルですが、特に 2 問目からは悩ましい問題だったのではないでしょうか。意識せずに使っている JVM の挙動を知るよいきっかけをご提供できて幸いです。問題文は本 blog に投稿済み ですので、Quiz をご覧になっていなかった方もご覧いただけます。

f:id:Saiya:20190520124330j:plain
@kis (id:nowokay)さんがその場で quiz の検証コードを書いていただいたり、来日された Java Champion の方が話しかけてくださり、楽しく議論したりもしていました

以下、問題文 と同じく、64bit 環境の OpenJDK 11 前提で解説いたします。

Q1: String のインスタンス

public static final String A = "hoge"; 
public static final String B = "hoge";

のときに、以下それぞれで A == Btrue / false どちらでしょうか:

  • A と B が同じ .class ファイルにある
  • A と B が異なる class, 同じ jar にある
  • A と B が異なる jar, 同じ ClassLoader にある
  • A と B が異なる ClassLoader にある

→ 答: 全て true

Java 言語におけるインスタンスの同一性

Java 言語の == は、同じインスタンスへの参照であるかどうかの比較になります *1。それゆえに「中身が同じ文字列でもインスタンスとしては別かもしれないので == で比較してはいけない」というベストプラクティスがあります *2

余談ですが、Kotlin, Scala, Groovy は上記のような分かりにくさを防げるように == 演算子も内部的には equals を使って比較する*3ようになっているので、Java 言語のような注意は不要になっています。

String のインスタンスと String intern pool

しかし、中身が同じ文字列でもインスタンスとしては別かもしれないとはいうものの、この quiz の例のような状況ではどうなるのでしょうか? 文字列の定数を複数宣言するだけでもインスタンスは複数作られるのでしょうか?

実は JVM の文字列定数は intern pool というプール上の String インスタンスへの参照になるため、同じ文字列定数を複数宣言したとしても 1 つの String インスタンスが使い回されます *4。また、String intern pool は JVM 本体が持っているプールであり、コンパイル単位(class 単位)や ClassLoader には依存しません。よってこの quiz の答えは全て同じ参照(A == Btrue)です。

性能を気にしてコード上の文字列("...")を必ず static 変数から参照している事例も時々ありますが、static な定数のみならずコード中の文字列リテラルは全て同様に intern pool に格納されます。コードのメンテナンス性や可読性の目的であれば文字列を定数にするのは良いことですが、性能のためだけに無理に文字列を定数にする意味はありません。

JLS 3.10.5 の規定

さらに、この挙動は実装依存ではなく、 JLS (Java Language Specification) の 3.10.5 節で保証されています: Moreover, a string literal always refers to the same instance of class String. (略) "interned" so as to share unique instances, using the method String.intern *5

String の最適化もろもろ

なお、JVM では文字列に関する最適化は他にもあります。例えば...

  • (かなり昔からある) コンパイラによる文字列連結の最適化
    • "hoge" + "huga""hogehuga" になる
    • a + b といった式は StringBuilder による文字列結合になる *6
  • JEP 280: Indify String Concatenation : StringBuilder の代わりに invokedynamics を使うことで実行時最適化しやすくする改善
  • JEP 348: Compiler Intrinsics for Java SE APIs : (提案段階) String::format などに対する同様の改善

検証コード

検証用コード: Q1.java

検証コードでは以下の工夫によって問題の状況を作り出しています:

  • 普通に A == B などと書くとコンパイル時最適化で true に決め打ちされてしまうので、reflection を使って最適化を防いでいます
  • 異なる ClassLoader でロードするケースのテストを簡潔に書くために、 ClassLoader 派生クラスを実装する手法を用いています

Q2: Integer のインスタンス

return (Integer)(new Random().nextInt(100));   // Integer 型を返すメソッド

100,000 回実行すると、上記コードが Integer 型のインスタンスをいくつ新規作成するでしょうか:

  1. 0 個
  2. 1〜100 個
  3. 256 個 (-128 から +127 の整数)
  4. 100,000 個

※インライン化などの最適化で int → Integer の型変換が消えることはない前提とします

→ 答: 0 個

実は乱数の結果に関係なく答えが決まる

たとえ何万回の乱数生成をしたとしても 100 種類の乱数すべてが出るわけではない、なので 1〜100 個 が正解だ、と考えた方もおられることでしょう。

しかし後述する通り、実は Random#nextInt の出力がどうなるかには関係なく quiz の答えが定まります。

int -> Integer へのキャスト と valueOf(int) と IntegerCache

Quiz のコードのコンパイル結果は以下のようになります:

    NEW java/util/Random
    DUP
    INVOKESPECIAL java/util/Random.<init> ()V         // Random() のコンストラクタ呼び出し
    ILOAD 0
    INVOKEVIRTUAL java/util/Random.nextInt (I)I       // nextInt(100) の呼び出し
    INVOKESTATIC java/lang/Integer.valueOf (I)Ljava/lang/Integer;  // Integer.valueOf(int) の呼び出し
    ARETURN

int → Integer のキャストはコンパイル時に Integer.valueOf(int) メソッド呼び出しになります。そして その実装IntegerCache のキャッシュ範囲内であればキャッシュから Integer インスタンスを取得する実装になっています。

IntegerCache の実装 はシンプルで、IntegerCache の static initializer が +127 〜 -128 の範囲*7Integer インスタンスを事前生成して配列に保持する実装です。

JLS 5.1.7 の規定

この +127 〜 -128 の範囲の Integer インスタンスがキャッシュされ使い回される挙動も実装依存のように感じられるかもしれませんが、少なくとも boxing *8時に +127 〜 -128 の Integer がキャッシュされるのは JLS で定められた挙動です。詳しくは JLS の 5.1.7 節 に背景や考慮事項も含めて詳細に書かれています。

IntegerCache と static initializer

ここまで読んだ方の多くは、quiz のコードによって新規に作成される Integer インスタンスが 0 個であることに納得されているのではないでしょうか。

しかし、static initializer の仕様に詳しい方は「当該 quiz のコードの処理の過程で IntegerCache の static initializer が実行されうるため、quiz のコードが 256 個(+127 〜 -128)の Integer 生成を伴いうるのではないか」と思い至られているかもしれません *9。実際、static initializer が走るタイミングは JLS で明確に規定 されており、 IntegerCache クラスにアクセスするような処理をしない限り IntegerCache は初期化されないはずです。そう考えると、quiz のコードだけを実行するプログラムを作れば、quiz のコードがはじめて Integer.valueOf を実行し IntegerCache の static initializer を呼び出すため、結果として quiz のコードの処理過程で 256 個の Integer が生成されるというシナリオも想像されます。

さらに上記の考えを裏付ける要素として、Integer のソースコードには IntegerCache の初期化前に parseInt が呼ばれるケースが有るという旨の注意コメント があったり、 JLS の 5.1.7 節 に処理系は Integer を遅延初期化してもよい旨が明記されていたりもします。

この論点については Integer::valueOf(int) にブレークポイントを当てた状態で起動してみると良いです。あらゆるユースケースを網羅はしていませんが、見る限り、アプリケーションの起動処理の過程で URLClassLoader などの JVM 内部のクラスが Integer::valueOf(int) を呼び出しているため IntegerCache は main の実行前に初期化されているはずです *10。よって、筆者の知りうる限り quiz のコードは IntegerCache が初期化された後にしか実行され得ないという見解です。

検証コード

検証用コード: Q2.java

検証コードでは、残念ながらも IntegerCache の static initializer の実行タイミングをうまく機械的に検知する方法を思いつかなかったため、単に Integer インスタンスがキャッシュされていること (JLS 5.1.7 で規定されている内容) を再確認するだけになっています。

ただしコード上の工夫として、 インスタンスの個数を調べるコード では IdentityHashMap という標準クラスを使っています。通常の Map と異なり、キーに指定したオブジェクトの identity (インスタンスが同じかどうか)を区別する Map です。

IdentityHashMap を使うと、(Integer)42new Integer(42) *11が別のキーとして扱われる不思議な Map を作れるので面白いです。

Q3: Exception のインスタンス - その 1

String str = null;
str.length();   // NullPointerException (NPE) が発生する

100,000 回実行すると、上記コードが NPE のインスタンスをいくつ新規作成するでしょうか:

  1. 0 個
  2. 1 個
  3. 2〜99,999 個
  4. 100,000 個

→ 答: 2〜99,999 個 (例えば 10,754 個)

C1 JIT, C2 JIT

10,754 個といった半端な個数は、JIT の最適化の都合に起因しています。

JIT の様子を見るための手順は少し面倒なのですが、hsdis ライブラリをインストールした JVM で -XX:+UnlockDiagnosticVMOptions -XX:+LogCompilation '-XX:CompileCommand=print,*対象クラス::メソッド' オプション付きで実行することで観察することができます。

実際に quiz の例のように null の参照に対してメソッドを呼び出すことで NullPointerException が頻繁に発生するコードは、概ね以下のような動きになっていました:

  1. 最初のうちはインタプリターで普通に実行される
  2. C1 JIT (1 段階目の JIT) が適用されるが、ここでも素直な動きをする
  3. C2 JIT (2 段階目の JIT) が適用される
    • この時点では null 参照はめったに起きない想定で最適化される
      • null チェックせずに callq 命令で関数呼び出し(quiz における String#length メソッドの呼び出し)する
    • null 参照が起きてしまったときには trap し builtin_throw に制御を移す *12
      • 上記の trap コードは呼び出された回数(too_many_trap *13 メソッドで判定)が一定回数を超えると deoptimization (再・最適化)を起こす
  4. Deoptimization 後に、再度 C2 JIT 最適化
    • メソッド呼び出しの前に null チェックを行うコードが JIT 生成される
      • test + jne *14 で null チェック
    • null であれば Universe::_null_ptr_exception_instance が保持している単一の NullPointerException のインスタンスを throw する

最終的に、計 3 回の JIT を経て決め打ちの NullPointerException インスタンスを throw するようになります。しかし、それまでは素直に NullPointerException のインスタンスを都度 new して throw するために 10,754 個といった半端な数の NullPointerException インスタンスが作られる、という原理でした。

なお builtin_throw のソースにある通り、NullPointerException だけでなく、ゼロ除算の ArithmeticException, ArrayIndexOutOfBoundsException, ArrayStoreException, ClassCastException でも同様です。実用的なプログラムではこれらの例外の方がありがちかもしれません。

スタックトレースと -XX:-OmitStackTraceInFastThrow

ところで、例外のインスタンスを使い回す場合、スタックトレースはどうなるのでしょうか? 呼び出し元のメソッドが異なるときに例外のインスタンスを使い回すとスタックトレース情報が正しくない情報になってしまい、デバッグ時に混乱してしまいそうです。

実際は、スタックトレース情報を持たない例外インスタンスが使い回される挙動になります。

例外のスタックトレースがなくなるとデバッグに不便ではないかとも思えますが、実際は 1 つのメソッド上で 10,754 回といった大量の回数の例外が発生するまでは当該挙動にならないので、問題は少ないと言えるでしょう。とはいえ、大量の例外の中に稀に呼び出し元が異なる例外が混ざっている場合などにはデバッグに困るかもしれません。そのような場合は -XX:-OmitStackTraceInFastThrow JVM オプションを付けることでこの挙動を抑制できます。

実は JIT が間に合わないことがあるかも...

ここまでに述べたのが問題作成者の意図だったのですが、実行環境やコードの書き方次第では 10 万回のループでは足りておらず、2 回目の C2 JIT にまで至らないことで 100,000 個 のインスタンスが作られてしまうことがありえるかもしれません。

特に、作者が実験用に書いたコード のように例外を投げるメソッドをループで呼び出す形でなく、ループの中に直接 try - catch を書いて実験するとそのような結果になることがありえるようです。検証しきれていないので推測ですが、おそらくループ内で try - catch する書き方の場合には On-stack Replacement *15 が必要になってしまうために JIT が間に合わなくなりやすいのではないでしょうか。

Q4: Exception のインスタンス - その 2

throw new UnauthorizedException("ログインが必要です");

100,000 回実行すると、上記コードが例外のインスタンスをいくつ新規作成するでしょうか:

  1. 0 個
  2. 1 個
  3. 2〜99,999 個
  4. 100,000 個

→ 答: 100,000 個

こちらは -XX:-OmitStackTraceInFastThrow オプションの存在を知っている方への引っ掛け問題でした。-XX:-OmitStackTraceInFastThrow の最適化は NullPointerException といった JVM 本体が投げる例外だけでなく、このように Java のコード上で throw する例外でも適用されます。

ただし、このように Java のコードとして throw new ... している場合は、頻繁に実行される throw 文でスタックトレースが採取されなくなるだけであり、例外のインスタンス自体は常に new されます。もしこのコードで例外のインスタンスの使い回しを行ってしまうと、コード上明示的に new と書かれているにもかかわらずインスタンスの生成やコンストラクタの呼び出しが発生しないことになってしまい、コードの意味が壊れてしまうためです *16

JVM 組み込みの例外型(IOExceptionなど)にしたほうが分かりやすい quiz だったかも...

Quiz をお配りした際のフィードバックとして、 UnauthorizedException のようなユーザー定義の例外型よりも JVM 組み込みの例外型の方がわかりやすい、というフィードバックをいただきました。Web アプリケーションでは認証状態のエラーと言った、バグではないが処理を中断したいケースでこのように例外を使うことがよくあるため quiz の例に使ったのですが、説明なくユーザー定義の例外型を例に出すよりも JVM 組み込みの例外型を例に使ったほうが分かりやすかったかもしれません。なお、先述の通り、明示的に throw new しているため 100,000 個のインスタンスが作られるという原理のため、例外の型は問題の答え自体を左右しません。

例外インスタンスの使い回しは必要か?

なお、マイクロベンチマークなどを行うと例外オブジェクトの生成はかなり重い処理に見えることがありますが、実際はスタックトレースの採取が重い処理です。上述の通り実際は -XX:-OmitStackTraceInFastThrow しない限り、頻繁に throw する例外ではスタックトレースの採取は行われません。

なので、 static final UnauthorizedException UNAUTHORIZED_EXCEPTION = throw new UnauthorizedException("ログインが必要です"); のように例外インスタンスをあえて使い回すコードを書いてもメリットはほぼありません。しかも、このような書き方をするとスタックトレース情報が一切取れなくなってしまいます*17。一方で先述の JVM 挙動であれば、最初の数千回 〜 1 万回程度はスタックトレース付きの例外が throw されます。その最初のうちの例外処理の性能すら問題になる局面ではそもそも例外処理を使うべきではないですし、そうでないならば JVM の JIT に任せたほうが良いと言えるでしょう。

最後に

著名な Computer Scientist の Donald Knuth 先生の言葉として「早すぎる最適化は諸悪の根源」という言葉があります。また、文字列や例外などのインスタンスを性能のために定数化するのは効果がなかったり逆効果である*18ことは、ここまで読んでいただけた方はご承知かと思います。

本稿では様々な JVM, コンパイラ の最適化を紹介しましたが、見ての通りいずれも言語仕様として可能な範囲で高速化するための工夫がなされています。他にも JVM では非常に様々な最適化が行われており、JVM を利用することで無意識にこれらのメリットを得ることができます。Rust といった非 VM 型のプログラム言語や GraalVM, Kotlin native といったネイティブ環境の文脈で JVM がないことによる軽量さのメリット*19も取りざたされていますが、しかしながらも JVM (特に JIT)の最適化の作り込みはまだまだ侮れないところがあるのではないでしょうか *20

We are hiring!!!

弊社エムスリーはインターネット技術を用いて医療にまつわるムダを改善したり、新たな可能性を実現することを目指しています。そのために、こういった技術的なバックグラウンドを知ることで無駄なく価値あるコードを書くことに興味のあるエンジニアの方を絶賛募集中です。例えば Java 関連では これこれ のような募集をしておりますし、その募集内容にとらわれずエンジニアの仲間を常に募集しておりますので、ぜひ下記リンクよりカジュアルな面談などでもお気軽にご応募ください!

m3.recruitment.jp

*1:ただし、よく初心者を混乱させる点として、int, long, boolean といったプリミティブ型に限っては値としての同値性での比較です

*2:代わりに equals メソッドを使います

*3:正確には Groovy は compareTo を優先的に使う

*4:Intern pool は JVM の実行時のものです。一方でコンパイル時の class ファイルのサイズという意味では、class ファイル内に現れる同一の文字列は constant_pool にまとめられる一方で、別の class にある文字列はそれぞれの class ファイルに保持されます

*5:ただし 3.10.5 節のサンプルコードは鵜呑みにできません, 3.10.5 節の内容に加えてコンパイル時最適化の作用もあるためです: https://stackoverflow.com/q/33398800

*6:ただしループで += するコードなどまで最適化してくれるわけではないです

*7:ソースを見ての通り、JVM パラメタを指定すれば上限を +127 より大きくすることは可能

*8:int といったプリミティブ型を Integer などにキャストする行為

*9:なので 256 個という選択肢をあえて用意しました

*10:JVM には java agent という JVM 自体に介入するコードを Java で書く機能もありますが、この機能で読み込むコード自体も URLClassLoader で読み込まれるためやはり回避は難しいのではないでしょうか、未検証ですが...

*11:この場合明示的に new しているので IntegerCache によるキャッシュは効きません

*12:事前に null チェックする代わりに例外が起きたときに trap することで、例外が起きていない限りはゼロ・オーバーヘッドでプログラムを実行するための工夫

*13:パット見 JVM 全体で 1 個のカウンタでカウントしているかのように見えてしまいますが、実際は http://hg.openjdk.java.net/jdk/jdk11/file/1ddf9a99e4ad/src/hotspot/share/opto/compile.hpp#l1009 にある通り "current method" での trap 発生回数で見ています

*14:jne = jump if not equal 命令, 直前の test 命令の結果が not equal ならば jump する、という条件分岐命令

*15:実行中のメソッドを JIT コンパイルされたコードに差し替える最適化

*16:理想論としては毎回 new しないように最適化できるかもしれませんが、そのためには throw した例外を catch する側の処理が例外インスタンスの同一性に依存していないことを証明することが必要であり実現困難です。throw した例外がどこで catch されるのかは静的には決定できないため。

*17:実際にその例外を投げるメソッドのスタックトレース情報ではなく、例外を生成した時点のスタックトレースになってしまうので static initializer の名前しか出てきません

*18:可読性や保守性のため、であれば意味があります

*19:処理時間の最悪値を抑えなければならない要件下では JIT, GC に依存しない処理系の方が実際良いと思います。

*20:実はここの quiz で挙げたような例はあまり JIT ならではの強みではない気はしますが...。インライン化と分岐予測の合わせ技や deoptimization といった事柄のほうが JIT ならではの長所に思います。