为什么在Lambdas中使用的局部变量必须是Final或有效Final

by Marcos Lopez Gonzalez

1. 介绍

Java 8提供了lambda表达式,并通过关联给出了有效final变量的概念。是否想知道为什么在lambdas中捕获的局部变量必须是final或有效的final?

JLS给了我们一些提示,它说对有效final变量的限制禁止对动态更改的本地变量的访问,捕获本地变量可能会引入并发问题。但是,这是什么意思呢?

在下一节中,我们将深入研究这一限制,并了解Java引入这一限制的原因。我们将通过一些示例来演示它如何影响单线程和并发应用程序,并且我们还将揭穿一个常见的反模式,以绕过这个限制

2. lambda捕获

Lambda表达式可以使用外部作用域中定义的变量。我们称这些为捕捉。它们可以捕获静态变量、实例变量和本地变量,但是只有本地变量必须是final或有效的final

在早期的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变量的更改呢?在其他上下文中,答案可能是使用synchronized块或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关键字。

一般来说,当捕获一个实例变量时,我们可以把它看作是捕获最后一个变量。不管怎样,编译器不会报错并不意味着我们不应该采取预防措施,尤其是在多线程环境中。

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.