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 并不会同步更新。
要解决可见性问题,通常会使用 volatile 或 synchronized。但 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 上查看。