Java final 字段:当前 JLS 是否可能出现“污点”行为

15 java multithreading final java-memory-model jls

我目前正在尝试了解有关最终字段的 JLS 部分

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

该论文包含让我感兴趣的示例:如果o具有 final 字段的对象对另一个线程可见t两次:

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

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

这是论文中的部分:

图 7.3:简单最终语义示例

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

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

Run Code Online (Sandbox Code Playgroud)
r1 = p;
i = r1.f1;
r2 = q;
if (r2 == r1)
    k = r2.f1;
Run Code Online (Sandbox Code Playgroud)
r3 = q;
j = r3.f1;



Run Code Online (Sandbox Code Playgroud)

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


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

...

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

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

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

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

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

ara*_*ran 7

是的,这是允许的。

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

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

正确构造对象意味着什么?它只是意味着在构造过程中不允许对正在构造的对象的引用“逃逸”

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

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


如果... reflection

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

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

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

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

另一个正确的陈述是来自chrylis -cautiouslyoptimistic- 的陈述

“最终冻结”发生在构造函数的末尾,从那时起,所有读取都保证是准确的。


JLS 17.5 最终字段语义声明:

只有在对象完全初始化后才能看到对对象引用的线程可以保证看到该对象的最终字段的正确初始化值

但是,您认为反思会对此感到不安吗?不,当然不是。它甚至没有读那段。

final字段的后续修改

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

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

final 字段的冻结发生在设置 final 字段的构造函数的末尾,以及在每次通过反射修改 final 字段之后立即发生

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

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

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

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

这说明:即使遵循了所有规则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);
      }    
 }
Run Code Online (Sandbox Code Playgroud)

输出

 5
 1
 -
 5   
 111
Run Code Online (Sandbox Code Playgroud)

最终的 inti在运行时被正确更新,为了检查它,您可以调试和检查对象的字段值:

在此处输入图片说明

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

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

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

在此处输入图片说明

这两个555111L被压入堆栈和领域得到他们的新分配的值。但是在操纵它们时会发生什么,例如打印它们的值?

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

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

在此处输入图片说明

16: before the update
42: after the update
Run Code Online (Sandbox Code Playgroud)

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

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


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

1-如果synchronized反射操作不正确,线程可能会在以下情况下陷入竞争状态

    final boolean flag;  // false in constructor
    final int x;         // 1 in constructor 
Run Code Online (Sandbox Code Playgroud)
  • 让我们假设反射操作将按以下顺序:
  1- Set flag to true
  2- Set x to 100.
Run Code Online (Sandbox Code Playgroud)

阅读器线程代码的简化:

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

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

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

    final boolean flag;  // false in constructor
Run Code Online (Sandbox Code Playgroud)
  • 让我们假设反射操作将:
  1- Set flag to true
Run Code Online (Sandbox Code Playgroud)

阅读器线程代码的简化:

    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. */
Run Code Online (Sandbox Code Playgroud)

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


小智 5

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

事实证明,在William Pugh(另一位 JMM 作者)的个人页面上可以找到对同一案例的详细解释:新的最终字段语义的演示/描述

精简版:

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

    w  ?? ? f  ?? ? a  ?? ? r1  ?? ? r2, 在哪里:
    • 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
    Run Code Online (Sandbox Code Playgroud)

    因此,o.f1 = 42k = r2.f1没有与发生前排序?我们有一个数据竞争,k = r2.f1可以读取 0 或 42。

自新的最终字段语义的表示/描述

为了确定对 final 字段的读取是否能保证看到该字段的初始化值,您必须确定无法构造偏序 ?? ? 和 ?? ? 不提供链w  ?? ? f  ?? ? a  ?? ? r?  ?? ? r?从字段的写入到该字段的读取。

...

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

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