当前位置: 首页 > 知识库问答 >
问题:

Java最终字段:当前JLS是否可能出现“污染”行为

郁博学
2023-03-14

我目前正在努力理解JLS关于最终字段的部分。

为了更好地理解JLS中的文本,我还阅读了杰里米·曼森(JMM的创建者之一)的《Java内存模型》。

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

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

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

以下是本文的部分:

图7.3:简单最终语义示例

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

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重用相同的值。我们希望允许编译器尽可能删除对final字段的冗余读取,因此我们允许k查看值0。

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

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

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

当前JLS中是否允许此类行为?

共有3个答案

曾泳
2023-03-14

第17.5条允许该行为:

允许编译器将final字段的值缓存在寄存器中,并且在必须重新加载非final字段的情况下,编译器不会从内存中重新加载它

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

彭俊智
2023-03-14

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

事实证明,在William Pugh(另一个JMM作者)的个人页面上可以找到对同一案例的详细解释:最终领域语义学的新呈现/描述。

简短版本:

>

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

    w 
       
         ʰᵇ 
       ► f 
       
         ʰᵇ 
       ► a 
       
         ᵐᶜ 
       ► r1 
       
         ᵈᶜ 
       ► r2, where:
    • w是对构造函数中最后一个字段的写入
    • f是“冻结操作”,在构造函数退出时发生
    • a对象的发布(例如,将其保存到共享变量)
    • r₁是在不同线程中读取对象的地址
    • r₂是对r所在线程中最后一个字段的读取₁

    问题中的代码具有从o.f1=42k=r2的路径。f1 不包括所需的冻结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。

    最后领域的语义学的新介绍/描述中的一句话:

    为了确定最终字段的读取是否保证看到该字段的初始化值,必须确定无法构造偏序

    ...

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

    请注意,对于线程2,差异链顺序为 r2=p

  • 关胜
    2023-03-14

    是的,这是允许的。

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

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

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

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

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

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

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

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

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

    另一个恰当的说法是chrylis的-谨慎乐观-:

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

    JLS 17.5最终字段语义声明:

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

    但是,你认为反思对此有什么影响吗?不,当然不是。它甚至没有读过那一段。

    最终字段的后续修改

    这些语句不仅正确,而且得到JLS的支持。我不打算反驳他们,只是补充一些关于这条定律例外的额外信息:反思。这种机制可以在初始化后更改最终字段的值。

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

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

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

    让我们想象一下,所有正确的流都得到了尊重,构造函数已经初始化,实例中的所有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
    

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

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

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

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

    两个555111L都被推送到堆栈中,字段得到它们新分配的值。但是当操纵它们时会发生什么,比如打印它们的值?

    >

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

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

    16: before the update
    42: after the update
    

    没有字段访问,但只是一个“是的,那是5肯定的,返回它”。这意味着即使遵循了所有协议,也不能保证从外线程正确地看到最终字段。

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

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

    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. */
    

    我知道这不是最终字段的具体问题,但只是添加了这些类型的变量的不正确读取流的可能情况。最后两个场景只是不正确实现的结果,但希望指出它们。

  •  类似资料:
    • 问题内容: 是否有任何条件最终可能无法在Java中运行?谢谢。 问题答案: 注意:如果在执行try或catch代码时JVM退出,则finally块可能不会执行。同样,如果执行try或catch代码的线程被中断或杀死,即使整个应用程序继续运行,finally块也可能不会执行。 我不知道finally块无法执行任何其他方式…

    • 定义不可变类的策略表明 所有字段都应该是最终字段。 对于ex: 为什么一定要最终决定? 因为我没有给出setter方法吗?它不能改变。谢谢。

    • 在可能的副本上: 此线程不是在询问如何扩展类。它问为什么一个声明为的类可能会扩展另一个类。 从该线程: <code>final</code>类只是一个不能扩展的类。 但是,我有一个帮助程序类,我声明它是,并了另一个类: Eclipse没有检测到任何错误。我已经测试了这个类,并且PDF是成功生成的,没有错误。 为什么我能够课程,而理论上我不应该延长? (如果重要的话,我正在使用Java7。)

    • 问题内容: 我知道在Java 7中使用带有泛型类型的varargs时会发生这种情况。 但是我的问题是.. Eclipse说“使用它可能会污染堆”时,这到底是什么意思? 和 新注释如何防止这种情况? 问题答案: 堆污染是一个技术术语。它引用的引用类型不是其指向的对象的超类型。 这可能会导致“无法解释” 。 @SafeVarargs完全不能阻止这一点。但是,有些方法证明不会污染堆,编译器无法证明这一点

    • 我知道Java 7在使用泛型类型的varargs时会出现这种情况; 但我的问题是.. 当Eclipse说“它的使用可能会潜在地污染堆”时,它到底是什么意思? 而且 新的注释如何防止这种情况发生?

    • 问题内容: 让我们从一个简单的测试用例开始: 任何人都在乎猜测什么将作为输出打印(在底部显示,以免立即破坏惊喜)。 问题是: 为什么原始和包装的整数表现不同? 为什么反射访问与直接访问返回不同的结果? 最困扰我的人-为什么String表现得像原始的而不是像? 结果(java 1.5): 问题答案: 内联编译时常量(在javac编译时)。参见JLS,尤其是15.28定义了常量表达式,而13.4.9讨