[Java 并发编程] 13. volatile关键字


前言

volatile 关键字用来标记一个 Java 变量被保存至主内存中。更确切的说:每次读取 volatile 变量时会从主内存中读取,而不是从CPU高速缓存中读取;每次写 volatile 变量时会把数据写回主内存中,而不是CPU高速缓存中。

实际上,自 Java 5 起,volatile 关键字不仅仅保证从主内存中读取数据或者写回数据至主内存中。


一、volatile 可见性问题

volatile 关键字保证了当某个线程改变了 volatile 变量的值时对其他线程可见。

在多线程应用系统中,线程操作 non-volatile 变量时,每个线程会拷贝 non-volatile 变量(拷贝引用)至CPU高速缓存中。如果你的计算机包含多个CPU,每个线程在不同的CPU上运行时,这就意味着每个线程拷贝这些变量至不同的CPU的CPU高速缓存中,如下图所示:

在这里插入图片描述

对于 non-volatile 变量,JVM每次读取变量数据时不会保证从主内存中拷贝至CPU高速缓存中,或者每次写数据时不会保证把CPU高速缓存的数据写回至主内存中。

下面我们来看个示例:

public class ShareObject {
    public int count = 0;
}

想象一下,有2个线程,第一个线程对 count 的值做修改,第二个的线程能读取到 count 被修改后的值吗???

由于这个 count 变量没有被 volatile 关键字声明,当2个CPU运行的2个线程分别读取 count 的值时,会从主内存中将count的值拷贝至各自的CPU高速缓存中,其中一个线程修改count的值,由于它没有被volatile声明,因此不会保证被修改后的值写回至主内存中。请看下图:

在这里插入图片描述
由于count变量值被修改后没有写回主内存中,并且线程每次读取count变量值也没有保证从主内存中读取,这个问题被称为数据可见性问题,我们可以通过使用volatile关键字解决数据可见性问题,也可以使用其他方式解决。


二、volatile 可见性保证

volatile 关键字解决了数据可见性问题。通过使用 volatile 关键字声明变量是一个volatile变量,保证了每次读取变量数据时从主内存读取,写数据时把数据写回主内存中。

通过 volatile 声明变量:

public class ShareObject {
    public volatile int count = 0;
}

上面提到的这种情况,当线程一修改了count的值,线程二没有修改count的值,只读取了count的值,这种情况下,使用volatile关键字声明count变量足够保证数据的线程二读取到的是最新的数据。

注意:当存在多个线程对同一个共享变量做修改操作时,使用volatile是远远不够的,volatile关键字只能保证数据可见性,并不能保证数据操作是原子性的,我们可以使用synchronized关键字或者Java提供的JUC工具包。

2.1 所有变量可见性保证

实际上,volatile 关键字的作用超越了 volatile 变量本身,有更多的作用。包括:

  • 当某个线程写回volatile变量的值至主内存时,这个线程可见的所有变量的值都会写回至主内存中
  • 当某个线程从主内存中读取volatile变量的值时,这个线程可见的所有变量的值都会重新从主内存中读取

上面提到的这些内容我们在博客《Java Happen Before Guarantee》提到过,这里不再举例证明。


三、指令重排挑战

JVM和CPU为了提升性能允许指令重排,只要满足语义不变(单线程情况下执行结果不变)即可指令重排。

请看下面示例:

int a = 1;
int b = 2;

a++;
b++;

指令重排后:

int a = 1;
a++;

int b = 2;
b++;

然而,当一个变量被volatile修饰时,存在一些指令重排的挑战。请看示例:

public class MyClass {
    private int years;
    private int months
    private volatile int days;

    public void update(int years, int months, int days){
        this.years  = years;
        this.months = months;
        this.days   = days;
    }
}

一旦执行上面的update()方法,执行到修改days的指令时,years和months修改后的值也会被写回到主内存中。但是如果发生了指令重排,如下所示

public void update(int years, int months, int days){
    this.days   = days;
    this.years  = years;
    this.months = months;
}

同样执行到days修改的指令时,years和months的值也会被写回到主内存中,但此时years和months的值未被修改(指令重排导致years和months修改的指令在days指令下面),写回数据至主内存的操作发生在years和months修改指令的前面,当执行完years和months的修改指令后,不会同步数据至主内存,因此years和months的新值对其他线程不可见(未使用volatile关键字),这样的指令重排导致程序的语义发生了改变。

Java 有解决这个问题,让我们看下一章节。


四、volatile happens-before 保证

为了解决指令重排可能产生的问题,java volatile 关键字除了提供可见性保证之外,还提供提供了happens-before保证,如下:

  • 当其他变量的读写指令原本就在volatile写指令前面时,那么其他变量的读写指令不会被重排序到 volatile 写指令后面。(请注意:这里只提出了原本在volatile变量写指令前面的指令,不会被重排序到volatile指令后面,原本在volatile写指令前面的指令之间可以发生指令重排。)
  • 当其他变量的读写指令原本就在volatile读指令后面时,那么其他变量的读写指令不会被重排序到 volatile 读指令前面。(同样:原本在 volatile 读指令后面的指令之间可以发生指令重排)

五、volatile 不足

即使 volatile 关键字保证了线程每次读取 volatile 变量时从主内存中读取数据,每次写数据时把数据写回主内存中,但在一些情况下,声明volatile变量是不够的。

上面我们提到过当一个线程修改volatile变量时,能保证其他线程可以读取到volatile变量的最新值。

事实上,在多个线程修改volatile变量的情况下,也能确保最新值被写回到主内存中。但这样可能会存在一些问题,示例:

private volatile int totalPrice = 0;

public int addPrice(int price) {
    totalPrice += price;
    return totalPrice;
}

public void initTotalPrice() {
    totalPrice = 0;
}

当线程一、线程二分别在执行addPrice()方法和initTotalPrice()方法时,线程一执行完totalPrice += price指令后,把totalPrice的最新值刷回到主内存中,若此时线程二正巧执行完initTotalPrice()方法,totalPrice的值被重新刷回0,当线程一再读取totalPrice值并返回,导致最终返回的值是0,这可能不是我们程序最终想要的结果。(备注:当然我们不会去写这种程序代码,这个示例我只用来说明问题,并没有实际测试过这种场景。)

上例其实就是为了说明一个问题:volatile 只是保证了数据的可见性,并没有保证原子性,如果要执行原子性操作,使用volatile关键字是远远不够的。这里顺便提一下,synchronized关键字不仅保证了数据可见性,同时也保证了原子性。

再来看下一个示例:

//声明volatile变量
volatile int i = 0;

//自增
i++;

当2个线程同时对变量 i 做自增操作,当他们同时把 i 的值写回到主内存中时,最终写回的值时1,实际上i做了2次自增。

因此:在多个线程同时存在修改共享数据的情况时,仅仅使用volatile变量是不够的。请记住:volatile 关键字只保证了数据可见性,并不保证原子性。


六、什么时候使用 volatile ?

正如前面提到的,如果多个线程同时对共享数据进行读写操作,这种情况下使用volatile关键字是不够的。我们需要使用synchronized关键字保证数据读写的可见性和原子性。

除了使用synchronized关键字之外,我们还可以使用 java.util.concurrent 包提供的各种工具类,比如 AtomicLong 、 AtomicReference 等等。

当只有一个线程会对共享变量的值进行修改,其他的线程只读取共享变量的值时,我们使用volatile关键字是足够的。

另外: volatile 关键字工作在32位或者64位的变量上。


七、volatile 性能考虑

我们都知道,CPU操作CPU高速缓存的速度远远快于操作主内存的速度。CPU每次操作volatile关键字都会操作主内存会带来额外的性能开销。另外,volatile 关键字防止指令重排是正常性能增强技术。在条件允许的情况下,使用volatile关键字代替synchronized代码块能节省性能开销。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值