한국어 | English | 日本語
Webアプリケーションエンジニア (経験8.8年)
技術・開発
engineering
ウェブフロントエンドと バックエンド開発を扱います

Wrapper Class Caching: Integer(ラッパークラス)における `==` 使用時の課題

JavaでIntegerオブジェクトを`==`演算子で比較する際、なぜある値では'true'となり、別の値では'false'となるのでしょうか?この単純な誤りのように見える現象の裏には、メモリ効率を最大化するためのJVMの'Wrapper Class Caching'メカニズムが隠されています。実務で遭遇した断続的なバグの事例を通して、Javaのメモリ管理戦略を掘り下げていきます。
Integerオブジェクトの比較において、127までは正常に動作するのに128から結果が変わる原因を分析します。プリミティブ型と参照型の違い、オートボクシングの原理を探り、Java 5から導入されたキャッシング仕様がメモリアドレス比較にどのような影響を与えるかを詳細に学習します。これにより、値の比較には必ず`equals()`を使用すべき理由と、境界値テストの重要性を再確認します。

最近、サーバーでIntegerオブジェクトを==(等値演算子)で比較するコードが原因で、時折エラーログが出力されているのを確認しました。不思議なことに、このAPIは非常に頻繁に使用されているにもかかわらず、エラーは断続的に発生していました。簡単に説明すると、更新しようとしているリストの数と、更新前のリストの数が一致するかを検証するバリデーションロジックだったのですが、エラーログを確認すると「更新前のリスト数と更新後のリスト数が異なります: 324 != 324」と記録されていました。 単純にチームメンバーと「オブジェクトの比較に==を使用すると参照メモリアドレス値を比較するため、当然equals()を使用すべきです」と共有しましたが、実際にそのロジックがおかしいと感じ、値を一つずつ1ずつ増やしながら代入してみた粘り強い開発者によって、以下の事実が明らかになりました。

Integerオブジェクトの比較に==を使用した場合、127までは’true’(等しい)を返しますが、 それ以上の128からは’false’(異なる)を返します。

本稿は、なぜそのような現象が起こるのかについての短い説明です。


JavaだけでなくJavaScriptを初めて学ぶ際にも、クラス、プリミティブ型、参照型に触れるでしょう。最近では計算機科学科でPythonを学ぶことが多いかもしれませんが、Cを学ぶと変数に値を保存する際にメモリにどのように格納されるかを学びます。これらは以下のように大別されます。

プリミティブ型 (Primitive Type)

参照型 (Reference Type)

本稿では、プリミティブ型その値を包むラッパークラスの二つにのみ焦点を当てます。


ボクシングとアンボクシング (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 aInteger 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 == b10 == 10という値が同じであるという理由ではなく、9ab2e1 == 9ab2e1というアドレスが同じであるという理由で’true’(等しい)を返していたのです。

エラーの発生頻度が少なかったのも、当該ロジックの特性上、127以上の値が出ることが滅多になかったからでしょう。テスト時に発見できなかったのは、テスト値を常識的な値の範囲に留めただけで、Integerの最大値、最小値といった境界値に対するテストケースを見落としていたためだと考えられます。改めて、値の比較にはequals()を使用すべきであること、そして常に境界値に対するテストケースは必須であるという当然の事実を再認識しました。


Javaは昔も今も、本当に難しい言語だと感じます。このようなことに遭遇すると、以前1年間触れたKotlinに戻りたくなる気持ちになります(…)。それでも、このような細かな部分までメモして覚えておくことは、いつか将来の知識に大いに役立つでしょう。JVMやJavaコンパイラには、開発者の利便性のためにいくつかの機能がサポートされていますが、今回のキャッシングの問題だけでなく、Javaのジェネリクスの概念においても、メモリ効率のためにコンパイル時に開発者が実装したインターフェースの実装がすべてインターフェースに自動変換され、コンパイル時に捕捉されなかったエラーがランタイム時に発生するという問題もあります。これについては、今後の投稿で説明する予定です。



Wrapper Class Caching: Integer(ラッパークラス)における `==` 使用時の課題
Author
Aaron
Posted on
Licensed Under
CC BY-NC-SA 4.0
CC BY-NC-SA 4.0
同じカテゴリーの関連記事
最新記事
LLMフィルターが奪う会話の筋肉とコミュニケーション様式
会話における無礼さを濾過し、洗練された回答を生成するLLMツールが日常化した現代において、私たちは本当に思慮深い会話をしているのだろうか?リアルタイムのコミュニケーションにおける数多くの失敗を通じて磨かれるべき会話能力が、外部ツールに依存することで退化している現象と、それがもたらす社会的な不安や世代間の行動様式の変化について考察する。
シニア採用における年俸交渉の最適なタイミングと戦略
年俸交渉は単なる数字の交換ではなく、心理的な駆け引きとタイミングが重要です。本稿では、企業側にとって、候補者が計算的な態度を取りがちな最終合格後よりも、採用プロセスの初期段階から段階的に交渉を進めることが、なぜより効率的であり、率直な情報の共有に繋がるのかを考察します。
法治主義の限界と人間の多様性
全ての人間の行為を単一の法体系で規制できるという信念は、傲慢であるかもしれない。この記事は、中世の階層的な統制から脱却し、現代の無限の自由を手に入れた人類が直面する法治主義の逆説と、多様性という名のもとに深化する社会的強制力と他者への悪魔化現象を鋭く分析する。
토스트 예시 메세지