13、Java并发性和多线程-Java Volatile关键字

以下内容转自http://tutorials.jenkov.com/java-concurrency/volatile.html(使用谷歌翻译):

Java volatile关键字用于将Java变量标记为“存储在主存储器”中。更准确地说,这意味着,每个读取volatile变量将从计算机的主存储器中读取,而不是从CPU缓存中读取,并且每个写入volatile变量的写入将被写入主存储器,而不仅仅是写入CPU缓存。

实际上,由于Java 5的volatile关键字保证不仅仅是volatile变量被写入和从主内存读取。我将在以下各节中解释一下。

Java volatile可见性保证

Java volatile关键字可确保跨线程对变量的更改的可见性。这可能听起来有点抽象,所以让我详细说明一下。

出于性能原因,线程在非volatile变量上运行的多线程应用程序中,每个线程可能会将变量从主存储器复制到CPU高速缓存中。如果你的计算机包含多个CPU,则每个线程可能在不同的CPU上运行。这意味着每个线程都可以将变量复制到不同CPU的CPU缓存中。这在这里说明了:

对于非volatile变量,不能保证Java虚拟机(JVM)将数据从主存储器读取到CPU高速缓存中,或者将数据从CPU缓存写入主存储器。这可能会导致几个问题,我将在以下部分中解释。

想象一下,两个或多个线程可以访问共享对象的情况,该对象包含一个如下所示的计数器变量:

public class SharedObject {

    public int counter = 0;

}

想象一下,只有线程1增加counter变量,但线程1和线程2都可能对counter不时读取变量。

如果counter未声明变量,volatile则不能保证将counter变量的值从CPU缓存写回主存储器。这意味着counter在CPU缓存中的变量值可能与主内存不一样。这种情况在这里说明:

没有看到变量的最新值,因为还没有被另一个线程写回到主内存的线程的问题被称为“可见性”问题。一个线程的更新对其他线程是不可见的。

通过声明counter变量,对变量的volatile所有写入counter将立即写回主内存。此外,counter变量的所有读取将直接从主存储器读取。下面是如何volatile在声明counter 变量的样子:

public class SharedObject {

    public volatile int counter = 0;

}

因此, 声明一个volatile变量可以保证该变量的其他写入线程的可见性。

Java volatile事件保证

由于Java 5的volatile关键字不仅仅保证了对变量的主内存的读取和写入。实际上,volatile关键字保证:

  • 如果线程A写入volatile变量和线程B随后读取相同的volatile变量,然后看到线程A的所有变量之前写volatile变量,也将是可见的线程B后,它已经读volatile变量。 

  • volatile变量的读写指令不能被JVM重新排序(只要JVM从重新排序中没有检测到程序行为的变化,JVM可能会因为性能原因重新排序指令)。之前和之后的指令可以重新排序,但是这些指令不能混合写入或写入。无论读取还是写入volatile变量,任何指令都将保证在读取或写入后发生。

这些陈述需要更深入的解释。

当一个线程写入一个volatile变量时,不仅将volatile变量本身写入主存储器。在写入volatile变量之前,线程更改的所有其他变量也被刷新到主存储器。当一个线程读取一个volatile变量时,它也将从主存储器中读取与volatile变量一起刷新到主存储器的所有其他变量。

看这个例子:

Thread A:
    sharedObject.nonVolatile = 123;
    sharedObject.counter     = sharedObject.counter + 1;

Thread B:
    int counter     = sharedObject.counter;
    int nonVolatile = sharedObject.nonVolatile;

由于线程A在写入volatile变量sharedObject.counter之前写入非volatile变量sharedObject.nonVolatile,所以当线程A写入sharedObject.counter(volatile变量)时,sharedObject.nonVolatile和sharedObject.counter都将写入主内存。

由于线程B从读取volatile的sharedObject.counter开始,所以sharedObject.counter和sharedObject.nonVolatile都从主内存读取到线程B使用的CPU高速缓存。当线程B读取sharedObject.nonVolatile时,它会看到值由线程A写。

开发人员可以使用这种扩展的可见性保证来优化线程之间变量的可见性。而不是声明每个变量volatile,只需要声明一个或几个变量volatile。这是一个简单的Exchanger类的例子:

public class Exchanger {

    private Object   object       = null;
    private volatile hasNewObject = false;

    public void put(Object newObject) {
        while(hasNewObject) {
            //wait - do not overwrite existing new object
        }
        object = newObject;
        hasNewObject = true; //volatile write
    }

    public Object take(){
        while(!hasNewObject){ //volatile read
            //wait - don't take old object (or null)
        }
        Object obj = object;
        hasNewObject = false; //volatile write
        return obj;
    }
}

线程A可能会通过调用put()来不时地设置对象。线程B可能会通过调用take()来不时地获取对象。只要线程A调用put()并且只有线程B调用take(),这个Exchanger可以使用volatile变量(不使用同步块)来正常工作。

但是,如果JVM可以在不改变重新排序的指令的语义的情况下,JVM可以重新排序Java指令来优化性能。如果JVM切换的读取和顺序写入里面会发生什么,put()take()?如果put()真的执行如下:

while(hasNewObject) {
    //wait - do not overwrite existing new object
}
hasNewObject = true; //volatile write
object = newObject;

注意,在实际设置新对象之前,对volatile变量hasNewObject的写入将被执行。对于JVM,这可能看起来完全有效。两个写入指令的值不依赖于彼此。

但是,重新排序指令执行会损害对象变量的可见性。首先,线程B可能会在线程A实际上为对象变量写入一个新值之前看到hasNewObject设置为true。第二,现在甚至不能保证写入对象的新值将被刷新回主内存(以下是线程A在某处写入volatile变量的情况)。

为了防止上述情况发生,volatile关键词带有“在保证之前发生”。在保证之前发生的事件保证了易失性变量的读写指令无法重新排序。之前和之后的指令可以重新排序,但是无法通过在其之前或之后发生的任何指令来重新排序易失性读/写指令。

看这个例子:

sharedObject.nonVolatile1 = 123;
sharedObject.nonVolatile2 = 456;
sharedObject.nonVolatile3 = 789;

sharedObject.volatile     = true; //a volatile variable

int someValue1 = sharedObject.nonVolatile4;
int someValue2 = sharedObject.nonVolatile5;
int someValue3 = sharedObject.nonVolatile6;

只要所有这些指令都发生在易失性写入指令之前(它们必须在易失性写入指令之前都必须执行),JVM可能会重新排序前3个指令。

类似地,只要在所有这些指令之前发生易失性写入指令,JVM可以重新排序最后3条指令。在易失性写入指令之前,最后3条指令都不能重新排序。

这基本上是Java保护之前发生的volatile的意思。

volatile并不总是足够

即使volatile关键字保证volatile变量的所有读取都直接从主存储器读取,并且对volatile变量的所有写入都直接写入主存储器,仍然存在声明volatile变量还不够的情况。

在前面所述的情况下,只有线程1写入共享counter变量,声明counter变量volatile就足以确保线程2总是看到最新的写入值。

事实上,volatile如果写入变量的新值不依赖于其先前的值,多线程甚至可能写入一个共享变量,并且仍然具有存储在主存储器中的正确值。换句话说,如果一个向共享volatile变量写值的线程首先不需要读取它的值来找出它的下一个值。

一旦线程需要首先读取volatile变量的值,并且基于该值为共享volatile变量生成新值,则变量volatile不再足以保证正确的可见性。在读取volatile变量和写入新值之间的短时间间隙创建了一个竞争条件 ,其中多个线程可能读取相同的volatile变量值,为变量生成一个新值,并将该值写回到主内存-覆盖彼此的值。

多线程增加相同计数器的情况正是这种情况,其中volatile变量不够。以下部分将更详细地解释这一情况。

想象一下,如果线程1将counter值为0的共享变量读入其CPU缓存,将其递增到1,而不是将更改的值写入主存储器。线程2然后可以从counter变量的值仍然为0的主存储器读取相同的变量到自己的CPU缓存中。线程2也可以将计数器递增到1,也不会将其写回主存储器。这种情况如下图所示:

线程1和线程2现在实际上不同步。共享counter变量的实际值应为2,但每个线程的CPU缓存中的变量的值为1,主内存中的值仍为0。这是一个混乱!即使线程最终将共享counter变量的值写回到主内存中,该值也将是错误的。

什么时候使用呢?

如前所述,如果两个线程都是共享变量的读取和写入,则使用volatile关键字是不够的。 在这种情况下,你需要使用synchronized来保证变量的读写是原子的。读取或写入volatile变量不阻止线程读取或写入。为了实现这一点,你必须在关键部分周围使用synchronized关键字。

作为synchronized块的替代,你还可以使用java.util.concurrent包中发现的许多原子数据类型之一。例如,AtomicLong或 AtomicReference其他人之一。

如果只有一个线程读写volatile变量的值,并且其他线程只读取变量,则读取线程将被保证看到写入volatile变量的最新值。在不变量变动的情况下,这不能保证。

volatile关键字保证在32位和64变量上工作。

性能考虑波动

读写volatile变量会导致变量被读取或写入主存储器。读取和写入主内存比访问CPU缓存更昂贵。访问volatile变量还可以防止指令重新排序,这是正常的性能增强技术。因此,当你真正需要强制实现变量的可见性时,你应该只使用volatile变量。

原文地址:https://www.cnblogs.com/EasonJim/p/7023580.html