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

Dam*_*GDO 29 java lambda instance-variables local-variables

我正在用 Java 练习 lambda 表达式。根据 Java SE 16 Lambda Body的 Oracle 文档,我知道局部变量需要是 final 或有效 final 的:

任何使用但未在 lambda 表达式中声明的局部变量、形式参数或异常参数必须是 final 或有效 final (§4.12.4),如 §6.5.6.1 中所述。

但它没有说为什么。搜索我发现了这个类似的问题为什么 lambdas 中的变量必须是最终的或有效的最终?,其中 StackOverflow 用户“snr”回复了下一个引用:

到目前为止,Java 中的局部变量不受竞争条件和可见性问题的影响,因为它们只能被执行声明它们的方法的线程访问。但是一个 lambda 可以从创建它的线程传递到另一个线程,因此如果由第二个线程评估的 lambda 被赋予改变局部变量的能力,那么这种免疫力就会丧失。

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

对不起,很长的解释。我做对了吗?

但接下来的问题是:

  • 为什么这种情况不适用于实例变量?
  • 如果 thread_1 与 thread_2 同时更改实例变量会发生什么(即使它们没有执行 lambda 表达式)?
  • 实例变量是否以另一种方式保护?

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

Lou*_*man 44

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

所以

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

可以将 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); })
  }
}
Run Code Online (Sandbox Code Playgroud)

...exceptthis已经是最终的,不需要复制到另一个局部变量(尽管 lambda 会捕获引用)。

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

  • @cat:可以作为参考,而且永远不会改变。它实际上并不是一个变量,但就本文讨论的所有内容而言,它的行为就像一个最终变量。 (4认同)
  • 我认为,如果你首先解释一下 lambda 是通过复制局部变量的值来捕获局部变量的,那么这个答案可能会更好——这就是为什么它们必须是有效的最终结果;否则调用者可能会发现它们的值不匹配。因此,由于 `this` 是“有效有效的最终”,所以 lambda 可以通过复制引用来捕获它。 (3认同)
  • 这个答案至少部分不正确。JLS 本身注意到了并发问题。而“this”是一个代表值的关键字,而不是一个可以有效地为final的变量。来自 JLS:*“关于变量使用的类似规则适用于内部类的主体(第 8.1.3 节)。对有效最终变量的限制禁止访问**动态更改的局部变量**,其捕获可能会引入**并发问题**。"* (2认同)

Kir*_*oll 23

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

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

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

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

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

因此,最终理由成为设计决策之一。在 Java 中,你不能用脚射击自己。在 C# 中,他们认为在大多数情况下,性能后果并不是什么大问题。

也就是说,C# 的决定通常是混淆和错误的根源,尤其是在循环中的循环迭代器变量for(循环变量i可以(并且必须)被改变)并传递给 lambda 的情况下,如 Eric Lippert 的博客文章中所述。问题如此严重,以至于他们决定为该foreach变体的编译器引入一个(罕见的)破坏性更改。

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

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

  • 小修正:在 C# 中,即使变量没有在 lambda 中发生变化,它也会被提升到闭包类。如果只是引用就足够了。 (3认同)
  • 这种行为比绩效产生更多的后果。这意味着突然之间,程序员有责任确保局部变量的线程安全。在 Java 中,局部变量不受数据竞争的影响,当您可以将局部变量转换为共享可变变量时,这并不适用。但是,例如,您不能在 Java 中将局部变量声明为“易失性”。这是不可能的,因为从来不需要它。由于您也无法在合成类的实例上进行同步,因此确保线程安全的局部变量突然变得比确保线程安全的字段更加复杂。 (3认同)
  • @ach:当我在 C# 团队时(差不多十年前),我们考虑进行一些优化,以区分 lambda 的变异变量和仅读取外部变量,并“按值”捕获后者,而不是捕获多变的。我从来没有做过这样的优化;听起来该团队从那以后的几年里都没有这样做过,但如果他们有一天这样做,我一点也不会感到惊讶。 (3认同)
  • @Holger:你的观点是正确的并且被很好地接受,但值得注意的是,自 C# 2.0 以来,局部变量一直可以以意想不到的方式和意想不到的顺序进行修改,并且你既不需要匿名方法,也不需要 lambda,甚至不需要多线程成为此类竞赛的受害者。协程(C# 2.0 中的迭代器块和 C# 6.0 中的异步方法)还具有将局部变量提升到堆并延长其生命周期的属性,因为协程激活不形成堆栈。 (2认同)
  • @supercat:回复:有多少个外部变量的子集就有多少个闭包类,是的,这正是我们考虑的优化。 (2认同)
  • @supercat:回复:用语言表示:当我们为 C# 3.0 设计 lambda 时,Herb Sutter 随机来到我的办公室,我们进行了一次非常有趣的对话,讨论 C++ 是如何做到这一点的,以及优点和缺点是什么。显然,C# 团队决定不添加用于指示所需闭包语义的语法。回想起来,我有点希望我们能够更轻松地静态检测和禁止关闭可修改变量的 LINQ 查询推导式,因为事实证明这会成为用户错误的丰富来源。 (2认同)
  • @Joker_vD:我注意到查询理解重写引入的“透明标识符”也存在同样的权衡。它们将糖化为类型,您最终可以通过几个级别的取消引用深入钻取变量。但是,如果您正在构建一个包含大量 SelectMany 子句的大型查询,那么访问范围变量所花费的时间很可能是您最不担心的性能问题。 (2认同)
  • @EricLippert 是的,对于 C#,已经采取的道路使得更容易决定对其他功能重复它。对于 Java,这并不适用,这使得仅共享(有效)最终局部变量变量变得有吸引力。这意味着Java中存在supercat让程序员决定的想法。只需使用局部变量来实现“按值”语义,或者显式创建保存该变量的类来实现“按引用”语义。 (2认同)

Arv*_*ash 17

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

基本上有四种方法可以使共享内存并发中的变量访问安全:

  • 坐月子。不要在线程之间共享变量。这个想法被称为限制,我们今天将探讨它。
  • 不变性。使共享数据不可变。我们已经讨论了很多关于不变性的内容,但是我们将在本阅读中讨论并发编程的一些额外约束。
  • 线程安全数据类型。将共享数据封装在为您进行协调的现有线程安全数据类型中。我们今天就讲这个。
  • 同步。使用同步来防止线程同时访问变量。同步是您构建自己的线程安全数据类型所需要的。

  • 这如何回答这个问题? (14认同)
  • @akuzminykh - 你还在寻找什么? (9认同)
  • 您谈论堆和堆栈,但没有谈论为什么局部变量需要是“final”而字段不需要。这个问题相当理论化,我认为它值得比这更广泛和更有启发性的解释。 (8认同)
  • @akuzminykh - OP 已经做了很好的研究,这个答案集中在最后列出的问题上。其余的解释位于引用的链接中。 (3认同)