1. 简介
Java 8为我们提供了lambda,并通过关联有效地定义了最终变量的概念。有没有想过为什么在lambda中捕获的局部变量必须是最终的有效的最终变量?
好吧,JLS给了我们一些提示,它说“对有效final变量的限制禁止访问动态更改的局部变量,这些局部变量的捕获可能会引入并发问题。” 但是,这意味着什么?
在接下来的部分中,我们将更深入地研究这个限制,看看为什么Java引入了它。我们将通过示例来演示它如何影响单线程和并发应用程序,并且我们还将揭穿解决此限制的常见反模式。
2. 捕获Lambda
Lambda表达式可以使用在外部作用域中定义的变量,我们将这些lambda称为捕获lambda。它们可以捕获静态变量、实例变量和局部变量,但只有局部变量必须是最终的或实际上是最终的。
在早期的Java版本中,当一个匿名内部类捕获包围它的方法的局部变量时,我们会遇到这种情况-我们需要在局部变量之前添加final关键字,以使编译器满意。
作为一个语法糖,现在编译器可以识别出虽然final关键字不存在但引用根本没有改变的情况,这意味着它实际上是最终的。如果编译器不会抱怨我们将其声明为final,则我们可以说该变量实际上是final。
3. 捕获Lambda中的局部变量
简单地说,这不会编译:
Supplier<Integer> 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变量的更改?在其他上下文中,答案可能是使用同步块或volatile关键字。
然而,因为Java强加了有效的最终限制,因此我们不必担心这样的复杂性。
4. 捕获Lambda中的静态或实例变量
如果我们将前面的示例与在lambda表达式中使用静态或实例变量进行比较,可能会引发一些问题。
我们可以通过将start变量转换为实例变量来编译我们的第一个示例:
private int start = 0;
Supplier<Integer> 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关键字。
一般来说,在捕获实例变量时,我们可以将其视为捕获最终变量this。无论如何,编译器不报错并不意味着我们不应该采取预防措施,尤其是在多线程环境中。
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时可用的最新值。
让我们更进一步,在另一个线程中执行求和:
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上获得。