为什么Java Lambda 表达式使用的本地变量必须为final?

原创
06/24 17:02
阅读数 1.2K

看到一篇文章讲的很好, 简单整理和翻译一下.

介绍

Java 8 引入了lambda 功能, 相关联的引入了 effectively final 的概念. 有没有想过lambda内使用的局部变量必须是 final 或者 effectively final 的?

effectively final: 作为语法糖,编译器已经可以识别出虽然变量没有被 final 修饰,但实际上引用并没有发生改变,这中情况就可以说这个变量是 effectively final的。也就是说在需要final变量的场合, 编译器没有报错, 那么这个变量就是 effectively final 的.

其实, JLS (Java Language Specification)给了我们一些提示: "禁止 effectively final 变量访问动态改变的局部变量, 是为了避免访问时可能引入的并发问题". 但是这句话是啥意思?

下面我们将会更深入的研究一下为什么Java会引入这种限制. 并展示此限制如何影响单线程和并发应用的. 并且会揭示此限制相关的反面用法.

Capturing Lambdas

Lambda 表达式可以使用在 lambda 外部范围中定义的变量。我们将这些 lambdas 称为Capturing lambdas。它们可以捕获静态变量(static variables)、成员变量(instance variables)和局部变量(local variables),但只有局部变量必须是final 或effectively final 的。

Capturing Lambdas 中的局部变量

以下是无法编译的示例:

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

start 是一个局部变量, 我们想在 lambda 表达式内部尝试更改它的值.

无法编译的本质原因是lambda捕获的是start的副本(copy of it, 而不是start本身). 强制让变量 final 可以避免给人在lambda内部改变了外部方法入参的错误印象.

现在我们考虑下lambda为什么要复制一份start, 而不是使用 start 本身. 我们发现, 此方法返回的是一个 lambda 表达式, 这个表达式执行的时候, start 局部变量可能已经被销毁并垃圾回收了. 为了让这个lambda在方法外部正常执行, Java只能拷贝一份start.

并发问题

我们假设Java允许局部变量以某种方式保持lambda内部使用值的联系. 看下面这个例子:

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

这段代码看起来没问题, 但它隐藏着"线程可见性"问题. 每个线程都有自己的堆栈, 我们无法确保在 while 循环中看到另一个堆栈中运行的变量. 线程可见性通常需要动用synchronize同步块或者 volatile 关键字解决.

当然, Java提供了 effectively final的限制, 我们不必担心这种复杂场景.

Capturing Lambdas中的静态变量和成员变量

只需把第一个例子中的start参数, 从局部变量改成成员变量, 就可以正常编译了:

private int start = 0;
​
Supplier<Integer> incrementer() {
    return () -> start++;
}

问题是, 为什么改成成员变量就行了?

简单说, 原因在于变量是在什么位置存储的. 本地变量存储在栈中, 成员变量存储在堆中. 我们处理堆内存时, 编译器可以保证我们处理的是最新值.(此处应该指的单线程场景)

同样, 我们可以修复第二个示例的编译问题:

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

因为添加了 volatile 关键字, 现在即使在lambda内部另一个线程中, run变量也是可以被看到最新值的.

一般来讲, 当捕获了成员变量时, 代表着捕获了final修饰的this. 但是, 编译器不报错并不能代表我们不需要采取一些预防措施, 特别是在多线程场景下.

应避免的用法

为了绕过对局部变量的限制,有人可能会想到使用变量容器来修改局部变量的值。

让我们看一个在单线程应用程序中使用数组存储变量的示例:

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 时变量的最新值。

使用 AtomicReference 作为容器也存在一样的问题:

    public static int workaroundSingleThread() {
        AtomicReference<Integer> holder = new AtomicReference<>(2);
        IntStream sums = IntStream.of(1, 2, 3).map(val -> val + holder.get());
        holder.set(0);
        return sums.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;
}

这里是和什么值求和?这取决于我们处理(这里用sleep模拟的)需要多长时间。如果它足够短,可以让方法的执行在另一个线程执行之前终止,它将打印 6,否则,它将打印 12。

一般来说,这些"变通解决方法"容易出错, 可能产生不可预知的结果,因此我们应该尽量避免。

总结

在本文中,我们解释了 lambda 表达式中使用的局部变量为什么必须是 final 或 effectively final 的。正如所见,这种限制来自这些变量的不同性质以及 Java 在内存中存储变量的方式(位置)。我们还展示了使用"变通解决方法"的危险。

另外, 不同语言对 lambda 要求不一样, 所以说对 lambda 语法限制是受限于 Java 语言的设计.

参考

https://www.baeldung.com/java-lambda-effectively-final-local-variables https://docs.oracle.com/javase/specs/jls/se11/html/jls-17.html#jls-17.4.1 https://docs.oracle.com/javase/specs/jls/se10/html/jls-15.html#jls-15.27.2 https://stackoverflow.com/questions/34865383/variable-used-in-lambda-expression-should-be-final-or-effectively-final# https://www.zhihu.com/question/27416568/answer/36565794

展开阅读全文
加载中

作者的其它热门文章

打赏
0
0 收藏
分享
打赏
0 评论
0 收藏
0
分享
返回顶部
顶部