为什么实例字段不需要是 final 或有效的 final 才能在 lambda 表达式中使用?

Why don't instance fields need to be final or effectively final to be used in lambda expressions?

我正在 Java 练习 lambda 表达式。根据 Java SE 16 Lambda Body :

的 Oracle 文档,我知道局部变量需要是最终的或有效的最终变量

Any local variable, formal parameter, or exception parameter used but not declared in a lambda expression must either be final or effectively final (§4.12.4), as specified in §6.5.6.1.

虽然没有说明原因。通过搜索,我发现了这个类似的问题 ,Whosebug 用户“snr”在其中回复了下一个引述:

Local variables in Java have until now been immune to race conditions and visibility problems because they are accessible only to the thread executing the method in which they are declared. But a lambda can be passed from the thread that created it to a different thread, and that immunity would therefore be lost if the lambda, evaluated by the second thread, were given the ability to mutate local variables.

这是我的理解:一个方法一次只能由一个线程(比方说thread_1)执行。这确保该特定方法的局部变量仅由 thread_1 修改。另一方面,lambda 可以传递给不同的线程 (thread_2),所以...如果 thread_1 以 lambda 表达式结束并继续执行方法的其余部分,它可能会更改值局部变量,同时 thread_2 可能会更改 lambda 表达式中的相同变量。然后,这就是存在此限制的原因(局部变量需要是最终的或有效的最终)。

抱歉解释得太长了。我做对了吗?

但接下来的问题是:

我对 Java 没有太多经验。对不起,如果我的问题有明显的答案。

实例变量存储在堆中space,而局部变量存储在堆栈中space。每个线程都维护自己的堆栈,因此局部变量不会在线程之间共享。另一方面,堆 space 由所有线程共享,因此多个线程可以修改一个实例变量。有多种机制可以使数据线程安全,您可以在该平台上找到许多相关讨论。为了完整起见,我在下面引用了 http://web.mit.edu/6.005/www/fa14/classes/18-thread-safety/

的摘录

There are basically four ways to make variable access safe in shared-memory concurrency:

  • Confinement. Don’t share the variable between threads. This idea is called confinement, and we’ll explore it today.
  • Immutability. Make the shared data immutable. We’ve talked a lot about immutability already, but there are some additional constraints for concurrent programming that we’ll talk about in this reading.
  • Threadsafe data type. Encapsulate the shared data in an existing threadsafe data type that does the coordination for you. We’ll talk about that today.
  • Synchronization. Use synchronization to keep the threads from accessing the variable at the same time. Synchronization is what you need to build your own threadsafe data type.

这个问题真的与线程安全无关。对于为什么始终可以捕获实例变量,有一个简单直接的答案:this 总是有效的最终值。也就是说,在创建访问实例变量的 lambda 时总是有一个已知的固定对象。请记住,名为 foo 的实例变量 always 实际上等同于 this.foo.

所以

class MyClass {
  private int foo;
  public void doThingWithLambda() {
    doThing(() -> { System.out.println(foo); })
  }
}

可以将 lambda 重写为 doThing(() -> System.out.println(this.foo); }),因此等同于

class MyClass {
  private int foo;
  public void doThingWithLambda() {
    final MyClass me = this;
    doThing(() -> { System.out.println(me.foo); })
  }
}

...除了 this 已经是最终的并且不需要复制到另一个局部变量(尽管 lambda 将捕获引用)。

当然,所有正常的线程安全警告都适用。如果你的 lambdas 被传递给多个线程并修改变量,那么如果没有使用 lambdas 就会发生完全相同的事情,并且除了你的变量的线程安全之外没有额外的线程安全适用(例如,如果它们是易变的)或者如果你的lambda 使用其他机制来安全地访问变量。 Lambda 对线程安全根本没有做任何特别的事情,它们也不对实例变量做任何特别的事情;他们只是捕获对 this 的引用,而不是对实例变量的引用。

其他答案已经提供了关于为什么这是 Java 中的限制的重要背景。我想提供一些背景知识,说明其他语言在不强制要求将局部变量视为不可变(即 final)时如何处理此问题。

建议的要点是“堆”值(即字段)本质上可以从其他线程访问,而“堆栈”值(即局部变量)本质上只能从声明这些值的方法内部访问。这是真实的。因此,由于字段存储在堆上,因此可以在方法完成后更改它们。相比之下,堆栈值会在方法完成后立即消失。

Java 选择遵守这些语义,因此在方法完成后绝不能修改局部变量。这是一个公平的设计决定。但是,某些语言 do 选择允许在方法退出后更改局部变量。那怎么可能?

在 C#(我最熟悉的语言,但 JavaScript 等其他语言也允许这些构造)中,当您在 lambda 中引用局部变量时,编译器 检测到 并且在幕后实际生成一个全新的 class 来存储局部变量。因此,编译器没有在堆栈上声明变量,而是检测到它在 lambda 内部被引用,因此实例化 class 来存储值。所以这个(在幕后)行为将堆栈值变成了堆值。 (您实际上可以反编译此类代码并查看这些编译器生成的 classes)

这个决定并非没有代价。例如,实例化一个 class 只是为了容纳一个整数显然更昂贵。在 Java,你保证这永远不会发生。在 C# 等语言中,需要仔细推理才能知道您的变量是否已“提升”到生成的 class.

所以最终基本原理成为设计决策之一。在 Java 你不能搬起石头砸自己的脚。在 C# 中,他们认为在大多数情况下,性能后果并不是什么大问题。

也就是说,C# 的决定常常是混乱和错误的来源,特别是围绕 for 循环中的循环迭代器变量(循环变量 i 可以(并且必须)发生突变) 并传递给 lambda,如 Eric Lippert 的 blog post 所述。问题如此之大,以至于他们决定对 foreach 变体的编译器进行(罕见的)重大更改。

另一方面,我很享受在 C# 中的 lamda 内部改变局部变量的自由。但是这两个决定都是有代价的。

这个答案绝对不是要提倡任何一个决定,但我认为值得详细说明其中的一些设计选择。