Java final字段:当前JLS是否可能存在“污点”行为?

15
我目前正在尝试理解这个JLS关于final字段的部分
为了更好地理解JLS中的文本,我也在阅读Jeremy Manson(JMM的创建者之一)的Java内存模型
该论文包含了让我感兴趣的示例:如果一个具有final字段的对象o在构造函数完成之前不正确地向另一个线程t公开两次:
首先,在o的构造函数完成之前“不正确地”;
接下来,在o的构造函数完成之后“正确地”。
然后,即使只通过“正确地”发布的路径访问,t也可以看到半构造的o。
以下是论文的一部分:

Figure 7.3: Example of Simple Final Semantics

f1 is a final field; its default value is 0

Thread 1 Thread 2 Thread 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;



We assume r1, r2 and r3 do not see the value null. i and k can be 0 or 42, and j must be 42.


Consider Figure 7.3. We will not start out with the complications of multiple writes to final fields; a freeze, for the moment, is simply what happens at the end of a constructor. Although r1, r2 and r3 can see the value null, we will not concern ourselves with that; that just leads to a null pointer exception.

...

What about the read of q.f1 in Thread 2? Is that guaranteed to see the correct value for the final field? A compiler could determine that p and q point to the same object, and therefore reuse the same value for both p.f1 and q.f1 for that thread. We want to allow the compiler to remove redundant reads of final fields wherever possible, so we allow k to see the value 0.

One way to conceptualize this is by thinking of an object being “tainted’ for a thread if that thread reads an incorrectly published reference to the object. If an object is tainted for a thread, the thread is never guaranteed to see the object’s correctly constructed final fields. More generally, if a thread t reads an incorrectly published reference to an object o, thread t forever sees a tainted version of o without any guarantees of seeing the correct value for the final fields of o.

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

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

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


1
我不明白。你找到了能够强制正确行为的引用,其他任何情况都意味着规则没有被遵守。这直接回答了你的问题。 - Eugene
我看到了一些错别字,使得阅读这份文档有点复杂。i = r.f1; 应该真的是 i = r1.f1; 而不是 i = r.f1;if (r2 == r) 应该是 if (r2 == r1)。还有 freeze o.f - f 是什么?应该是 f1 吧?我还假设他所说的 "freeze" 意味着适当的内存屏障?然后:"Thread 2 中对 q.f1 的读取怎么样?" 它不是 q.f1,而是 r2.f1 - Eugene
@Eugene 我修正了拼写错误。 “冻结操作”来源于JLS。 在我看来,q.f1 是没有问题的:它意味着我们通过共享变量 q 访问 o.f1(就像在 JLS 中的 r1,r2,r3 是局部变量,i,j,k,p,q 是共享变量一样)。 - user15094989
我并不是说 q.f1 不好,但你实际上在哪里看到它的呢?真正的读取应该是 r2.f1 - Eugene
@Eugene Thread 2 中 q.f1 的读取怎么样? 是来自 Jeremy Manson 的 Java 内存模型 的一句引用。 - user15094989
4个回答

8

是的,允许这样做。

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

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

什么意思是一个正确地构造的对象?它简单地意味着在构造过程中不允许任何对正在构造的对象的引用“泄漏”

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

所以是的,只要符合规定,就可以这样做。最后一段充满了如何不做某些事情的建议;每当有人说要避免做X时,就隐含着X是可以做的。


如果... 反射

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

最经常重复的规则/短语可能是这个,在这里抄袭自Eugene顺便说一句,该回答不应该有任何负面评价):

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

请注意,我用"initialized"这个术语替换了相应的术语assigned、loaded或set。这是有目的的,因为术语可能会误导我的观点。

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

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

JLS 17.5 final Field Semantics 表示:

一个只能在对象完全初始化后看到对象引用的线程保证可以看到该对象的 final 字段正确初始化的值。

但是,你认为反射会关心这个吗?当然不会。它甚至都没有读那一段话。

final 字段的后续修改

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

一个final字段的结构冻结发生在设置该final字段的构造函数结束时,这是完全正确的。但是还有另一个触发结构冻结操作的方法没有被考虑到:通过反射初始化/修改字段也会导致final字段结构冻结(JLS 17.5.3):

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

假设已经遵循了所有正确的流程,构造函数已被初始化,并且线程正确地查看了实例中的所有final字段。现在是通过反射对这些字段进行一些更改的时候了(想象一下这是必要的,即使不寻常,我知道...)。
遵循了上述规则,并且所有线程等待直到所有字段都已更新:就像通常的构造函数情况一样,在字段被冻结并且反射操作正确完成后才可以访问这些字段。 这就是法律被打破的地方

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

这是在说明:即使遵循了所有规则,如果该变量是一个原始类型或字符串,并且在字段声明中初始化为常量表达式,则您的代码将无法正确读取final字段的分配值。为什么?因为对于编译器来说,该变量只是一个硬编码的值,它不会再次检查该字段或其更改,即使您的代码在运行时正确更新了该值。 < p > 因此,让我们进行测试:

 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);
      }    
 }

Output:

 5
 1
 -
 5   
 111

最终的整型变量i在运行时被正确更新,您可以通过调试和检查对象的字段值来验证它:

enter image description here

两个字段il都已经正确更新。那么i发生了什么,为什么还显示5?因为根据JLS所述,字段i在编译时直接替换为常量表达式的,在这种情况下是5

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

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

enter image description here

555111L 都推入堆栈,并使字段获得新赋值。但是当操作它们时,例如打印它们的值会发生什么?

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

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

enter image description here

16: before the update
42: after the update

没有字段访问,只有一个“是的,这肯定是5,返回它”。这意味着一个final字段并不总是能够从外部线程正确地被看到,即使所有协议都被遵循。
这会影响原始类型和字符串。我知道这是一个不寻常的情况,但它仍然是可能的。

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

1- 如果没有正确地与反射操作进行同步,线程可能会在以下情况下陷入竞态条件

    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”,所以最终的“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. */

我知道这不是关于final字段的具体问题,只是作为这些类型变量的错误读取流程的可能情况之一。这最后两种情况只是由于不正确的实现而导致的结果,但我想指出这一点。

1
谢谢。我猜 https://www.cs.umd.edu/~pugh/java/memoryModel/ 上的信息至少对于第一版JMM是有效的。而且自第一版以来,JMM并没有发生太多(甚至完全没有)变化。因此,它应该对当前的JLS有效。 - user15094989
1
@jyoxbffz 我也相信;无论如何,如果我在最新版本中发现一些不一致之处,我会进行更新。希望这对你有所帮助,伙计。 - aran
1
@aran 非常有趣。您基本上在实践中测试了JLS的第17.5.3节。final字段的后续修改,并发现其中提到的“常量表达式复杂化”确实会在现代JVM中发生。 - user15094989
1
@jyoxbffz 我知道这个回答并没有像其他给出的答案那样专注于你的问题,但希望它能提供一些额外的背景信息,或者至少让你觉得有趣。感谢你的留言:) - aran
1
这曾经被Shenandoah GC利用(通过一个标志)来绕过在final字段周围插入任何GC屏障的限制,但这已经被移除了(因为有多少程序遵守了final字段的语义?),其次 - 当加载引用屏障出现在Shenandoah 2.0中时,它已经过时了。我可能会把这作为我的最后一条评论,已经够多了。 - Eugene
显示剩余9条评论

5
是的,这种行为是允许的。 事实证明,关于这个问题的详细解释可以在William Pugh(另一个JMM作者)的个人主页上找到: final fields语义的新演示/描述
简短版本:
  • section 17.5.1. Semantics of final Fields of JLS defines special rules for final fields.
    The rules basically lets us establish an additional happens-before relation between the initialization of a final field in a constructor and a read of the field in another thread, even if the object is published via a data race.
    This additional happens-before relation requires that every path from the field initialization to its read in another thread included a special chain of actions:

    w <s> ʰᵇ </s>► f <s> ʰᵇ </s>► a <s> ᵐᶜ </s>► r<sub>1</sub> <s> ᵈᶜ </s>► r<sub>2</sub></code>, where:</pre>
    <ul>
    <li><code>w</code> is a write to the final field in a constructor</li>
    <li><code>f</code> is "freeze action", which happens when constructor exits</li>
    <li><code>a</code> is a publication of the object (e.g. saving it to a shared variable)</li>
    <li><code>r₁</code> is a read of the object's address in a different thread</li>
    <li><code>r₂</code> is a read of the final field in the same thread as <code>r₁</code>.</li>
    </ul>
    </li>
    <li><p>the code in the question has a path from <code>o.f1 = 42</code> to <code>k = r2.f1;</code> which doesn't include the required <code>freeze o.f</code> action:</p>
    <pre><code>o.f1 = 42 <s> ʰᵇ </s>► { freeze o.f <i>is missing</i> } <s> ʰᵇ </s>► p = o <s> ᵐᶜ </s>► r1 = p <s> ᵈᶜ </s>► k = r2.f1

    As a result, o.f1 = 42 and k = r2.f1 are not ordered with happens-before ⇒ we have a data race and k = r2.f1 can read 0 or 42.

来自final字段语义的新演示/描述的一句引用:
要确定final字段的读取是否保证看到该字段的初始化值,必须确定没有办法构造出部分顺序 ᵐᶜ ►和 ᵈᶜ ►,而不提供链 w ʰᵇ ► f ʰᵇ ► a ᵐᶜ ► r₁ ᵈᶜ ► r₂,从该字段的写入到该字段的读取。
线程1中的写入和线程2中的读取都涉及内存链。线程1中的写入和线程2中的q的读取也涉及内存链。两个对f的读取看到同一个变量。可以从对f的读取到p或q的读取进行解引用链,因为这些读取看到同一个地址。如果解引用链来自于对p的读取,则不能保证r5将看到值42。
注意,在线程2中,解引用链的顺序是r2 = p ᵈᶜ ► r5 = r4.f,但不是r4 = q ᵈᶜ ► r5 = r4.f。这反映了编译器允许将对象o的任何final字段的读取移动到该线程中第一次读取o地址之后。

1

这种行为在17.5条款中被允许:

编译器可以将final字段的值缓存在寄存器中,在需要重新加载非final字段的情况下不必从内存中重新加载。

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


谢谢。但我个人认为短语“在需要重新加载非最终字段的情况下”相当模糊。特别是,我不认为它适用于“final freeze”操作。 - user15094989

0

停止引用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),即:我们通过o.f1 = 42进行写入(w),通过freeze o.f1进行冻结,并且也满足了第二个条件(hb(f,a))通过q=o
接下来我们需要建立mc(a,r1)。为此,我们需要涉及线程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 的字段或元素进行读取或写入,而该线程 t 没有初始化...

Thread 3 是否初始化了 q?没有(这很好)。如果您阅读此引文的后半部分(至少在我的理解中),我们也已经满足了这个规则。因此:

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

因此(根据同一最初的引用):

   hb(w, r2).

这意味着“不可能存在数据竞争”。因此,线程3 只能 读取42,因为读取操作要么看到先于其发生的最新写入,要么看到任何其他写入


如果将此推广到Thread 1Thread 2,您会立即发现缺少freeze操作-您甚至无法开始构建这样的链。因此:作为数据竞争,它可以读取任何其他值。但实际上它只能读取042,因为Java不允许“空气中出现”的值。

JMM 不是为我们这样的人准备的,而是为真正知道自己在做什么的人准备的,比如 JVM 编译器编写者。你是其中之一吗?我是其中之一吗?我不这么认为,因此要远离它。就是这样,我说了。我感同身受,相信我。JMM 太过复杂,即使它的作者也承认这一点(C/C++ 内存模型要简单得多)。但由于我使用 Java 进行软件开发,我想知道即使代码存在错误(例如,代码未正确同步),我应该期望什么。而最可靠的信息来源是 Java 规范。 - user15094989
2
@Eugene 虽然我原则上同意你的观点,但是由于我和大多数人一样都在广泛地使用遗留代码,并且见证了其中的恐怖和误解,我认为对JLS和JMM有相当的了解是一件好事。 - Erik
1
@Erik同意。这不仅仅是“合理的”,我越了解和阅读JLSJMM,就越难以忍受我知道存在于我们代码库中的恐怖。此时此刻,我相当确信,绝大多数开发人员应该只遵循由知名工程师提出的已知最佳实践。 - Eugene
1
@jyoxbffz 在重新阅读了你的问题和那些论文几次之后,我改变了我的看法。这是一个非常好的问题,我没有足够的注意力,对此感到很抱歉。我的道歉以我给出的答案为基础。 - Eugene
@Eugene,你的进展是可见的。根据《final fields语义的新演示/描述》(http://www.cs.umd.edu/%7Epugh/java/memoryModel/may-12.pdf),如果将此推广到Thread 1和Thread 2,您会立即发现缺少冻结操作——您甚至无法开始构建这样的链。实际上,您可以构建正确的链。问题在于,您也可以构建一个“无效”的链,而没有“冻结”——这就是防止w和r2之间发生顺序的原因。 - user15094989
显示剩余2条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接