Java

キャプチャリングとは?Javaのラムダ式・匿名クラスと実質finalの制約

キャプチャリングとは?Javaのラムダ式・匿名クラスと実質finalの制約

キャプチャリング(capturing)は分野によって指すものが変わる言葉で、Web開発ではDOMイベントの伝播フェーズ、ネットワークではパケットの記録、CGでは身体の動きの取り込みを指します。この記事で扱うのはプログラミング言語としてのJavaにおけるキャプチャ、つまりラムダ式や匿名クラスの中から、外側で宣言されたローカル変数を参照する仕組みです。Javaはこの参照に「final または実質final であること」という条件を課しており、これがコンパイルエラーの原因としてよく登場します。以下では Java言語仕様(JLS)の規定を根拠に、条件の正確な中身と、ラムダ式と匿名クラスで挙動がどう違うのかを、JDK 25(Temurin 25.0.4.1+1 LTS)での実測とあわせて整理します。

まとめ:Javaのキャプチャリングで押さえる4点

Javaの変数キャプチャでつまずきやすいのは、次の4点です。

  • 条件は2つあり、片方だけでは足りません。ラムダ式や内部クラスから参照するローカル変数は final または実質final であることに加えて、その本体より前に definitely assigned(確実に代入済み)である必要があります。後者を外すと javac は別のエラーメッセージを出すため、キャプチャの問題だと気づきにくくなります。
  • 制限の理由は性能ではなく安全性です。JLS は、変化し続けるローカル変数のキャプチャが並行性の問題を招きやすいことを根拠に挙げています。
  • ローカル変数は値がコピーされ、フィールドはコピーされません。インスタンスフィールドに制限がないのは、値がキャプチャされるのではなく、外側のインスタンス(this)がキャプチャされて実行時にフィールドを読むためです。static フィールドはキャプチャを経由せず、クラスを介して直接読まれます。
  • ラムダ式と匿名クラスはキャプチャの条件が同じでも、生成物と this が違います。匿名クラスは合成フィールドを持つクラスファイルを生成し this が自分自身を指しますが、ラムダ式は今回の javac では独立したクラスファイルを出力せず、this は書かれた場所の文脈をそのまま引き継ぎます。

キャプチャリングとは|分野ごとの用法とJavaでの定義

検索で「キャプチャリングとは」と調べると、まったく別の技術の解説が並びます。用語が「取り込む」という英語の意味から派生しているためで、どの分野の話かを先に確定させないと、読んでいる資料が目的に合いません。

分野ごとに指す対象の違い

IT分野で使われる主な用法は次の4つです。

分野 指している対象 よく併記される語
プログラミング言語(Java・JavaScript など) 関数やクラスの内部から、外側のスコープにある変数を参照する仕組み クロージャ、実質final、変数キャプチャ
Webフロントエンド(DOM) イベントが伝播経路の上位からターゲットに到達する直前まで下りていくフェーズ バブリング、キャプチャフェーズ、addEventListener
ネットワーク 通信を流れるパケットを記録・保存する行為 パケットキャプチャ、Wireshark
CG・映像 身体の動きや映像信号をデータとして取り込む行為 モーションキャプチャ、キャプチャボード

このうちDOMのキャプチャフェーズは「イベントがどの順で伝わるか」の話で、変数とは関係がありません。伝播経路には Window や Document のように要素ではないオブジェクトも含まれ、ターゲットに到達した時点のフェーズはキャプチャフェーズとは別に扱われます。名前が同じだけで内容が重ならないため、JavaScriptのイベント伝播を調べている場合は、この記事の内容は目的に合いません。ここから先は、プログラミング言語としてのJavaにおける変数キャプチャだけを扱います。

Java言語仕様でのキャプチャの位置づけ

Javaでキャプチャが問題になるのは、ラムダ式・匿名クラス・ローカルクラスのように、宣言された場所とは別のタイミングで実行されうるコードを書いたときです。Java言語仕様 SE 25 の §15.27.2 は、ラムダ式についてこう定めています。

Any local variable, formal parameter, or exception parameter used but not declared in a lambda expression must either be final or effectively final (§4.12.4), as specified in §6.5.6.1.(ラムダ式の中で使われているが宣言されていないローカル変数・仮引数・例外パラメータは、final または実質final でなければならない)

同じ規定が内部クラスにもあり、§8.1.3 が「Any local variable, formal parameter, or exception parameter used but not declared in an inner class must either be final or effectively final」と定めています。つまり条件そのものはラムダ式と匿名クラスで共通です。

「実質final(effectively final)」という用語そのものは Java 7 の時点で存在していました。Java 7 の言語仕様 §4.12.4 は、単一catch節の例外パラメータについて「may be effectively final instead of being explicitly declared final」と述べています。ただし当時この概念はマルチcatchと再スロー解析のためのもので、キャプチャの条件には使われていませんでした。同じ Java 7 の §8.1.3 は内部クラスについて「must be declared final」と書いており、匿名クラスからローカル変数を参照するには final の明示が必要でした。Java 8 でラムダ式が導入された際に、実質finalがキャプチャの条件としてラムダ式と内部クラスの両方へ適用範囲を広げています。

キャプチャできる変数とできない変数の一覧

「finalを付ければよい」で済ませると、拡張forが通って基本forが通らない理由や、初期化していない変数で別のエラーが出る理由を説明できません。条件を仕様の記述に沿って分解します。

実質final(effectively final)の3条件

JLS SE 25 §4.12.4 は、初期化子を持つローカル変数について、次の3つをすべて満たすものを実質finalと定義しています。

  • final が宣言に付いていないこと(付いていればそもそも final 変数です)
  • 代入式の左辺に一度も現れないこと
  • 前置・後置のインクリメント演算子・デクリメント演算子のオペランドにならないこと

初期化子を持たない変数の場合は、代入が現れる時点でその変数が definitely unassigned であること、つまり「まだ代入されていないと確定している箇所での1回きりの代入」であることが条件になります。int y; if (cond) y = 1; else y = 2; のように分岐ごとに1回だけ代入する書き方は、この条件を満たします。

加えて仕様は、メソッド・コンストラクタ・ラムダ式の仮引数と例外パラメータについても、初期化子を持つローカル変数と同じ基準で実質finalかどうかを判定すると定めています。メソッドの引数をメソッド内で再代入していると、その引数はキャプチャできなくなります。

変数の種類別のキャプチャ可否

JDK 25 の javac で実際にコンパイルして確認した結果をまとめます。

変数の種類 キャプチャ 補足
再代入していないローカル変数 可 実質final。final の明示は不要です
再代入しているローカル変数 不可 ラムダ式より後ろで代入していてもエラーになります
基本for文の増減させるループ変数 不可 インクリメントされるため実質finalになりません
拡張for文のループ変数 反復内で再代入しなければ可 反復ごとに別の変数として扱われます
メソッドの仮引数(再代入なし) 可 再代入すると不可になります
マルチcatchの例外パラメータ 可 暗黙にfinalで、再代入自体がコンパイルエラーです
単一catchの例外パラメータ 条件付きで可 暗黙finalではないため、再代入していなければ可です
try-with-resourcesのリソース変数 可 暗黙にfinalで、再代入自体がコンパイルエラーです
インスタンスフィールド・staticフィールド 可(制限なし) 値のコピーではなく実行時に読むため、変更も反映されます

暗黙にfinalとなるのは、JLS SE 25 §4.12.4 が挙げるインタフェースのフィールド、try-with-resources のリソース変数、マルチcatch節の例外パラメータの3種類です。単一catch節の例外パラメータは「never implicitly declared final, but may be effectively final」とされ、再代入しなければキャプチャできます。再代入してからラムダ式で参照すると、javac は次のエラーを出します。

B_UniCatch.java:3: error: local variables referenced from a lambda expression must be final or effectively final
  void m(){ try { throw new IOException("x"); } catch (IOException e) { e = new IOException("y"); foo(() -> System.out.println(e.getMessage())); } } }

拡張forと基本forで結果が分かれる理由

同じループでも、拡張for文のループ変数は反復内で再代入しなければキャプチャできる一方、基本for文の増減させるループ変数はキャプチャできません。判定しているのは構文の種類ではなく、あくまでその変数が実質finalかどうかですが、両者では結論が分かれます。JLS SE 25 §15.27.2 はこの差を明示しています。

The restriction to effectively final variables includes standard loop variables, but not enhanced-for loop variables, which are treated as distinct for each iteration of the loop (§14.14.2).(実質finalの制限は通常のループ変数には及ぶが、拡張forのループ変数には及ばない。後者は反復ごとに別個の変数として扱われるため)

基本for文の i はループ全体で1つの変数を使い回してインクリメントするため、実質finalの条件を満たしません。一方、拡張for文の変数は反復ごとに新しく作られるので、ループ本体で再代入しない限り実質finalです。逆に言えば、拡張forの変数でもループ本体で代入し直せばキャプチャできなくなります。実際に拡張forの変数をキャプチャしたラムダ式を3つ作って順に実行すると、それぞれが自分の反復時の値を保持していることを確認できます。

List<Runnable> rs = new ArrayList<>();
for (String s : List.of("a", "b", "c")) {
    rs.add(() -> System.out.print(s + " "));
}
rs.forEach(Runnable::run);
// 実行結果: a b c

これを基本for文で書き換えると、javac は該当行を指してエラーを出します。

Bad.java:17: error: local variables referenced from a lambda expression must be final or effectively final
            rs.add(() -> System.out.println(i));
                                            ^

実質finalでも通らない「初期化されていない変数」

見落とされやすいのが、条件がもう1つあることです。JLS SE 25 §15.27.2 は実質finalの規定に続けて「Any local variable used but not declared in a lambda body must be definitely assigned (§16 (Definite Assignment)) before the lambda body, or a compile-time error occurs.」と定めています。実質finalであっても、ラムダ式の本体より前に確実に代入されていなければコンパイルエラーになります。

次のコードは y に代入が1回しか現れないため実質finalの条件を満たしますが、cond が偽のときに未代入のままラムダ式へ到達しうるため通りません。

void m(boolean cond) {
    int y;
    if (cond) y = 1;          // else 節がない
    foo(() -> System.out.println(y));
}

このときの javac の出力は、実質finalのエラーとは別物です。

A_DefAssign.java:3: error: variable y might not have been initialized

メッセージに final も lambda も出てこないため、キャプチャの条件の話だと結びつきにくくなります。直し方は、全経路でそれぞれ1回だけ代入する形にすることです。else 節を足すか、int y = cond ? 1 : 0; のように条件演算子で初期化すれば通ります。

注意したいのは、int y = 0; と初期値を与えるだけでは解決しない点です。初期化した上で if (cond) y = 1; を残すと、今度は代入式の左辺に現れて実質finalでなくなり、最初のエラーに変わります。確実な代入と実質finalの両方を同時に満たす必要があります。

void initThenReassign(boolean cond) {
    int y = 0;
    if (cond) y = 1;          // 初期値を足しただけでは再代入が残る
    foo(() -> System.out.println(y));
}
// error: local variables referenced from a lambda expression must be final or effectively final

キャプチャの制限が設けられている理由

「なぜ再代入する変数を使えないのか」は、パフォーマンス上の都合として説明されることがありますが、言語仕様が挙げている根拠は別のところにあります。

制約を設ける理由と、finalから実質finalへ緩めた理由

JLS SE 25 §15.27.2 は、実質finalへの制限の意図をこう説明しています。挙げられている2点は役割が異なり、前半が制約そのものを設ける理由、後半が final の明示という当初の条件を緩めた理由です。

The restriction to effectively final variables prohibits access to dynamically-changing local variables, whose capture would likely introduce concurrency problems. Compared to the final restriction, it reduces the clerical burden on programmers.(実質finalへの制限は、動的に変化するローカル変数へのアクセスを禁じる。そうした変数のキャプチャは並行性の問題を招く可能性が高いためである。また final 限定と比べて、プログラマの事務的な負担を減らす)

挙げられているのは並行性の問題と記述負担の軽減であり、メモリ配置の最適化ではありません。ラムダ式や匿名クラスは別スレッドや後のタイミングで実行されうるため、呼び出し元のローカル変数が書き換わり続ける前提を許すと、実行順序やスレッド間の同期を書き手が管理しなければならなくなります。

匿名クラスは合成フィールドへの値コピー

コンパイラが実際に何をしているかは、バイトコードで確認できます。引数 n をキャプチャする匿名クラスをコンパイルし、生成されたクラスを javap で見ると、キャプチャした値を保持する合成フィールドが作られています。

class Cap$1 implements java.lang.Runnable {
  final int val$n;
  public void run();
}

呼び出し側のバイトコードでは、外側のインスタンスと n の値をコンストラクタへ渡しています。

java.lang.Runnable anon(int);
     0: new           #11        // class Cap$1
     4: aload_0                  // 外側のインスタンス
     5: iload_1                  // n の値
     6: invokespecial #13        // Method Cap$1."<init>":(LCap;I)V

ラムダ式の場合は、今回の javac では独立したクラスファイルを出力せず、invokedynamic の呼び出し記述子にキャプチャした値の型が現れます。

java.lang.Runnable lambda(int);
     0: iload_1
     1: invokedynamic #7,  0     // InvokeDynamic #0:run:(I)Ljava/lang/Runnable;

どちらも「その時点の値を渡している」点は同じです。コピーされるのは変数が保持している値で、参照型であれば参照そのものがコピーされます。したがって、参照先のオブジェクトは呼び出し元と共有され、そのオブジェクトの中身を書き換えれば双方から見えます。コピーされないのは「どのオブジェクトを指しているか」を後から差し替える権利のほうです。

フィールド参照はthisのキャプチャ

インスタンスフィールドに制限がない理由も、バイトコードを見ると分かります。フィールドを参照するラムダ式と、何も参照しないラムダ式を比べると、呼び出し記述子が変わります。

java.lang.Runnable usesField();
     1: invokedynamic #15,  0    // InvokeDynamic #0:run:(LCap2;)Ljava/lang/Runnable;
java.lang.Runnable usesNothing();
     0: invokedynamic #19,  0    // InvokeDynamic #1:run:()Ljava/lang/Runnable;

private void lambda$usesField$0();          // インスタンスメソッド
private static void lambda$usesNothing$0(); // staticメソッド

フィールドを参照する側は Cap2 のインスタンス、つまり this をキャプチャしており、生成されたメソッドもインスタンスメソッドになっています。フィールドそのものがコピーされているわけではないので、ラムダ式を作った後でフィールドを書き換えると、実行時にはその新しい値が読まれます。ローカル変数との違いは実行して確かめられます。

int local = 10;
field = 10;
Runnable showLocal = () -> System.out.println("local captured = " + local);
Runnable showField = () -> System.out.println("field read      = " + field);
field = 99;
showLocal.run();   // local captured = 10
showField.run();   // field read      = 99

static フィールドはこれとも仕組みが違い、そもそもキャプチャを経由しません。クラスを介して直接読み書きされるため、ラムダ式の中からインクリメントできます。「キャプチャできないから値を変えられない」のではなく、ローカル変数だけが値コピーの対象になっている、と理解すると挙動が一貫します。

ラムダ式と匿名クラスのキャプチャの違い

キャプチャの条件は共通でも、書き換えたときに動作が変わる箇所が3つあります。匿名クラスからラムダ式への置き換えを検討するときは、ここを確認してから進めます。

thisが指すオブジェクトの違い

最も実害が出やすいのが this です。匿名クラスの中の this は匿名クラス自身のインスタンスを指しますが、ラムダ式の中の this は外側のインスタンスを指します。外側のクラスを Good として実測すると、次のようになります。

lambda this        = Good
anonymous this     = Good$1
anonymous Good.this= Good

匿名クラスで外側のインスタンスを参照するには Good.this と書く必要がありましたが、ラムダ式では this がそのまま外側を指します。this を使っている匿名クラスをそのままラムダ式へ書き換えると、参照先が変わって挙動が壊れます。JavaScriptのアロー関数と通常の関数でも this の扱いが分かれますが、Javaでは「ラムダ式が外側を引き継ぐ」側である点は共通しています。

匿名クラスとラムダ式で分かれるクラスファイルと実装クラス

匿名クラスはコンパイル時に独立したクラスファイルを生成しますが、ラムダ式は生成しません。ラムダ式1つと匿名クラス1つを含むクラスをコンパイルすると、出力されるクラスファイルは2つだけです。

$ javac Name.java
$ ls Name*.class
Name$1.class    // 匿名クラスの分
Name.class      // ラムダ式の分は生成されない

ラムダ式の実装クラスがどう用意されるかは言語仕様ではなく実行環境の実装に委ねられています。今回検証した Temurin 25 では実行時に生成され、隠しクラス(hidden class)になりました。実行時にクラス名を取ると違いが分かります。

lambda class    = Name$$Lambda/0x0000000150080210
lambda isHidden = true
anon   class    = Name$1
anon   isHidden = false

隠しクラスは通常のクラス検索の対象になりません。上の名前をそのまま Class.forName へ渡すと ClassNotFoundException になります。名前を前提にしたリフレクションや、クラスファイルの数を数えるビルド検証は、匿名クラスからラムダ式への置き換えで結果が変わります。

非キャプチャラムダのインスタンス再利用

何もキャプチャしないラムダ式は、評価のたびに新しいオブジェクトを作るとは限りません。ループ内で同じラムダ式を3回評価して System.identityHashCode を取ると、JDK 25 では非キャプチャのラムダ式だけ同じ値が返りました。

non-capturing #0 identity=1654589030
non-capturing #1 identity=1654589030
non-capturing #2 identity=1654589030
capturing     #0 identity=257895351
capturing     #1 identity=1929600551
capturing     #2 identity=1690716179

ただしこれは仕様上の保証ではありません。LambdaMetafactory の javadoc は「Capture may involve allocation of a new function object, or may return a suitable existing function object. The identity of a function object produced by capture is unpredictable」と述べ、参照の同一性比較・オブジェクトロック・System.identityHashCode() の結果は実装や呼び出しごとに変わりうると明記しています。ラムダ式のインスタンスを == で比較したり、ロックの対象にしたりする設計は避けてください。逆に言えば、ホットパスで毎回オブジェクトが割り当てられることを心配して匿名クラスへ戻す必要もありません。

よくあるコンパイルエラーと回避策

キャプチャ関連のエラーはメッセージが数種類に分かれており、どれが出たかで原因を切り分けられます。

エラーメッセージ別の原因と対処

JDK 25 の javac が実際に出力したメッセージと、その対処をまとめます。以下は英語ロケールでの表示で、日本語環境では「ラムダ式から参照されるローカル変数は、finalまたは事実上のfinalである必要があります」のように訳されます。英語の文字列で検索しても見つからないときは、javac -J-Duser.language=en で表示を揃えると原文と突き合わせられます。

エラーメッセージ(冒頭) 原因 対処
local variables referenced from a lambda expression… ラムダ式が参照するローカル変数を再代入している 再代入をやめる、または別の変数へ写してから参照します
local variables referenced from an inner class… 匿名クラスやローカルクラスが参照する変数を再代入している 同上。メッセージだけがラムダ式版と異なります
variable y might not have been initialized 実質finalだが definitely assigned ではない 全経路で1回だけ代入します。初期値を足すだけでは再代入が残ります
multi-catch parameter e may not be assigned マルチcatchの例外パラメータへ再代入した 暗黙finalなので再代入しません。別の変数を用意します
auto-closeable resource ac may not be assigned try-with-resourcesのリソース変数へ再代入した 同上。リソース変数は差し替えられません

最初の2つはメッセージの差が「lambda expression」と「inner class」だけなので、どちらの構文で書いているかを確認する目印として使えます。

ミュータブルなホルダを使うときの注意点

ラムダ式の中から外側へ値を持ち出したい場合、要素数1の配列や AtomicInteger をホルダにする書き方が知られています。変数そのものは再代入されないため実質finalのままで、中身だけが変わります。

int[] holder = new int[1];
Runnable inc = () -> holder[0]++;
inc.run(); inc.run(); inc.run();
System.out.println(holder[0]);   // 3

AtomicInteger counter = new AtomicInteger();
Runnable inc2 = counter::incrementAndGet;
inc2.run(); inc2.run();
System.out.println(counter.get());   // 2

ただしこれは、仕様が避けようとした並行性の問題を自分で引き受ける書き方です。配列ホルダには何の同期もないため、複数スレッドから更新すると結果が壊れます。スレッドをまたぐ可能性があるなら AtomicInteger のような並行処理向けのクラスを使い、単一スレッドで完結する集計であれば Stream API の reduce や collect に寄せるほうが安全です。ラムダ式をどこまで使うかの判断は、Javaラムダ式の使いどころで扱っている「副作用を持ち込まない」基準と同じ考え方になります。

thisキャプチャによるメモリリーク

前述のとおり、インスタンスフィールドやインスタンスメソッドを参照するラムダ式は this をキャプチャします。このラムダ式を長寿命のオブジェクト(イベントリスナーの登録先、キャッシュ、staticなコレクションなど)へ渡すと、外側のインスタンス全体が到達可能なまま残ります。画面やセッションに紐づくオブジェクトで起きると、解放されるべきインスタンスが生き続けます。

フィールドを参照する代わりに、必要な値だけをローカル変数へ取り出してからキャプチャすれば、this は捕まりません。実際に置き換えると、呼び出し記述子から外側の型が消え、生成されるメソッドも static になります。

Runnable viaField() { return () -> System.out.println(name); }
Runnable viaLocal() { String n = name; return () -> System.out.println(n); }

// javap -p -c の該当行
viaField(): invokedynamic  // InvokeDynamic #0:run:(LVerify;)Ljava/lang/Runnable;
viaLocal(): invokedynamic  // InvokeDynamic #1:run:(Ljava/lang/String;)Ljava/lang/Runnable;
private void        lambda$viaField$0();
private static void lambda$viaLocal$0(java.lang.String);

ただし、取り出した値そのものが外側のインスタンスを参照しているオブジェクトであれば、this を直接キャプチャしなくても参照経路は残ります。効果があるのは、取り出す対象が文字列や数値のように外側と切り離されている場合です。リスナーの登録解除を含めた対処の全体像はメモリリークの原因の特定方法と直し方で整理しています。

よくある質問

キャプチャリングとキャプチャの違いは何ですか?

Javaの文脈では同じことを指します。「キャプチャリング」は英語の capturing をそのままカタカナにした表記で、言語仕様では variable capture のように「キャプチャ」側の語が使われます。ただし分野をまたぐと指す対象が変わり、DOMのイベント伝播ではキャプチャリングフェーズ、ネットワークではパケットキャプチャ、CGではモーションキャプチャを指します。

なぜキャプチャする変数にfinalが必要なのですか?

Java 8 以降は final の明示は不要で、実質final であれば足ります。制限そのものが残っている理由として、Java言語仕様 §15.27.2 は、動的に変化するローカル変数のキャプチャが並行性の問題を招きやすいことを挙げています。参照する値が変わりうると、実行順序やスレッド間の同期を書き手が管理する必要が生じるためです。

実質final(effectively final)とは何ですか?

final が付いていないのに、final が付いているのと同じ扱いを受ける変数のことです。Java言語仕様 §4.12.4 は、初期化子を持つローカル変数について、final 宣言がなく、代入式の左辺に現れず、インクリメント演算子・デクリメント演算子のオペランドにもならないものを実質finalと定義しています。初期化子を持たない変数の場合は、宣言後に1回だけ代入する形であれば実質finalです。いずれの場合も、実質finalの変数に後から final を付けてコンパイルエラーになることはありません。

ラムダ式の中からローカル変数を書き換える方法はありますか?

変数そのものへ代入することはできません。要素数1の配列や AtomicInteger、可変オブジェクトのフィールドを経由すれば中身は変更できますが、並行性の問題を自分で管理する必要があります。集計が目的であれば Stream API の reduce や collect を使うか、処理結果を戻り値として受け取る設計に変えるほうが安全です。

匿名クラスとラムダ式はどちらを使うべきですか?

関数型インタフェース1つを実装するだけならラムダ式が簡潔で、クラスファイルも増えません。一方、複数のメソッドを実装する、自分自身の状態を持つ、this で自分自身を参照する、といった場合は匿名クラスが必要です。既存の匿名クラスを機械的にラムダ式へ置き換えると this の指す先が変わるため、this を使っているコードは個別に確認してください。

キャプチャリングはパフォーマンスに影響しますか?

キャプチャするラムダ式は評価のたびにオブジェクトが割り当てられる可能性がありますが、何もキャプチャしないラムダ式は既存のインスタンスが返されることがあります。JDK 25 の実測でも非キャプチャのラムダ式は同じ identityHashCode を返しました。ただし LambdaMetafactory の javadoc はこの同一性を保証しないと明記しているため、性能を理由に書き方を変えるのではなく、まず計測してから判断してください。

関連記事

お気に入りに入れた記事の一覧

資料請求

RELATED POSTS 関連記事

目次