Wrapper Class Caching: Integer(ラッパークラス)における `==` 使用時の課題
最近、サーバーでIntegerオブジェクトを==(等値演算子)で比較するコードが原因で、時折エラーログが出力されているのを確認しました。不思議なことに、このAPIは非常に頻繁に使用されているにもかかわらず、エラーは断続的に発生していました。簡単に説明すると、更新しようとしているリストの数と、更新前のリストの数が一致するかを検証するバリデーションロジックだったのですが、エラーログを確認すると「更新前のリスト数と更新後のリスト数が異なります: 324 != 324」と記録されていました。 単純にチームメンバーと「オブジェクトの比較に==を使用すると参照メモリアドレス値を比較するため、当然equals()を使用すべきです」と共有しましたが、実際にそのロジックがおかしいと感じ、値を一つずつ1ずつ増やしながら代入してみた粘り強い開発者によって、以下の事実が明らかになりました。
Integerオブジェクトの比較に==を使用した場合、127までは’true’(等しい)を返しますが、 それ以上の128からは’false’(異なる)を返します。
本稿は、なぜそのような現象が起こるのかについての短い説明です。
JavaだけでなくJavaScriptを初めて学ぶ際にも、クラス、プリミティブ型、参照型に触れるでしょう。最近では計算機科学科でPythonを学ぶことが多いかもしれませんが、Cを学ぶと変数に値を保存する際にメモリにどのように格納されるかを学びます。これらは以下のように大別されます。
プリミティブ型 (Primitive Type)
- 変数に値が割り当てられると、その値がそのままメモリに保存されます。
- 値がそれ自体として使用可能な型。
- 整数型: byte, short, int, long
- 浮動小数点型: float, double
- 文字型: char
- 論理型: boolean
参照型 (Reference Type)
- 変数には値を持つオブジェクトのアドレスが保存され、その値はアドレスが指すオブジェクト空間に保存されています。
- 値(フィールド)と便利な関数(メソッド)を一つのオブジェクトにまとめた型。
- ラッパークラス (Wrapper Class): その中でも、プリミティブ型の値と便利な関数を一つのオブジェクトにまとめた型。
- 整数型: Byte, Short, Integer, Long
- 浮動小数点型: Float, Double
- 文字型: Character
- 論理型: Boolean
- その他: 配列 (Array)、クラス (Class) など
- ラッパークラス (Wrapper Class): その中でも、プリミティブ型の値と便利な関数を一つのオブジェクトにまとめた型。
本稿では、プリミティブ型とその値を包むラッパークラスの二つにのみ焦点を当てます。
ボクシングとアンボクシング (Boxing & Unboxing)
これら2つの型はJavaで混用できるため、プリミティブ型とラッパークラスに格納された値を使用するために、毎回演算子や関数が要求する型に合わせて変換を行うのは現実的ではありません。不必要なコード量が増加するため、Javaコンパイラがバイトコード生成時に自動変換を行います。どの型からどの型へ変換するかによって、ボクシング(boxing)とアンボクシング(unboxing)に分けられます。直感的には、クラスから値を取り出すのがアンボクシング、クラスに値を格納するのがボクシングと理解できます。
ボクシング (Boxing)
プリミティブ型の値をラッパークラスオブジェクトの内部に包んで(boxして)保存し、ラッパークラスのアドレスを返します。例えば、Integer a = 10; のように宣言すると、左側はInteger(ラッパークラス)、右側は**10(プリミティブ型)であるため、右側の値10は自動的にnew Integer(10)の形式でオブジェクトとして包まれ、返されます。これをオートボクシング(Auto-boxing)**と呼びます。この機能のおかげで、関数パラメータが private void pleaseGiveMeReference(Integer a) のように定義されていても、pleaseGiveMeReference(10) と呼び出すことができるのです。
アンボクシング (Unboxing)
プリミティブ型の値を持つラッパークラスオブジェクトから値を取り出す(unboxする)ことです。int a のようなプリミティブ型変数や、Integer b = new Integer(10) のような初期化で使用する場合、int a = b の結果は int a = 10 となります。これをオートアンボクシング(Auto-unboxing)と呼びます。これも上記のボクシングで見たように、private void pleaseGiveMePrimitive(int a) のような関数パラメータが定義されていても、Integer wrapped = 10 オブジェクトを pleaseGiveMePrimitive(wrapped) のように呼び出すことができるのです。
記事の冒頭で問題になった==は、実際の値の比較であるため、プリミティブ型の比較においてのみ私たちの直感通りに動作します。ラッパークラスを比較する場合、Integer a変数に保存されたオブジェクトのメモリアドレスのみを比較するため、たとえ同じ値を持つ2つのオブジェクトを比較しても、結果は’false’(不一致)となるでしょう。心に留めておくべきは、==演算子は「決して」オートボクシング、オートアンボクシングをサポートしないということです。たとえIntegerのようにオートボクシング、オートアンボクシングをサポートしている型であってもです。
では、なぜサーバーでInteger == Integerが127までは正しく動作し、128からは私たちの期待通りに動作しないのでしょうか?==演算子はオートアンボクシングに対応していないと言いましたが、まさか条件によって動作が変わるのでしょうか?
いいえ、違います。
ラッパークラスのキャッシング (Wrapper Class Caching, Java 5+)
Java 5では、メモリ効率のためにラッパークラスのキャッシングが導入されました。「一部の」ラッパークラス(Byte, Short, Integer, Long, Character)について、小さい値のオブジェクトをメモリにキャッシュし、そのような小さい値のオブジェクトが作成される際に、キャッシュ済みのラッパークラスオブジェクトを返すようにしています。Integerの例では、1, 2, 10のような値は使用頻度が非常に高いため、これらを使用するたびにラッパークラスオブジェクトをいちいち生成すると、メモリの観点から問題が発生します。例えば、Integer a = 10;、Integer b = 10;のように100個定義した場合、100個分のメモリを全て割り当てる必要があります。そこで、頻度の高いオブジェクトはあらかじめ作成しておき、値10に対応するラッパークラスオブジェクトは、事前に作成されたただ一つのオブジェクトのみを使用するようにしています。Integer a = 10;、Integer b = 10;…のすべてがキャッシュされたnew Integer(10)オブジェクトを使用するため、Integer aもInteger bも同じオブジェクトアドレスを持ち、メモリはたった1つ分だけ割り当てればよいのです。
一つのオブジェクトを複数の変数で利用できるようにしたため、これを**不変ラッパーオブジェクト(Immutable Wrapper Object)**とも呼ぶようです。ラッパークラスのキャッシングが「一部の」ラッパークラスにのみ適用されると強調した理由は、Floatはキャッシングされず、Characterは負の値を除く0〜127のみをキャッシングするなど、型ごとにサポートされるキャッシング仕様が異なるためです。詳細な仕様については、Java公式仕様ドキュメントを参照してください。おそらく、少数の値に限定してキャッシングしているのは、使用頻度の高い少数の値に集中するためでしょう。2^8(256)を超えるとビット数に応じてキャッシュメモリも増加するため、ある程度の合意点を見出したように感じられます。
Integerにおけるラッパークラスのキャッシングは、-128〜127の値に対するオブジェクトをキャッシュしています。
結論 (Conclusion)
ラッパークラスの同一性(等価性)は、
equals()を使用して確認しましょう。
これで、Integer == Integerがどのような場合に動作し、どのような場合に動作しなかったのか、その理由が明確になりました。Integerは-128〜127までの値に対するオブジェクトを、Javaのラッパークラスのキャッシングによって、毎回定義するたびにメモリに生成するのではなく、事前にキャッシュされているオブジェクトを使用します。 そのため、Integer a = 10;、Integer b = 10; の両方が同じオブジェクトアドレスを持つため、a == bは10 == 10という値が同じであるという理由ではなく、9ab2e1 == 9ab2e1というアドレスが同じであるという理由で’true’(等しい)を返していたのです。
エラーの発生頻度が少なかったのも、当該ロジックの特性上、127以上の値が出ることが滅多になかったからでしょう。テスト時に発見できなかったのは、テスト値を常識的な値の範囲に留めただけで、Integerの最大値、最小値といった境界値に対するテストケースを見落としていたためだと考えられます。改めて、値の比較にはequals()を使用すべきであること、そして常に境界値に対するテストケースは必須であるという当然の事実を再認識しました。
Javaは昔も今も、本当に難しい言語だと感じます。このようなことに遭遇すると、以前1年間触れたKotlinに戻りたくなる気持ちになります(…)。それでも、このような細かな部分までメモして覚えておくことは、いつか将来の知識に大いに役立つでしょう。JVMやJavaコンパイラには、開発者の利便性のためにいくつかの機能がサポートされていますが、今回のキャッシングの問題だけでなく、Javaのジェネリクスの概念においても、メモリ効率のためにコンパイル時に開発者が実装したインターフェースの実装がすべてインターフェースに自動変換され、コンパイル時に捕捉されなかったエラーがランタイム時に発生するという問題もあります。これについては、今後の投稿で説明する予定です。