はじめに

「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)。

  1. ローカル変数テーブル(Local Variable Array)
  2. オペランドスタック(Operand Stack)
  3. フレームデータ(定数プールポインタ・メソッド戻りアドレス等)

オブジェクト型変数は「参照の値」が4/8バイトで格納され、実体はJavaヒープ(Old/Eden/Survivor) にあります。

バイトコードで見る「参照の値渡し」

次のシンプルなコードを考えます。

Java
class 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の配列を作り「箱」を共有することで疑似参照渡し風の実装をすることがあります。

Java
static void increment(int[] box) { box[0]++; }

これもやはり「配列オブジェクトの参照の値のコピー」であり、box = new int[5]として再代入すれば呼び出し元には影響しません。

解決策:値を返すか、ラッパーを使う

swapを実現したい場合は値を返すか、ラッパークラスを用意するのが定石です。

Java
static 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/ スタックフレーム実装