Java Volatile关键字

学无止境,一段旅程结束了,短暂的休整之后我们就要重新踏上征程。在做面试系列之前,码之初有两个未完的系列,一个是设计模式还有五六种没有更新完,一个是并发系列没有介绍完,做人要始终不渝,做事要善始善终,所以接下来的一段时间,码之初会将并发系列更新完,尽量在中间加一些有趣的内容,防止乡亲们觉得枯燥,弃我而去,哈哈。

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

事实上,从Java 5开始,volatile关键字保证了volatile变量不仅仅是写入主内存或者是从主内存读取,我将从下面几个方面进行解释。

01、变量可见性问题
Java volatile关键字可确保跨线程更改变量的可见性。这听起来有点抽象,下面来详细说明。

在多线程应用中,线程对非易失性变量进行操作,出于性能方面的考虑,每个线程在进行操作时都可以将主内存中的变量复制到CPU缓存中。如果计算机包含多个CPU,则每个线程可能在不同的CPU上运行。这意味着,每个线程可以将变量复制到不同CPU的CPU缓存中。我们来看个例子:
在这里插入图片描述
对于非易失性变量,无法保证Java虚拟机(JVM)何时将数据从主存读取到CPU缓存,何时将数据从CPU缓存写入主存。这可能会导致一些问题,这些都将在下面一起解释。

设想一种情况,有两个或多个线程可以访问一个共享对象,该共享对象包含一个声明为以下内容的计数器变量:

public class SharedObject {
    public int counter = 0;
}

想象一下,只有线程1递增计数器变量,但线程1和线程2都可能时不时地读取计数器变量。

如果计数器变量未声明为volatile,则无法保证计数器变量的值何时从CPU缓存写入主内存。这意味着,CPU缓存中的计数器变量值可能与主内存中的不同。这种情况如下所示:
在这里插入图片描述
线程看不到变量的最新值,因为它还没有被另一个线程写回主内存的问题,称为“可见性”问题。一个线程的更新对其他线程不可见。

02、Java volatile可见性保证
Java volatile关键字旨在解决变量可见性问题。通过声明计数器变量为volatile,所有对计数器变量的写操作将立即写回到主内存中。同样,计数器变量的所有读取将直接从主内存中读取。

我们对上面的计数器变量加上volatile关键字声明,像这样:

public class SharedObject {
    public volatile int counter = 0;
}

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

在上面给出的场景中,一个线程(T1)修改计数器,另一个线程(T2)读取计数器(但从不修改),声明计数器变量volatile足以保证T2对计数器变量的写入可见性。

但是,如果T1和T2都在递增计数器变量,那么声明计数器变量volatile是不够的,这个以后再说。

完全易失的可见性保证(Full volatile Visibility Guarantee)

实际上,Java volatile的可见性保证超出了volatile变量本身。可见性保证如下:

  • 如果线程A写入易失性变量,并且线程B随后读取相同的易失性变量,则在写入易失性变量之前线程A可见的所有变量在线程B读取易失性变量后也将可见。
  • 如果线程A读取了易失性变量,则在读取易失性变量时线程A可见的所有所有变量也将从主内存中重新读取。

我用代码示例来说明这一点:

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;
    }
}

udpate()方法写入三个变量,其中只有days是可变的。

完全易失的可见性保证意味着,当将值写入days时,线程可见的所有变量也将写入主内存。这意味着,当将值写入days时,years和months的值也将写入主内存中。

在读取years,months和days的值时,可以这样:

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

    public int totalDays() {
        int total = this.days;
        total += months * 30;
        total += years * 365;
        return total;
    }

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

注意:totalDays()方法首先将days的值读入total变量。当读取days的值时,months和years的值也会被读入主内存,因此,可以保证按照上述读取顺序查看days,months和years的最新值。

03、指令重排挑战性
出于性能原因,允许Java VM和CPU对程序中的指令进行重新排序,只要指令的语义含义保持相同即可。例如,看下面这个指令:

int a = 1;
int b = 2;

a++;
b++;

这些指令可以重新排序为以下顺序,而不会丢失程序的语义:

int a = 1;
a++;

int b = 2;
b++;

然而,当其中一个变量是volatile易失性变量时,指令重新排序是一个挑战。让我们看一下前面示例中的MyClass类:

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值也将写入主内存。但是,如果Java VM重新对指令进行排列,比如:

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

当days变量被修改时,months和years的值仍会写入主内存,但这次是在新值写入months和years之前发生的。因此,新值对其他线程来说是不可见的。重新排序的指令的语义已更改。

Java有解决此问题的方法,我们将在后面看到。

04、Java易失性发生之前保证
为了解决指令重新排序的难题,除了可见性保证之外,Java volatile关键字还提供易失性发生之前(“happens-before”)保证。发生之前保证保证了:

  • 如果读/写最初发生在对volatile变量的写入之前,则不能将对其他变量的读/写重新排序为在对volatile变量的写入之后发生。在写入volatile变量之前的读/写保证在写入volatile变量之前“发生”。注意,例如,在对volatile的写操作之后的其他变量的读/写操作仍有可能在对volatile的写操作之前重新排序。但不是相反。允许从后到前,但不允许从前到后。
  • 如果读取/写入最初发生在读取volatile变量之后,则不能将对其他变量的读取和写入重新排序为在读取volatile变量之前发生。注意,在volatile变量的读取之前发生的其他变量的读取可能会被重新排序为在volatile变量的读取之后发生。但不是相反。允许从前到后,但不允许从后到前。

上述“易失性发生之前(“happens-before”)确保强制执行volatile关键字的可见性保证。

05、声明volatile还不一定够
即使volatile关键字保证所有volatile变量的读取都直接从主存中读取,并且所有对volatile变量的写入都直接写入主存,但在某些情况下,声明变量volatile还不够。

在前面解释的只有线程1写入共享计数器变量的情况下,声明计数器变量为volatile,足以确保线程2始终看到最新的写入值。

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

一旦线程需要首先读取volatile变量的值,并基于该值为共享的volatile变量生成新值,则volatile变量将不再足以保证正确的可见性。读取volatile变量与写入新值之间的时间间隔很短,从而造成竞争状态,多个线程可能会读取volatile变量的同一个值,为该变量生成一个新值,并且在将该值写入主内存时 - 覆盖彼此的值。

多个线程递增同一个计数器的情况正是这样一种情况,即声明volatile变量还不够。下面将更详细地解释此案例。

想象一下,如果线程1将一个值为0的共享计数器变量读入其CPU高速缓存中,将其递增为1,而不是将更改后的值写回到主内存中。然后,线程2可以从主内存中(该变量的值仍为0)读取相同的计数器变量到其自己的CPU高速缓存中。然后线程2还可将计数器增加到1,并且也不会将其写回主内存。下图说明了这种情况:
在这里插入图片描述
线程1和线程2现在实际上不同步。共享计数器变量的实际值应该是2,但每个线程的CPU缓存中都有该变量的值1,但在主内存中该值仍然为0。这是糟糕的,即使线程最终将共享计数器变量的值写回主内存,该值也会出错。

06、什么时候声明volatile变量就足够
如前所述,如果两个线程都在读写一个共享变量,那么使用volatile关键字是不够的。在这种情况下,您需要使用synchronized来保证变量的读写是原子的。读取或写入volatile变量不会阻塞线程的读取或写入。为此,必须在关键部分使用synchronized关键字。

作为同步块的替代方法,您还可以使用java.util.concurrent包中提供的许多原子数据类型之一。例如,AtomicLong或AtomicReference或其他之一。

如果只有一个线程读取和写入volatile变量的值,而其他线程只读取该变量,那么读取线程将保证看到写入volatile变量的最新值。如果不将变量声明volatile,就无法保证这一点。

volatile关键字保证可以在32位和64位变量上使用。

07、volatile的性能考虑
读写volatile变量会使该变量被读写到主存。与访问CPU缓存相比,读写主内存的开销更大。访问volatile变量还可以防止指令重新排序,这是一种常见的性能增强技术。因此,只有在确实需要增强变量的可见性时,才应该使用volatile变量。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值