Java final 字段:当前 JLS

问题描述

我目前正在尝试理解 this JLS section on final fields

为了更好地理解 JLS 中的文字,我还在阅读 Jeremy Manson(JMM 的创建者之一)的 The Java Memory Model

这篇论文包含了一个让我感兴趣的例子:如果一个带有 final 字段的对象 o 对另一个线程 t 可见两次:

  • o 的构造函数完成之前的第一个“不正确”
  • o 的构造函数完成后的下一个“正确”

然后 t 可以看到半构造的 o,即使它只能通过“正确”发布的路径访问。

这是论文的一部分:

图 7.3:简单最终语义的示例

f1 是最后一个字段;它的默认值为 0

主题 1 主题 2 主题 3
o.f1 = 42;
p = o;
freeze o.f1;
q = o;

r1 = p;
i = r1.f1;
r2 = q;
if (r2 == r1)
    k = r2.f1;
r3 = q;
j = r3.f1;



我们假设 r1、r2 和 r3 没有看到 null 值。 i和k可以是0或42,j必须是42。


考虑图 7.3。我们不会从多次写入最终字段的复杂性开始;目前,冻结只是在构造函数结束时发生的事情。尽管 r1r2r3 可以看到值 null,但我们不会关心它;这只会导致空指针异常。

...

线程 2 中 q.f1 的读取情况如何?是否保证看到最终字段的正确值?编译器可以确定 pq 指向同一个对象,因此对该线程的 p.f1q.f1 重用相同的值。我们希望允许编译器尽可能删除最终字段的冗余读取,因此我们允许 k 看到值 0。

对此概念化的一种方法是,如果线程读取了对对象的错误发布的引用,则认为该对象被线程“污染”。如果某个对象被某个线程污染,则永远无法保证该线程会看到该对象正确构造的最终字段。 更一般地说,如果线程 t 读取了对对象 o 的错误发布的引用,则线程 t 将永远看到 o 的受污染版本,而无法保证看到o 的最终字段的正确值。

我试图在 the current JLS 中找到任何明确允许或禁止此类行为的内容,但我发现的是:

当一个对象的构造函数完成时,它被认为是完全初始化的。只有在对象完全初始化后才能看到对该对象的引用的线程可以保证看到该对象的最终字段的正确初始化值。

当前的 JLS 允许这种行为吗?

解决方法

是的,这是允许的。

主要暴露在JMM已经引用的部分:

假设对象被“正确”构造,一旦对象被 构造,分配给最终字段的值 构造函数将对所有其他线程可见,而无需 同步

对象正确构造意味着什么?它简直 意味着 不允许对正在构造的对象的引用 施工中“逃生”

换句话说,不要放置对对象的引用 在另一个线程可能能够看到的任何地方构建 它;不要将其分配给静态字段,不要将其注册为 侦听器与任何其他对象,依此类推。这些任务应该做 构造函数完成后,不在构造函数中** *

所以是的,在允许的范围内,这是可能的。最后一段充满了不该做的事情的建议;每当有人说避免做X时,就暗示了X可以做到。


如果……怎么办 reflection

其他答案正确指出了final字段要被其他线程正确看到的要求,比如构造函数末尾的冻结、链等。这些答案提供了对主要问题的更深入理解,应首先阅读。 这个重点关注这些规则的一个可能的例外。

最重复的规则/短语可能是这里的这个,复制自 Eugene 的答案(顺便说一句,不应该有任何反对票):

当一个对象的构造函数完成时,它被认为是完全初始化的。一个线程只能看到一个引用 object 在该对象完全初始化之后是 保证看到该对象的正确 [分配/加载/设置] 值 最终字段

请注意,我将术语“初始化”更改为分配、加载或设置的等效术语。这是故意的,因为术语可能会误导我的观点。

另一个恰当的陈述来自chrylis -cautiouslyoptimistic-

“最终冻结”发生在构造函数的末尾,从 保证所有读数上的这一点都是准确的。


JLS 17.5 final Field Semantics 声明:

在此之后只能看到对对象的引用的线程 对象已经完全初始化保证看到 为该对象的最终字段正确初始化值

但是,您认为反思对此有什么看法吗?不,当然不是。它甚至没有读那段。

final 字段的后续修改

这些陈述不仅是正确的,而且还得到了 JLS 的支持。我不打算反驳他们,只是添加一些关于此法律例外的额外信息:反思这种机制,除其他外,可以在初始化后更改最终字段的值

final 字段的冻结发生在设置了 final 字段的构造函数的末尾,这是完全正确的。但是还有另一个未考虑冻结操作的触发器:final 字段的冻结也会通过反射 (JLS 17.5.3) 初始化/修改字段:

final 字段的冻结发生在构造函数的末尾 设置最后一个字段,并在每次修改后立即 通过反射的最终字段

final 字段的反射操作“打破”了规则:在构造函数正确完成后,对 final 字段的所有读取仍然不能保证准确。我会试着解释一下。

让我们假设所有正确的流程都得到了遵守,构造函数已经初始化,并且一个线程可以正确地看到一个实例中的所有 final 字段。现在是时候通过反射对这些字段进行一些更改(想象一下这是需要的,即使不寻常,我知道..)。

遵循之前的规则,所有线程都等待,直到所有字段都被更新:就像通常的构造函数场景一样,字段只有在被冻结并且反射操作正确完成后才被访问。 这是违法的地方

如果一个 final 字段被初始化为一个常量表达式 (§15.28) 字段声明,可能不会观察到对最终字段的更改, 因为该最终字段的使用在编译时被替换为 常量表达式的值。

这说明:即使遵循所有规则,您的代码也不会正确读取 final 字段的分配值,如果该变量是原语或字符串并且您对其进行了初始化作为字段声明中的常量表达式。为什么?因为该变量只是编译器的硬编码值,它不会再次检查该字段或其更改,即使您的代码在运行时执行中正确更新了该值。

那么,让我们测试一下:

 public class FinalGuarantee 
 {          
      private final int  i = 5;  //initialized as constant expression
      private final long l;

      public FinalGuarantee() 
      {
         l = 1L;
      }
        
      public static void touch(FinalGuarantee f) throws Exception
      {
         Class<FinalGuarantee> rfkClass = FinalGuarantee.class;
         Field field = rfkClass.getDeclaredField("i");
         field.setAccessible(true);
         field.set(f,555);                      //set i to 555
         field = rfkClass.getDeclaredField("l");
         field.setAccessible(true);
         field.set(f,111L);                     //set l to 111                 
      }
      
      public static void main(String[] args) throws Exception 
      {
         FinalGuarantee f = new FinalGuarantee();
         System.out.println(f.i);
         System.out.println(f.l);
         touch(f);
         System.out.println("-");
         System.out.println(f.i);
         System.out.println(f.l);
      }    
 }

输出

 5
 1
 -
 5   
 111

最终的 int i 已在运行时正确更新,要检查它,您可以调试和检查对象的字段值:

enter image description here

il 均已正确更新。那么 i 发生了什么,为什么仍然显示 5?因为如 JLS 所述,字段 i 在编译时直接替换为常量表达式的值,在本例中为 5。

随后对最终字段 i 的每次读取都将是 INCORRECT,即使遵循了之前的所有规则。编译器永远不会再次检查该字段:当您编写 f.i 代码时,它不会访问任何实例的任何变量。它只会返回 5:final 字段只是在编译时硬编码,如果在运行时对其进行了更新,则任何线程将永远不会再正确地看到它。 这违反了法律

作为在运行时正确更新字段的证明:

enter image description here

555111L 都被压入堆栈,字段获得新分配的值。但是在操作它们时会发生什么,例如打印它们的值?

  • l 未初始化为常量表达式,也未在字段声明中初始化。因此,不受 17.5.3 规则的影响。该字段已正确更新并从外线程读取。

  • 但是,
  • i 被初始化为字段声明中的常量表达式。在初始冻结之后,编译器不再有 f.i,该字段将永远不会被再次访问。即使变量在示例中正确更新为 555,每次从字段读取的尝试都已被硬编码常量 5 替换;无论对变量进行任何进一步的更改/更新,它都将始终返回 5。

enter image description here

16: before the update
42: after the update

没有字段访问权限,但只是“是的,肯定是 5,返回它”。这意味着 final 字段并不总是保证可以从外部线程正确看到,即使遵循了所有协议。

这会影响原语和字符串。我知道这是一个不寻常的场景,但它仍然是可能的。


其他一些有问题的场景(一些也与评论中引用的同步问题有关):

1-如果没有正确使用反射操作synchronized,线程可能会在以下情况下陷入竞争条件

    final boolean flag;  // false in constructor
    final int x;         // 1 in constructor 
  • 假设反射操作将按以下顺序进行:
  1- Set flag to true
  2- Set x to 100.

读者线程代码的简化:

    while (!instance.flag)  //flag changes to true
       Thread.sleep(1);
    System.out.println(instance.x); // 1 or 100 ?

作为一种可能的情况,反射操作没有足够的时间来更新 x,因此 final int x 字段可能会或不会被正确读取。

2-在以下情况下,线程可能陷入死锁

    final boolean flag;  // false in constructor
  • 假设反射操作将:
  1- Set flag to true

读者线程代码的简化:

    while (!instance.flag) { /*deadlocked here*/ } 

    /*flag changes to true,but the thread started to check too early.
     Compiler optimization could assume flag won't ever change
     so this thread won't ever see the updated value. */

我知道这不是最终字段的特定问题,只是作为这些类型变量的错误读取流的可能情况添加。 最后两个场景只是不正确实现的结果,但想指出它们。

,

是的,这种行为是允许的。

事实证明,在 William Pugh(另一位 JMM 作者)的 personal page 上可以找到对同一案例的详细解释:New presentation/description of the semantics of final fields

简短版本:

  • 17.5.1. Semantics of final Fields of JLS 节定义了最终字段的特殊规则。
    这些规则基本上允许我们在构造函数中的 final 字段的初始化和另一个线程中的字段的读取之间建立额外的happens-before 关系,即使该对象是通过数据竞争发布的。
    这个额外的happens-before 关系要求从字段初始化到它在另一个线程中读取的每个路径都包含一个特殊的动作链:

    w  ʰᵇ ► f  ʰᵇ ► a  ᵐᶜ ► r1 ᵈᶜ ► r2,where:
    • w 是对构造函数中最后一个字段的写入
    • f 是“冻结动作”,在构造函数退出时发生
    • a 是对象的发布(例如,将其保存到共享变量)
    • r₁ 是对不同线程中对象地址的读取
    • r₂ 是在与 r₁ 相同的线程中读取最终字段。
  • 问题中的代码具有从 o.f1 = 42k = r2.f1; 的路径,其中不包括所需的 freeze o.f 操作:

    o.f1 = 42  ʰᵇ ► { freeze o.f is missing }  ʰᵇ ► p = o  ᵐᶜ ► r1 = p  ᵈᶜ ► k = r2.f1

    因此,o.f1 = 42k = r2.f1 的顺序不是先发生的 ⇒ 我们有数据竞争,k = r2.f1 可以读取 0 或 42。

来自New presentation/description of the semantics of final fields的引用:

为了确定对最终字段的读取是否能保证看到该字段的初始化值,您必须确定无法构造偏序 ᵐᶜ ► 和 ᵈᶜ ► 不提供链 w ʰᵇ f ʰᵇ a ᵐᶜ r₁ ᵈᶜ r₂ 从字段的写入到该字段的读取。

...

p 的线程 1 中的写入和线程 2 中的读取都涉及到一个内存链。 q 的线程 1 中的写入和线程 2 中的读取也涉及内存链。 f 的两次读取都看到相同的变量。从 f 的读取到 p 的读取或 q 的读取可能存在取消引用链,因为这些读取看到相同的地址。如果取消引用链来自 p 的读取,则不能保证 r5 会看到值 42。

请注意,对于线程 2,尊重链顺序 r2 = p ᵈᶜ r5 = r4.f,但 顺序 r4 = q ᵈᶜ ► r5 = r4.f。这反映了这样一个事实,即允许编译器将对象 o 的最终字段的任何读取移动到该线程内 o 的地址的第一次读取之后。

,

该行为在 17.5 中被该条款允许:

允许编译器将 var ans = from a in dbo.Group join b in dbo.SubGroupActivity on a.GroupID equals b.GroupID group new { a,b } by a.GroupID into abg let TotalRaised = abg.Sum(ab => ab.b.AmountRaised) where TotalRaised > 0 select new { GroupID = abg.Key,Number = abg.Select(ab => ab.b.GroupName).Distinct().Count(),TotalRaised }; 字段的值缓存在寄存器中,并且在必须重新加载非 final 字段的情况下不会从内存中重新加载它

“最终冻结”发生在构造函数的末尾,从那时起,所有读取都保证是准确的。但是,如果对象被不安全地发布,那么另一个线程可以 (1) 读取字段 final,这是未初始化的,并且 (2) 假设因为 o 是最终的,它永远不会改变,因此永久缓存该值而无需重新读取它。

,

停下。引用。 JMM。

JMM 不适合我和你,它适合真正知道自己在做什么的人,例如 JVM 编译器编写者。你是其中之一吗?我是其中之一吗?我不这么认为,因此远离它。在那里,我已经说过了。

很有趣的是,您自己通过 JLS 中的正确引用回答了这个问题:

当一个对象的构造函数完成时,它被认为是完全初始化的。在对象完全初始化之后,可以只能看到对该对象的引用的线程可以保证看到该对象的正确初始化最终字段

就是这样。它明确说明什么是正确,什么是预期结果。其他一切都没有记录,因此未定义,因此“欢迎来到未知领域。祝您有美好的一天”。所以是的,只需排除不可能的(或由 JLS 保证)即可。

编辑

走吧,这会很长。我们需要从 JLS here 中查看某个规则:

给定一个写 w,一个冻结 f,一个动作 a(不是读最后一个字段),读一个被 f 冻结的最后一个字段的 r1,和一个读 r2,使得 hb(w,f)hb(f,a)mc(a,r1)dereferences(r1,r2) ,那么在确定r2可以看到哪些值时,我们考虑hb(w,r2)

很多,但随着我们的发展,应该会慢慢变得有意义。我承认我从来没有用 final 字段做过这个练习。

我将从 Thread 1Thread 3 开始。很明显,Thread 1 中的所有这些操作都形成了一个 happens-before 链,因为明显的“程序顺序”:

o.f1 = 42;
p = o;
freeze o.f1;
q = o;

所以我们有:

   (hb)                   (hb)
w ------> freeze,freeze ------> q

如果你看上面的引用,我们满足两个条件:hb(w,f)hb(f,a),即:我们通过 w 进行写入 (o.f1 = 42),通过 freeze o.f1 进行冻结,并且通过 {hb(f,a) 满足第二个条件 (q = o) {1}}。

接下来我们需要确定的是mc(a,r1)。为此,我们需要涉及 Thread 3,它会:

r3 = q;
j = r3.f1;

因此,我们可以说“action a”(来自同一个引用)是一个,而r1(来自mc(a,r1))是一个,通过 r3 = q;。同一章说到memory chain

如果 r 是读看到写 w,那么 mc(w,r) 一定是这种情况。

完全符合我们上面的描述。因此,到目前为止,我们有:

      (hb)                       (hb)
   w ------> freeze --> freeze ------> q --> mc(w,r1).

现在我们需要看看那个 dereferences(r1,r2)。我们再次回到同一章:

解引用链:如果操作 a 是未初始化的线程 t 读取或写入对象 o 的字段或元素...

Thread 3 是否初始化了 q?不(这很好)。如果你阅读了这句话的后半部分(至少在我的理解中),我们也满足了这个规则。因此:

      (hb)          (hb)     (mc)       (dereferences)
   w ------> freeze -----> a ------> r1 ----------------> r2

因此(根据相同的初始报价):

   hb(w,r2).

读作“不可能发生数据竞争”。因此,Thread 3 唯一可以读取的是 42,因为读取要么看到在订单之前发生的最新写入,要么任何其他写入 .


如果您将其推断为 Thread 1Thread 2,您会立即看到 freeze 操作缺失 - 您甚至无法开始构建这样的链。因此:数据竞争,因此它可以读取任何其他值。但实际上它可以读取 042,因为 java 不允许“凭空而来”的值。

相关问答

错误1:Request method ‘DELETE‘ not supported 错误还原:...
错误1:启动docker镜像时报错:Error response from daemon:...
错误1:private field ‘xxx‘ is never assigned 按Alt...
报错如下,通过源不能下载,最后警告pip需升级版本 Requirem...