1. 引言

Java 8 引入了 Lambda 表达式,也顺带引入了 effectively final 这个概念。你有没有想过,为什么在 Lambda 表达式中捕获的局部变量必须是 final 或 effectively final?

JLS 给出了一点提示:“限制使用 effectively final 变量是为了避免访问动态变化的局部变量,这种访问可能会引发并发问题。” 但这句话到底意味着什么?

接下来我们深入分析这个限制,并通过示例展示它在单线程和并发环境下的影响,还会指出一种常见的错误绕过方式。

2. Lambda 捕获变量

Lambda 表达式可以使用其外部作用域中定义的变量,我们称这种 lambda 为 capturing lambdas。它可以捕获:

  • 静态变量 ✅
  • 实例变量 ✅
  • 局部变量 ❌(但必须是 final 或 effectively final)

在 Java 8 之前,当匿名内部类捕获其外部方法的局部变量时,我们必须手动加上 final 关键字才能编译通过。

Java 8 为了简化语法,引入了 effectively final 的概念:即使没有显式声明 final,只要变量在初始化后没有被修改,编译器就会认为它是 effectively final。

effectively final 是指:如果你加上 final 关键字,编译器不会报错。

3. Lambda 中的局部变量

下面这段代码是不能编译通过的:

Supplier<Integer> incrementer(int start) {
  return () -> start++;
}

这里 start 是一个局部变量,在 lambda 中被修改,违反了 effectively final 的规则。

为什么不允许这么做?因为 lambda 表达式会捕获这个变量的值(即复制)。如果允许修改,会给人一种错觉,以为 lambda 中修改的 start 会影响原始方法中的 start。

更关键的是,lambda 可能在其定义方法返回后才执行。此时 start 已经被回收,lambda 中保存的只能是其拷贝。

3.1. 并发问题

我们来设想一个场景:

public void localVariableMultithreading() {
    boolean run = true;
    executor.execute(() -> {
        while (run) {
            // do operation
        }
    });
    
    run = false;
}

看起来很合理,但实际上存在可见性问题。每个线程有自己的栈,lambda 中的 run 是 run 变量的拷贝,主线程修改 run 为 false,lambda 中的 run 并不会同步更新。

要解决可见性问题,通常会使用 volatilesynchronized。但 Java 通过 effectively final 的限制,直接避免了这个问题。

⚠️ Lambda 中的局部变量本质上是值拷贝,不共享引用。

4. Lambda 中的静态变量与实例变量

前面的例子可能会让人产生疑问:为什么 lambda 可以自由修改静态变量和实例变量?

我们可以将前面的例子稍作修改,让其编译通过:

private int start = 0;

Supplier<Integer> incrementer() {
    return () -> start++;
}

这是因为:

  • 局部变量存在栈中
  • 实例变量存在堆中

Lambda 捕获实例变量时,本质上是捕获了 this 对象。堆中的变量生命周期更长,也能保证多线程下的一致性。

同样的,我们可以修复前面的并发问题:

private volatile boolean run = true;

public void instanceVariableMultithreading() {
    executor.execute(() -> {
        while (run) {
            // do operation
        }
    });

    run = false;
}

虽然编译器不会报错,但我们在多线程环境下依然需要使用 volatile 来保证可见性。

✅ Lambda 中的实例变量本质是 this 的引用,不是拷贝。

5. 避免错误的绕过方式

为了绕过 effectively final 的限制,有些人会使用一些“技巧”,比如用数组或包装类来“模拟可变”。

比如下面这个例子:

public int workaroundSingleThread() {
    int[] holder = new int[] { 2 };
    IntStream sums = IntStream
      .of(1, 2, 3)
      .map(val -> val + holder[0]);

    holder[0] = 0;

    return sums.sum();
}

看起来是给每个数加 2,但其实最终是加 0,因为 lambda 是在最后执行 sum 的时候才取值,而此时 holder[0] 已经变成 0。

再来看一个多线程版本:

public void workaroundMultithreading() {
    int[] holder = new int[] { 2 };
    Runnable runnable = () -> System.out.println(IntStream
      .of(1, 2, 3)
      .map(val -> val + holder[0])
      .sum());

    new Thread(runnable).start();

    // 模拟处理
    try {
        Thread.sleep(new Random().nextInt(3) * 1000L);
    } catch (InterruptedException e) {
        throw new RuntimeException(e);
    }

    holder[0] = 0;
}

结果取决于线程执行顺序:

  • 如果主线程先改了 holder[0],输出是 6
  • 如果子线程先执行,输出是 12

这种做法非常不可靠且容易踩坑,应坚决避免。

6. 总结

Lambda 表达式只能使用 final 或 effectively final 的局部变量,这背后是 Java 对内存模型和并发安全的考虑:

  • 局部变量是栈上变量,生命周期短,lambda 会拷贝其值
  • 实例变量是堆上变量,lambda 捕获的是 this,生命周期长
  • 使用数组、包装类等绕过限制的做法容易引发并发问题和不确定性行为,应坚决避免

理解 effectively final 的本质,有助于我们写出更安全、更清晰的 Lambda 表达式代码。

完整示例代码可在 GitHub 上查看。


原始标题:Why Do We Need Effectively Final? | Baeldung