SJ blog
backend
A

信頼度ランク

S 公式ソース確認済み
A 成功実績多数・失敗例少数
B 賛否両論
C 動作未確認・セキュリティリスク高
Z 個人所感

ラムダ式の正体 ── 匿名クラス・effectively final・クロージャをメモリから理解する

ラムダ式はなぜ匿名クラスより軽いのか。なぜ外側の変数は effectively final でなければならないのか。クロージャのキャプチャという概念をメモリの視点から解説する。

一言結論

ラムダは「匿名クラスの糖衣構文」ではなく invokedynamic を使った独立した仕組み。effectively final が必要な理由は「キャプチャした変数のコピーとの不整合を防ぐため」。

List<String> names = List.of("Alice", "Bob", "Charlie");
names.forEach(name -> System.out.println(name));

ラムダ式は「短く書ける無名関数」として使われるが、なぜこれが動くのかを説明できるかは別の話だ。

  • なぜ外の変数を使えるのに、変更できないのか(effectively final
  • 匿名クラスと何が違うのか
  • PredicateFunction という型は何者か

これらはメモリとJVMの仕組みから説明できる。


1. 関数型インターフェースとは何か

Java でラムダ式を受け取れるのは**関数型インターフェース(抽象メソッドがちょうど 1 つのインターフェース)**だけだ。

@FunctionalInterface
interface Greeter {
    void greet(String name); // 抽象メソッドが 1 つだけ
}

ラムダ式はこのインターフェースの匿名実装として扱われる。

Greeter g = name -> System.out.println("Hello, " + name);
g.greet("Alice"); // "Hello, Alice"

g の型は Greeter(インターフェース型)。ヒープ上には Greeter を実装したオブジェクトが存在し、g はその参照を持つ。

標準ライブラリの主な関数型インターフェース:

インターフェース抽象メソッド用途
Predicate<T>boolean test(T t)条件判定(filter)
Function<T,R>R apply(T t)変換(map)
Consumer<T>void accept(T t)消費(forEach)
Supplier<T>T get()供給(遅延生成)
BiFunction<T,U,R>R apply(T t, U u)2引数の変換

2. 匿名クラスとラムダの違い

ラムダ登場以前、同じことを匿名クラスで書いていた:

// 匿名クラス(Java 8 以前)
Runnable r1 = new Runnable() {
    @Override
    public void run() {
        System.out.println("running");
    }
};

// ラムダ(Java 8 以降)
Runnable r2 = () -> System.out.println("running");

見た目の違いだけでなく、JVM 内部の動作が異なる

匿名クラスの場合:

  • コンパイル時に Main$1.class のような名前のクラスファイルが生成される
  • new Main$1() でヒープにオブジェクトが確保される
  • 毎回呼ばれるたびに新しいオブジェクトが生成される可能性がある

ラムダの場合:

  • invokedynamic という JVM 命令を使う
  • 初回実行時に JVM がラムダの実体(実装クラス)を動的に生成する
  • 状態を持たないラムダはキャッシュされ、同じインスタンスが再利用される場合がある
Runnable r1 = () -> System.out.println("running");
Runnable r2 = () -> System.out.println("running");
System.out.println(r1 == r2); // true になる場合がある(JVM の最適化による)

ラムダは匿名クラスより軽量に動作できる理由はここにある。


3. effectively final ── なぜ外の変数を変更できないのか

int count = 0;
Runnable r = () -> {
    count++;  // コンパイルエラー!
    System.out.println(count);
};

なぜ count を変更できないのか。

キャプチャ(変数の取り込み)の仕組み

ラムダが外側のローカル変数を使うとき、その値をコピーしてキャプチャする。

【メモリ上で何が起きているか】

スタック(main フレーム)      ヒープ(ラムダオブジェクト)
┌──────────────────┐          ┌──────────────────────┐
│ count = 0        │          │ ラムダ実体            │
│ r: 0xB200 ───────┼─────────►│ captured_count = 0   │ ← コピーされた値
└──────────────────┘          └──────────────────────┘

count のコピーがラムダオブジェクトに入っている。もし count を変更できてしまったら:

count を 1 に変更:

スタック(main フレーム)      ヒープ(ラムダオブジェクト)
┌──────────────────┐          ┌──────────────────────┐
│ count = 1        │          │ captured_count = 0   │ ← コピーなので変わらない
└──────────────────┘          └──────────────────────┘

→ スタックの count とラムダ内の captured_count が不一致
→ どちらが正しい count か不明確

ローカル変数はスタックに置かれ、メソッドが終わると消える。ラムダはメソッド終了後も(どこかが参照している間は)ヒープ上で生き続ける。スタックの変数とヒープのラムダを直接リンクすることはできないので、コピーするしかない。コピーと元の値が食い違わないよう、「変更しないこと(effectively final)」を Java が強制する

// OK: effectively final(一度も再代入していない)
String prefix = "Hello";
Consumer<String> greet = name -> System.out.println(prefix + ", " + name);

// NG: 再代入している(effectively final でない)
String prefix2 = "Hello";
prefix2 = "Hi";  // この再代入があるだけでラムダ内で使えなくなる
Consumer<String> greet2 = name -> System.out.println(prefix2 + ", " + name); // エラー

インスタンス変数とstaticは変更できる

class Counter {
    int count = 0;  // インスタンス変数

    void increment() {
        Runnable r = () -> count++;  // OK!
        r.run();
    }
}

インスタンス変数や static 変数はヒープ(または Metaspace)にあり、ラムダが直接参照できる。ライフタイムの問題が起きないため、変更が許可されている。


4. メソッド参照はラムダの短縮形

// ラムダ
names.forEach(name -> System.out.println(name));

// メソッド参照(等価)
names.forEach(System.out::println);

4 種類のメソッド参照:

// 1. 静的メソッド参照
Function<String, Integer> f1 = Integer::parseInt;
// 等価: s -> Integer.parseInt(s)

// 2. インスタンスメソッド参照(特定のオブジェクト)
String prefix = "Hello";
Supplier<String> f2 = prefix::toUpperCase;
// 等価: () -> prefix.toUpperCase()

// 3. インスタンスメソッド参照(任意のオブジェクト)
Function<String, String> f3 = String::toUpperCase;
// 等価: s -> s.toUpperCase()

// 4. コンストラクタ参照
Supplier<ArrayList<String>> f4 = ArrayList::new;
// 等価: () -> new ArrayList<String>()

5. ラムダとストリームが繋がる

ラムダを理解すると、Stream API の設計が見えてくる。

List<String> result = names.stream()
    .filter(name -> name.length() > 3)   // Predicate<String>
    .map(name -> name.toUpperCase())      // Function<String, String>
    .collect(Collectors.toList());

filterPredicate<T>boolean test(T t))を受け取る。mapFunction<T,R>R apply(T t))を受け取る。各メソッドに「処理の内容をラムダで渡す」という設計だ。

Stream はこれらのラムダを即座に実行しない(遅延評価)。collect のような終端操作が呼ばれて初めて、すべてのラムダが順番に実行される。詳細は Stream の記事で。


まとめ

概念正体
関数型インターフェース抽象メソッドが 1 つだけのインターフェース
ラムダ式関数型インターフェースの匿名実装。invokedynamic で実体化
effectively finalラムダがコピーキャプチャするため、コピーと元値の不一致を防ぐ制約
メソッド参照既存メソッドをラムダとして渡す簡潔な構文
Predicate/Function/Consumer/Supplierよく使うパターンを標準化した関数型 I/F