Java最终字段:当前JLS的“污点”行为是否可能

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

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

这篇论文包含了一个让我感兴趣的示例:如果一个具有最终字段的对象对另一个线程可见两次:ot

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

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

以下是本文的部分内容:

图 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 中的 读取情况如何?这是否保证看到最终字段的正确值?编译器可以确定这一点并指向同一对象,因此对两者和该线程重用相同的值。我们希望允许编译器尽可能删除最终字段的冗余读取,因此我们允许看到值 0。q.f1pqp.f1q.f1k

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

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

当对象的构造函数完成时,将被视为已完全初始化。如果线程只能在对象完全初始化后才能看到对该对象的引用,则保证可以看到该对象的最终字段的正确初始化值。

在当前的JLS中是否允许这样的行为?


答案 1

是的,这是允许的。

主要公开在已经引用的部分:JMM

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

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

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

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


如果。。。 reflection

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

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

当对象的构造函数完成时,将被视为已完全初始化。如果线程只能在对象完全初始化后才能看到对该对象的引用,则保证能够看到该对象的最终字段的正确 [赋值/加载/设置] 值

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

另一个恰当的说法是来自chrylis的那个 - 谨慎的乐观主义 -

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


JLS 17.5 最终字段语义指出:

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

但是,你认为反思会对此给出一个f***吗?不,当然不是。它甚至没有读过那一段。

最终字段的后续修改

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

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

最终字段的冻结既发生在设置最终字段的构造函数的末尾,也发生在每次通过反射修改最终字段之后

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

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

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

如果在字段声明中将最终字段初始化为常量表达式 (§15.28),则可能无法观察到对最终字段的更改,因为在编译时,该最终字段的使用将替换为常量表达式的值。

这很能说明问题:即使遵循了所有规则,如果该变量是基元或 String,并且您在字段声明中将其初始化为常量表达式,则您的代码也无法正确读取字段的赋值。为什么?因为该变量只是编译器的硬编码值,因此即使您的代码在运行时执行中正确更新了该值,编译器也不会再次检查该字段及其更改。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

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

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

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

enter image description here

555111L 都推送到堆栈中,字段获取其新分配的值。但是,在操纵它们时会发生什么,例如打印它们的价值?

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

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

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 ?

作为可能的情况,反射操作没有足够的时间来更新 ,因此可能会或不能正确读取字段。xfinalint 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. */

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


答案 2

是的,允许此类行为。

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

简短版本:

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

    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

    因此,并且不以发生在之前排序⇒我们有一个数据竞赛,可以读取0或42。o.f1 = 42k = r2.f1k = r2.f1

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

为了确定最终字段的读取是否保证看到该字段的初始化值,您必须确定没有办法构造偏序 mc ► 和 dc ► 而不提供链 hb hb mc dc ► 从字段的写入到该字段的读取。wfar₁r₂

...

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

请注意,对于线程 2,服从链排序为 dc ► ,但不排序 dc ► 。这反映了这样一个事实,即允许编译器将对象的最终字段的任何读取移动到该线程内地址的第一次读取之后。r2 = pr5 = r4.fr4 = qr5 = r4.foo