はじめに
「Javaはすべて値渡し(pass-by-value)」という言葉を聞いたことはありませんか?
しかし、メソッドにListや独自クラスのインスタンスを渡すと、呼び出し元のオブジェクトが書き換わる「参照渡し(pass-by-reference)」のように見える現象に戸惑う方も多いはずです。
本記事は、そんな「参照渡し」に見える挙動の背後で、JVMがどのようなメモリ操作を行っているのかを、実際のバイトコードとスタックフレームを追いながら解説します。
読み終えると「参照の値渡し」とは何なのか、なぜswap()が効かないのか、そしてエンジニアとして「値か参照か」を正確に言語化できる力が身につきます。
前提知識
- Javaの基本文法(クラス・メソッド・変数のスープール)
- オブジェクト指向の基礎(インスタンスと参照を区別できる)
- 簡単なアセンブリ・バイトコードの読み書き(
javap -cの結果を恐れない)
Javaに「参照渡し」は存在しない?~値渡しと参照渡しの定義
プログラミング言語における「値渡し」と「参照渡し」の定義を整理しておきましょう。
- 値渡し:変数の中身(プリミティブならビット列、オブジェクト型なら「参照の値」)をコピーして渡す
- 参照渡し:変数が指すメモリ上のオブジェクトそのものを共有し、呼び出された側で再代入しても呼び出し元に影響する
C++ならint&、C#ならrefキーワードで明示的に参照渡しが可能ですが、Javaにはこれが存在しません。
つまり、Javaメソッドに渡されるのは「参照の値のコピー」であり、再代入は呼び出し元に波及しません。
しかし、参照先のオブジェクトのフィールドを変更すれば、当然呼び出し元でも変更が見えるため「参照渡しに見える」のです。
内部で何が起きているのか~JVMスタックとバイトコードで追う
実際に.classファイルをjavap -c -vで分解し、JVMがどのようにスタックフレームを作って値をコピーするのかを追いかけます。
スタックフレームの構造
HotSpot JVMはメソッド呼び出しごとに以下のフレームをスタック上に確保します(JVM仕様§2.5.5)。
- ローカル変数テーブル(Local Variable Array)
- オペランドスタック(Operand Stack)
- フレームデータ(定数プールポインタ・メソッド戻りアドレス等)
オブジェクト型変数は「参照の値」が4/8バイトで格納され、実体はJavaヒープ(Old/Eden/Survivor) にあります。
バイトコードで見る「参照の値渡し」
次のシンプルなコードを考えます。
Javaclass Person { int age; } class Main { static void updateAge(Person p, int a) { p.age = a; } static void swap(Person p1, Person p2) { Person tmp = p1; p1 = p2; p2 = tmp; // ここで交換 } public static void main(String[] args) { Person alice = new Person(); alice.age = 20; Person bob = new Person(); bob.age = 30; updateAge(alice, 99); // alice.age == 99 swap(alice, bob); // aliceとbobは交換したように見える? System.out.println(alice.age); // 99(交換してない!) } }
javap -cの主要部分を抜粋(aload_0はローカル変数0番目をスタックに積む命令)。
// updateAge
0: aload_0 // 引数p(参照のコピー)をスタックへ
1: iload_1 // 引数a
2: putfield #2 // Person.ageフィールドへ書き込み
5: return
// swap
0: aload_0
1: astore_2 // tmp = p1
2: aload_1
3: astore_0 // p1 = p2(ローカル変数0番を書き換え)
4: aload_2
5: astore_1 // p2 = tmp(ローカル変数1番を書き換え)
6: return
ポイントはputfieldはヒープ上のオブジェクトを更新する命令なので、これが呼び出し元に見えるのに対し、aload/astoreはローカル変数テーブルの値を書き換えるだけで、呼び出し元の変数テーブルには影響しません。
つまり「参照先のフィールド更新は可、参照の書き換え(再代入)は不可」というルールが、バイトコードレベルで明快に示されています。
ハマりどころ:配列やコレクションを渡したときの挙動
配列はnew int[1]のように長さ1の配列を作り「箱」を共有することで疑似参照渡し風の実装をすることがあります。
Javastatic void increment(int[] box) { box[0]++; }
これもやはり「配列オブジェクトの参照の値のコピー」であり、box = new int[5]として再代入すれば呼び出し元には影響しません。
解決策:値を返すか、ラッパーを使う
swapを実現したい場合は値を返すか、ラッパークラスを用意するのが定石です。
Javastatic class Ref<T> { T value; Ref(T v) { value = v; } } static <T> void swap(Ref<T> a, Ref<T> b) { T tmp = a.value; a.value = b.value; b.value = tmp; }
まとめ
- Javaには「参照渡し」は存在せず、すべて「値渡し(参照の値のコピー)」
- バイトコードで
putfield/getfieldがヒープオブジェクトを操作するためフィールド更新は呼び出し元に見える - メソッド内での再代入(
p = another)はローカル変数テーブルのみの変更で、呼び出し元に影響しない - 交換処理が必要なら戻り値で返すか、ラッパークラスを利用する
この知識があれば「なんでswapできないんだ!」と時間を無駄にすることはありません。
次回は「finalが参照の不変性に与える影響」と「メモリバリアによる可視性保証」について掘り下げていきます。
参考資料
- The Java® Virtual Machine Specification(Java SE 21 Edition): https://docs.oracle.com/javase/specs/jvms/se21/html/
- 『Java言語で学ぶデザインパターン入門』 結城 浩(著)
- JetBrains IntelliJ IDEA付属の
javapプラグイン使用 - OpenJDK 21ソースコード:
hotspot/share/runtime/スタックフレーム実装
