最终字段对于线程安全是否真的有用?

时间:2014-03-30 11:06:13

标签: java multithreading synchronization final

我已经每天使用Java Memory Model工作多年了。我认为我对数据竞争的概念以及避免它们的不同方法(例如,同步块,易变变量等)有很好的理解。但是,我仍然认为我完全不了解内存模型,这是最终类的字段应该是线程安全的,而不需要任何进一步的同步。

所以根据规范,如果一个对象被正确初始化(也就是说,没有引用该对象在其构造函数中以某种方式转义,以便另一个线程可以看到引用),那么,在构造之后,任何线程看到对象将保证看到对象的所有最终字段的引用(在它们构造时的状态),没有任何进一步的同步。

特别是,标准(http://docs.oracle.com/javase/specs/jls/se7/html/jls-17.html#jls-17.4)说:

  

最终字段的使用模型很简单:设置最终字段   对于该对象的构造函数中的对象;并且不要写   引用正在另一个地方构建的对象   线程可以在对象的构造函数完成之前看到它。如果这   跟随,然后当另一个线程看到该对象时,那   线程将始终看到正确构造的版本   对象的最终字段。它还会看到任何对象的版本或   由最终字段引用的数组,这些字段至少是最新的   最后的字段是。

他们甚至给出了以下示例:

class FinalFieldExample { 
    final int x;
    int y; 
    static FinalFieldExample f;

    public FinalFieldExample() {
        x = 3; 
        y = 4; 
    } 

    static void writer() {
        f = new FinalFieldExample();
    } 

    static void reader() {
        if (f != null) {
            int i = f.x;  // guaranteed to see 3  
            int j = f.y;  // could see 0
        } 
    } 
}

其中线程A应该运行" reader()",并且线程B应该运行" writer()"。

到目前为止,显然很好。

我主要担心的是......这在实践中真的有用吗?据我所知,为了使线程A(运行" reader()")看到对" f"的引用,我们必须使用一些同步机制,例如使f易失,或使用锁来同步对f的访问。如果我们不这样做,我们甚至不能保证"读者()"将能够看到一个初始化的" f",也就是说,由于我们没有同步访问" f",读者可能会看到" null"而不是由编写者线程构造的对象。这个问题在http://www.cs.umd.edu/~pugh/java/memoryModel/jsr-133-faq.html#finalWrong中说明,它是Java内存模型的主要参考之一[大胆强调我的]:

  

现在,已经说过所有这些,如果,在一个线程构造之后   不可变对象(即只包含最终字段的对象),   你想确保所有其他人都能正确看到它   线程,你仍然通常需要使用同步。 没有   以其他方式确保,例如,对不可变的引用   第二个线程将看到对象。保证计划   从最后的领域获得应该仔细调整与深刻和   仔细了解代码中如何管理并发。

因此,如果我们甚至不能保证看到对" f"的引用,那么我们必须使用典型的同步机制(易失性,锁定等),这些机制确实已经导致数据竞争走开,对决赛的需求是我甚至不会考虑的。我的意思是,如果是为了制造" f"对于其他线程可见,我们仍然需要使用volatile或synchronized块,并且它们已经使内部字段对其他线程可见...在第一个字段中使字段成为最终的点(在线程安全术语中)是什么放置?

4 个答案:

答案 0 :(得分:10)

我认为您误解了JLS示例的目的:

static void reader() {
    if (f != null) {
        int i = f.x;  // guaranteed to see 3  
        int j = f.y;  // could see 0
    } 
}

此代码不保证调用f的线程会看到reader()的最新值。但它的含义是,如果你确实看到f为非空,那么f.x保证为3 ......尽管事实上我们实际上并没有显式同步。

这对于构造函数中的终结的隐式同步是否有用?当然是...... IMO。这意味着每次访问不可变对象的状态时,我们都不需要需要进行任何额外的同步。这是一件好事,因为同步通常需要缓存读取或直写,这会降低程序的速度。

但Pugh所说的是,通常需要首先同步获取对不可变对象的引用。他指出,使用不可变对象(使用final实现)并不能免除同步的需要......或者需要理解应用程序的并发/同步实现。


  

问题在于我们仍然需要确保读者会得到一个非空的“f”,而这只有在我们使用其他同步机制时才有可能,这种机制已经提供了允许我们为f.x看到3的语义。如果是这样的话,为什么还要使用final作为线程安全的东西呢?

同步获取引用和同步以使用引用之间存在差异。第一个我可能只需做一次。第二个我可能需要做很多次...同样的参考。即使它是一对一的,我仍然减少了同步操作的数量......如果我(假设)将不可变对象实现为线程安全的。

答案 1 :(得分:7)

TL; DR:大多数软件开发人员应该忽略 Java内存模型中有关最终变量的特殊规则。它们应遵循一般规则:如果程序没有数据竞争,则所有执行似乎都是顺序一致。在大多数情况下, final 变量不能用于提高并发代码的性能,因为 Java内存模型中的特殊规则会为 final创建一些额外的成本变量,几乎所有用例的 volatile 优于 final 变量的原因。

关于 final 变量的特殊规则在某些情况下会阻止 final 变量显示不同的值。但是,在性能方面,规则无关紧要。


话虽如此,这里有一个更详细的答案。但我必须警告你。以下描述可能包含一些不稳定的信息,大多数软件开发人员都不应该关心这些信息,如果他们不了解它,那就更好了。

Java Memory Model 中关于 final 变量的特殊规则暗示,如果成员变量是,那么它对Java VM和Java JIT编译器有所不同 final 或者如果不是。

public class Int {
    public /* final */ int value;
    public Int(int value) {
        this.value = value;
    }
}

如果您查看 Hotspot 源代码,您将看到编译器检查类的构造函数是否至少写入一个 final 变量。如果它这样做,编译器将为构造函数发出额外的代码,更确切地说是内存释放障碍。您还可以在源代码中找到以下注释:

  

此方法(必须是Java规则的构造函数)   写了一个决赛。所有初始化的效果必须是   在构造函数之后的任何代码之前提交内存   发布对新构造函数对象的引用。   我们不是等待发布,而是阻止发布   写到这里。而不是只对那些写入设置障碍   这些都需要完成,我们强制完成所有写入。

这意味着 final 变量的初始化类似于写入 volatile 变量。它意味着某种内存释放障碍。但是,从引用的评论中可以看出, final 变量可能更加昂贵。更糟糕的是,无论是否在并发代码中使用它们, final 变量都会产生这些额外成本。

这很糟糕,因为我们希望软件开发人员使用 final 变量来提高源代码的可读性可维护性。不幸的是,使用 final 变量会显着影响程序的性能。


问题仍然存在:是否存在关于 final 变量的特殊规则有助于提高并发代码性能的用例?

这很难说,因为它取决于Java VM的实际实现和机器的内存架构。到目前为止,我还没有看到任何此类用例。快速浏览包 java.util.concurrent 的源代码也没有透露任何内容。

问题是: final 变量的初始化与写入 volatile atomic 变量一样昂贵。如果您使用 volatile 变量来引用新创建的对象,则会获得相同的行为和成本(例外),该引用也将立即发布。因此,使用 final 变量进行并发编程基本上没有任何好处。

答案 2 :(得分:5)

你是对的,因为锁定提供了更强的保证,final s的可用性保证在存在锁定时并不特别有用。但是,并不总是需要锁定才能确保可靠的并发访问。

  

据我所知,为了使线程A(运行“reader()”)看到对“f”的引用,我们必须使用一些同步机制,例如使f volatile,或使用lock同步访问f。

使f volatile不是同步机制;它会强制线程在每次访问变量时读取内存,但不会同步访问内存位置。锁定是一种同步访问的方法,但实际上并不需要保证两个线程可靠地共享数据。例如,您可以使用ConcurrentLinkedQueue<E>类(一个无锁的并发集合 * )将数据从读取器线程传递到写入器线程,并避免同步。您还可以使用AtomicReference<T>来确保在不锁定的情况下可靠地并发访问对象。

当您使用lock-free concurrency时,对final字段可见性的保证会派上用场。如果你创建一个无锁集合,并使用它来存储不可变对象,你的线程将能够访问对象的内容而无需额外的锁定。

* ConcurrentLinkedQueue<E>不仅是无锁的,而且是一个无等待的集合(即无锁集合,其附加保证与此讨论无关)。

答案 3 :(得分:1)

是最终的最终字段在线程安全方面很有用。它可能在您的示例中没有用,但是如果您查看旧的ConcurrentHashMap实现,get方法在搜索值时不会应用任何锁定,尽管存在查找正在查找的风险列表可能会更改(想想ConcurrentModificationException)。然而,CHM使用最终提交的“下一个”字段列表,保证列表的一致性(前面/尚未看到的项目不会增长或缩小)。因此优点是线程安全是在没有同步的情况下建立的。

来自文章

  

利用不变性

     

通过输入可以避免一个重要的不一致来源   元素几乎不可变 - 所有字段都是最终的,除了   值字段,它是易失性的。这意味着元素不可能   添加到哈希链的中间或末尾或从中删除 -   元素只能在开头添加,并且删除涉及   克隆全部或部分链并更新列表头指针。   所以,一旦你有一个哈希链的引用,你可能不知道   无论你是否有对列表头部的引用,你都知道   列表的其余部分不会改变其结构。另外,自从   值字段是易失性的,您将能够看到值的更新   现场立即,大大简化了编写Map的过程   可以处理可能陈旧的内存视图的实现。

     

虽然新的JMM为最终变量提供初始化安全性,   旧的JMM没有,这意味着另一个可能   线程查看最终字段的默认值,而不是   对象的构造函数放置在那里的值。实施   必须准备好检测这一点,它通过确保来做到这一点   Entry的每个字段的默认值不是有效值。   构造列表使得如果出现任何Entry字段   有默认值(零或null),搜索将失败,   提示get()实现同步和遍历   再次链。

文章链接:https://www.ibm.com/developerworks/library/j-jtp08223/