为什么在Lambdas中使用的局部变量必须是Final或有效Final
1. 介绍
Java 8提供了lambda表达式,并通过关联给出了有效final变量的概念。是否想知道为什么在lambdas中捕获的局部变量必须是final或有效的final?
JLS给了我们一些提示,它说对有效final变量的限制禁止对动态更改的本地变量的访问,捕获本地变量可能会引入并发问题。但是,这是什么意思呢?
在下一节中,我们将深入研究这一限制,并了解Java引入这一限制的原因。我们将通过一些示例来演示它如何影响单线程和并发应用程序,并且我们还将揭穿一个常见的反模式,以绕过这个限制。
2. lambda捕获
Lambda表达式可以使用外部作用域中定义的变量。我们称这些为捕捉。它们可以捕获静态变量、实例变量和本地变量,但是只有本地变量必须是final或有效的final。
在早期的Java版本中,当一个匿名内部类捕获了一个局部变量时,我们就会遇到这种情况。我们需要在局部变量之前添加final关键字,这样编译器才会高兴。
作为语法糖,现在编译器可以识别这样的情况:虽然final关键字不存在,但引用根本没有改变,这意味着它实际上是final。如果编译器不报错的话,我们可以说一个变量实际上是final。
3. lambda捕获局部变量
简单地说,这个编译不通过:
Supplier incrementer(int start) {
return () -> start++;
}
start是一个局部变量,我们试图在lambda表达式中修改它。
这编译不通过的基本原因是lambda捕获了start的值,这意味着创建了它的一个副本。强制变量为final避免给人留下这样的印象:在lambda内部递增start实际上可以修改start方法参数。
但是,为什么要复制呢?注意,我们从我们的方法返回lambda。因此,直到start方法参数被垃圾收集之后,lambda才会运行。Java必须创建start的一个副本,以使这个lambda位于这个方法之外。
3.1 并发问题
为了好玩,让我们假设Java允许局部变量以某种方式保持与它们捕获的值的连接。
这里我们应该怎么做:
public void localVariableMultithreading() {
boolean run = true;
executor.execute(() -> {
while (run) {
// do operation
}
});
run = false;
}
虽然这看起来是无害的,但它也有潜在的可见性问题。回想一下,每个线程都有自己的堆栈,那么如何确保while循环看到对另一个堆栈中的run变量的更改呢?在其他上下文中,答案可能是使用synchronized块或volatile关键字。
然而,由于Java施加了有效的最终限制,我们不必担心这样的复杂性。
4. lambda捕获静态或实例变量
如果我们将前面的示例与在lambda表达式中使用静态或实例变量进行比较,就会产生一些问题。
我们可以通过将start变量转换为实例变量来实现第一个示例的编译:
private int start = 0;
Supplier incrementer() {
return () -> start++;
}
但是,为什么我们要改变start的值呢?
简单地说,它是关于存储成员变量的位置。局部变量在堆栈上,但是成员变量在堆上。因为我们处理的是堆内存,所以编译器可以保证lambda可以访问start的最新值。
我们可以通过同样的方法来修正第二个例子:
private volatile boolean run = true;
public void instanceVariableMultithreading() {
executor.execute(() -> {
while (run) {
// do operation
}
});
run = false;
}
run变量现在对lambda是可见的,即使它是在另一个线程中执行的,因为我们添加了volatile关键字。
一般来说,当捕获一个实例变量时,我们可以把它看作是捕获最后一个变量。不管怎样,编译器不会报错并不意味着我们不应该采取预防措施,尤其是在多线程环境中。
5. 变通方案
为了绕过对局部变量的限制,有人可能会考虑使用变量占位符来修改局部变量的值。
让我们来看一个使用数组在单线程应用程序中存储变量的示例:
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:
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();
// simulating some processing
try {
Thread.sleep(new Random().nextInt(3) * 1000L);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
holder[0] = 0;
}
这里的和是多少?这取决于我们的模拟处理需要多长时间。如果它足够短,让方法的执行在另一个线程执行之前终止,它将输出6,否则,它将输出12。
一般来说,这类变通方法容易出错,并且会产生不可预知的结果,所以我们应该避免使用它们。
6. 结论
在本文中,我们解释了为什么lambda表达式只能使用final或有效的final局部变量。正如我们所看到的,这种限制来自于这些变量的不同性质以及Java如何将它们存储在内存中。我们还展示了使用常见的变通方法的危险。
与往常一样,示例的完整源代码可以在GitHub上找到。over on GitHub.