Java volatile关键字剖析

提示:文章写完后,目录可以自动生成,如何生成可参考右边的帮助文档


在这里插入图片描述

1. volatile关键字介绍

在阅读本文的时候,需要有Java内存模型相关基础知识,如果不了解Java内存模型的朋友,请点击这里先了解Java内存模型,有助于您更好的理解本文对volatile的讲解。

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

实际上,从 Java 5 开始,volatile关键字就起到保证 volatile 变量可以写入主内存和从主内存读取

2. volatile变量可见性问题

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

在线程对non-volatile 变量进行操作的多线程应用程序中,出于性能原因,每个线程在处理变量时都可以将变量从主内存复制到CPU寄存器中。如果您的计算机包含多个CPU,则每个线程可能在不同的CPU上运行。这意味着,每个线程都可以将变量复制到不同CPU的CPU寄存器中。如下图所示:
在这里插入图片描述
对于non-volatile 变量,无法保证 Java 虚拟机 (JVM) 何时将数据从主内存读取到 CPU 寄存器,或将数据从 CPU 寄存器写入主内存。这可能会导致几个问题,我将在以下部分中解释这些问题。

想象一下这样一种情况,两个或多个线程可以访问一个共享对象,该对象包含如下声明的counter变量:

public class SharedObject {
    public int counter = 0;
}

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

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

3. volatile 变量可见性保证

Java volatile关键字旨在解决变量可见性问题。通过声明counter变量为volatile,可以促使对counter变量的所有写入都将立即写回主内存。此外,counter变量的所有读取都将直接从主存储器中读取。

以下是counter变量的volatile声明:

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

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

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

然而,如果T1和T2都在递增counter变量,那么仅仅为counter变量声明volatile却是不够的,无法保证counter变量最终的值是准确的

3.1 Full volatile完全易失性可见性保证

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

如果线程A写入一个volatile变量,而线程B随后读取了相同的volatile参数,那么线程A在写入volatile之前可见的所有变量在线程B读取volatile后也将可见

如果线程A读取了一个volatile变量,那么线程A在读取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;
    }
}

udpate()方法写入三个变量,其中只有days变量是声明了volatile的。
完全易失性(Full volatile)可见性保证意味着,当一个值被写入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的值时,years和months的值也会被读取到主存储器中。因此,您可以保证按照上述读取顺序看到最新的 years, months 和days值。

3.2 指令重新排序挑战

出于性能原因,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和month值也会写入主存。但是,如果Java虚拟机对指令进行了重新排序,如下所示:

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

修改days变量时,monthsyears的值仍会写入主内存,但这次是在新值写入monthsyears之前发生的。因此,新值不能正确地对其他线程可见。重新排序的指令的语义含义发生了变化。

Java为这个问题提供了一个解决方案,请继续阅读下一节

3.3 volatile 的 Happens-Before 保证

为了应对指令重新排序的挑战,Java volatile关键字除了提供可见性保证外,还提供了“happens-before”的保证。担保前发生保证:

如果读取/写入最初发生在写入volatile变量之前,则不能将对其他变量的读取和写入重新排序为在写入volatile变量之后发生。

在写入volatile变量之前进行的读/写操作保证会发生在写入volatile变量之前。请注意,例如,对位于写入volatile变量之后的其他变量的读取/写入仍然有可能在写入volatile之前重新排序。只是不是反过来。从后到前是允许的,但从前到后是不允许的

如果读取/写入最初发生在读取volatile变量之后,则不能将对其他变量的读取和写入重新排序为在读取volable变量之前发生。请注意,在读取volatile变量之前发生的其他变量的读取可能会被重新排序为在读取volable变量之后发生。只是不是反过来。从之前到之后是允许的,但从之后到之前是不允许的。

4. volatile 并不总是够用的

即使volatile关键字保证对volatile变量的所有读取都直接从主存读取,对volatile变量的所有写入都直接写入主存,但仍然存在仅声明一个volatile变量是不够的情况。

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

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

一旦线程需要先读取volatile变量的值,然后根据该值为共享volatile变量生成新值,volatile变量就不足以保证正确的可见性。读取volatile 变量和写入新值之间的短暂时间间隔会产生竞争条件 ,其中多个线程可能会读取相同的变量值volatile,为变量生成新值,并在将值写回主内存时覆盖彼此的值。

多个线程增加同一个counter的情况正是volatile变量将表现不足的情况。以下部分将更详细地解释这种情况。

想象一下,如果线程1将值为0的共享counter变量读取到其CPU寄存器中,将其增量为1,而不是将更改后的值写回主内存。然后,线程2可以从主内存中读取相同的counter变量,其中变量的值仍然是0,并将其读取到自己的CPU寄存器中。然后,线程2也可以将counter递增到1,并且也不会将其写回主内存。这种情况如下图所示:
在这里插入图片描述
线程1和线程2现在几乎不同步。共享counter变量的实际值应该是2,但每个线程在其CPU寄存器中的变量值都是1,而在主存中的值仍然是0。真是一团糟!即使线程最终将共享counter变量的值写回主内存,该值也会出错

5. volatile怎样才能具备原子性?

正如我之前提到的,如果两个线程都在读写共享变量,那么使用volatile关键字是不能保证的该共享变量的原子性的。在这种情况下,您需要使用synchronized来保证变量的读写是原子性的。

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

如果只有一个线程读取和写入volatile变量的值,而其他线程只读取该变量,那么读取线程可以保证看到写入volatile变量的最新值。如果不将变量设为 volatile,就无法保证这一点,这就是volatile的可见性特性。

6. volatile的性能考虑

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

在实践中,CPU寄存器值通常只会写入CPU L1缓存,这相当快。虽然不如写入CPU寄存器快,但仍然很快。从L1缓存到L2和L3缓存,再到主存储器(RAM)的同步是由与CPU不同的芯片完成的(据我所知),因此CPU没有负担。

即便如此,在实际需要时,尽管只使用volatile变量,这也将迫使您详细了解volatile变量的工作原理!

7. 总结

通过本文对volatile关键字的剖析,我们详细了解了volatile的工作原理。

总体说来,volatile具有如下三大特性:

7.1 可见性

当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值

7.2 不具备原子性

两个线程都在读写共享变量,那么使用volatile关键字是不能保证的该共享变量的原子性的。在这种情况下,您需要使用synchronized来保证变量的读写是原子性的。

7.3 有序性

有序性是指的volatile变量会禁止前后语句发生指令重排序。因此多线程环境下要保证指令执行的有序性禁止指令被JVM出于性能优化而采取重排序机制),必须要使用volatile关键字。

  • 20
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

龙殿殿主

你的打赏是我精心创作的动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值