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

Knu*_*dna 20 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
        } 
    } 
}
Run Code Online (Sandbox Code Playgroud)

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

到目前为止,显然是如此的好.

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

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

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

Ste*_*n C 10

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

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

此代码不保证f调用的线程将看到最新值reader().但它所说的是,如果你确实看到f非空,那么f.x保证是3......尽管我们实际上没有做任何明确的同步.

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

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


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

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

  • @KnucklestheEchidna假设您有一个缓存,并且您不关心某个线程是否在一段时间内看到过时的值.你关心的是它应该看到正确的值(陈旧或不陈旧).如果其字段是最终的,则不安全地发布缓存对象将正常工作.如果它的字段不是最终字段,它将不会:一个线程可能会看到一些新值和一些旧值. (6认同)

nos*_*sid 7

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

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


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

关于Java内存模型中的最终变量的特殊规则在某种程度上意味着,如果成员变量是最终成员或者不成功,那么它对Java VM和Java JIT编译器有所不同.

public class Int {
    public /* final */ int value;
    public Int(int value) {
        this.value = value;
    }
}
Run Code Online (Sandbox Code Playgroud)

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

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

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

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


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

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

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

  • 如果您真的担心StoreLoad内存屏障的成本,那么您要么编写极高性能的代码,要么分配数百万个此类对象,要么进行微优化."显然,与易失性变量和原子数据类型相比,最终变量是昂贵的" - 是吧?Volatiles(无论如何都是原子数据类型在内部使用)在每次写入后都需要相同的障碍!任何增加"最终"真正"显着"影响性能的基准/程序? (5认同)

das*_*ght 5

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

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

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

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

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

  • @dasblinkenlight对不起,我不认为我跟着.ConcurrentLinkedQueue在add()/ remove()系列方法中提供了发生之前的语义,因此从队列中放置然后检索对象将"刷新"对象的整个内容,无论其字段是否为final,对? (2认同)