关于线程安全,最终字段真的有用吗?

几年来,我每天都在使用Java内存模型。我认为我对数据竞赛的概念以及避免它们的不同方法(例如,同步块,易失性变量等)有很好的理解。但是,对于内存模型,我仍然没有完全理解一些东西,这就是类的最终字段应该是线程安全的,而无需任何进一步的同步。

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

特别是,标准(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的访问。如果我们不这样做,我们甚至不能保证“reader()”能够看到初始化的“f”,也就是说,由于我们尚未同步对“f”的访问,因此读者可能会看到“null”而不是由编写器线程构造的对象。这个问题在 http://www.cs.umd.edu/~pugh/java/memoryModel/jsr-133-faq.html#finalWrong 中有所说明,这是Java内存模型的主要参考之一[粗体强调我的]:

现在,说了这么多,如果在线程构造了一个不可变对象(即仅包含最终字段的对象)之后,您希望确保所有其他线程都能正确看到它,则通常仍需要使用同步。例如,没有其他方法可以确保第二个线程可以看到对不可变对象的引用。程序从最终字段获得的保证应该仔细地调整,并深入了解如何在代码中管理并发性。

因此,如果我们甚至不能保证看到对“f”的引用,因此我们必须使用典型的同步机制(易失性,锁等),并且这些机制已经导致数据竞争消失,那么对final的需求是我甚至不会考虑的事情。我的意思是,如果为了使“f”对其他线程可见,我们仍然需要使用易失性或同步块,并且它们已经使内部字段对其他线程可见......首先使字段最终确定的意义(在线程安全术语中)是什么?


答案 1

我认为您误解了JLS示例旨在显示的内容:

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

此代码不保证 调用 的线程将看到 的最新值。但它说的是,如果你确实看到非空,那么保证是......尽管我们实际上没有进行任何显式同步。freader()ff.x3

那么,构造函数中 finals 的这种隐式同步有用吗?当然是...国际 海事 组织。这意味着我们不需要在每次访问不可变对象的状态时都执行任何额外的同步。这是一件好事,因为同步通常需要缓存直读或直写,这会降低程序的速度。

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


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

同步获取引用和同步使用引用是有区别的。第一个我可能只需要做一次。第二个我可能需要做很多次...具有相同的引用。即使是一对一,我仍然将同步操作的数量减少了一半......如果我(假设)将不可变对象实现为线程安全。


答案 2

TL;DR:大多数软件开发人员应该忽略有关 Java 内存模型中最终变量的特殊规则。他们应该遵守一般规则:如果一个程序没有数据竞争,那么所有执行都将在顺序上保持一致。在大多数情况下,最终变量不能用于提高并发代码的性能,因为 Java 内存模型中的特殊规则会为最终变量带来一些额外的成本,这使得在几乎所有用例中,易失性都优于最终变量。

在某些情况下,有关最终变量的特殊规则可防止最终变量显示不同的值。但是,在性能方面,该规则无关紧要。


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

关于 Java 内存模型中最终变量的特殊规则以某种方式暗示,如果成员变量是最终变量,或者不是最终变量,它对 Java VM 和 Java JIT 编译器会有所作为。

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

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

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

这意味着最终变量的初始化类似于易失性变量的写入。它暗示了某种内存释放障碍。但是,从引用的注释中可以看出,最终变量可能更昂贵。更糟糕的是,无论最终变量是否在并发代码中使用,您都需要这些额外的成本。

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


问题仍然存在:是否有任何用例,其中有关最终变量的特殊规则有助于提高并发代码的性能?

这很难说,因为它取决于Java VM的实际实现和机器的内存架构。直到现在我还没有看到任何这样的用例。快速浏览一下java.util.concurrent包的源代码也没有任何发现。

问题是:最终变量的初始化与写入易失性原子变量一样昂贵。如果使用可变变量作为新创建对象的引用,则会得到相同的行为和开销,但有一个例外,即引用也将立即发布。因此,使用最终变量进行并发编程基本上没有任何好处。