JVM内存模型之Volatile关键字

起笔

“不吃饭则饥,不读书则愚”

在这里插入图片描述
参考书籍:“深入理解java虚拟机”

对于volatile型变量的特殊规则

书接上文,上篇了解到java内存模型的由来、主内存与工作内存的交互等,这篇文章我们来了解一下内存模型对于Volatile关键字的内存定义。

private static volatile SingleInstance singleInstance = null;

public static SingleInstance getSingleInstance(){
        if (singleInstance == null) {
            synchronized (SingleInstance.class) {
                if (singleInstance == null) {
                    singleInstance = new SingleInstance();
                }
            }
        }
        return singleInstance;
    }

不知道大家对于单例模式中的DCL(双重校验)这种写法是否还有印象,这种写法里面就使用了Volatile关键字去修饰变量,至于为什么要使用Volatile关键字去修饰,那就请看完这篇文章。

可见性

当一个变量定义为Volatile之后,它将具备两种特性,第一是保证此变量对所有线程的可见性,这里的“可见性”是指当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。而普通变量不能做到这一点,普通变量的值在线程间传递均需要通过主内存来完成,例如,线程A修改一个普通变量的值,然后向主内存进行回写,另外一条线程B在线程A回写完成了之后再从主内存进行读取操作,新变量值才会对线程B可见,整个过程算是线程A给线程B发送了一个信息“我改了变量的值”(线程之间的通信方式之一)。

虽然Volatile保证了数据的可见性,但并不代表着数据的线程安全,我们通过一个案例去验证一下:

public class VolatileTest {
    public static volatile int race = 0;
    private static final int THREADS_COUNTS = 20;

    public static void increase() {
        race++;
    }

    public static void main(String[] args) throws InterruptedException {
        Thread[] threads = new Thread[THREADS_COUNTS];
        for (int i = 0; i < THREADS_COUNTS; i++) {
            threads[i] = new Thread(() -> {
                for (int j = 0; j < 10000; j++) {
                    increase();
                }
            });
            threads[i].start();
        }
        Thread.sleep(20000);
        System.out.println("race = "+race);
        System.out.println("ThreadActiveCount = "+Thread.activeCount());
    }
}

在这里插入图片描述

如果race是线程安全的,那么返回的结果应该是200000,但是通过实际验证却发现是一个比200000小的数,是什么导致结果的不一致呢?
在这里插入图片描述

问题就出现在自增运算“race++”之中,用Javap反编译这段代码后发现只有一行代码的increase()方法在Class文件中是由4条字节码指令构成的(return 指令不是由race++产生的,这条指令可以不计算),从字节码层面上很容易就分析出并发失 败的原因了:当getstatic指令把race的值取到操作栈顶时,volatile关键字保证了race的值在此时是正确的,但是在执行iconst_1、iadd这些指令的时候,其他线程可能已经把race的值加大了,而在操作栈顶的值就变成了过期的数据,所以putstatic指令执行后就可能把较小的race值 同步回主内存之中。

总结地说就是:volatile变量在各个线程的工作内存中不存在一致性问题(在各个线程的工作内存中,volatile变量也可以存在不一致的情况,但由于每次使用之前都要先刷新,执行引擎看不到不一致的情况,因此可以认为不存在一致性问题),但是Java里面的运算并非原子操作,导致volatile变量的运算在并发下一样是不安全的。

虽然说Volatile只保证有序、可见性,但是只要在符合下面两种规则的情况下是可以保证线程安全的:

  1. 运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值
  2. 变量不需要与其他的状态变量共同参与不变约束。

通过一个案例解释这两种规则:

volatile boolean shutdownflag = false;
public void shutdown(){
	shutdownflag = true;
}
public void dowork(){
	while(!shutdownflag){
		//dosomething
	}
}

我们观察这个案例不难发现,shutdownflag并没有参与到具体的逻辑操作中去,它只是作为一个判断条件去使用,当某个线程调用了shutdown方法的时候,就能保证dowork的执行线程都立刻停止下来。

有序性

使用volatile变量的第二个语义是禁止指令重排序优化,普通的变量仅仅会保证在该方法 的执行过程中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值操作的顺序与程序代码中的执行顺序一致。因为在一个线程的方法执行过程中无法感知到这点,这也就是Java内存模型中描述的所谓的“线程内表现为串行的语义”(Within-Thread As-If-Serial Semantics)

还是通过案例来进行一个解释:

public class VolatileTest2 {
    private static VolatileTest2 singletonInstance;

    public static VolatileTest2 getVolatileTest2() {
        if (singletonInstance == null) {
            synchronized (VolatileTest2.class) {
                if (singletonInstance == null) {
                    singletonInstance = new VolatileTest2();
                }
            }
        }
        return singletonInstance;
    }
}

这是一个不正规的DCL写法,这种写法有着一些缺陷,用户可能会获取一个实例化没有完全的VolatileTest2对象。正常的逻辑是当A线程第一次通过getVolatileTest2()方法去获取一个对象的时候此时singletonInstance还是null值,此时会对singletonInstance进行实例化,然后将实例化完全的对象返回给调用方,但是实际效果却可能不是这样的,由于编译器或者硬件的优化策略它会将这些指令进行一个重新排序,那也就是说singletonInstance实例化操作的过程优化过后可能会在返回singletonInstance这条语句之后执行,所以就会导致用户拿到的singletonInstance对象是一个实例化不完全的对象或者是null对象。

而Volatile是通过内存屏障去保证指令的有序性的,这块如果有感兴趣的小伙伴可以去度娘一下,还是挺值得研究一下的。

我们回头看一下Java内存模型中对volatile变量定义的特殊规则。假定T 表示一个线程,V和W分别表示两个volatile型变量,那么在进行read、load、use、assign、 store和write操作时需要满足如下规则:

  1. 只有当线程T对变量V执行的前一个动作是load的时候,线程T才能对变量V执行use动作;并且,只有当线程T对变量V执行的后一个动作是use的时候,线程T才能对变量V执行 load动作。线程T对变量V的use动作可以认为是和线程T对变量V的load、read动作相关联,必须连续一起出现(这条规则要求在工作内存中,每次使用V前都必须先从主内存刷新最新的值,用于保证能看见其他线程对变量V所做的修改后的值)
  2. 只有当线程T对变量V执行的前一个动作是assign的时候,线程T才能对变量V执行store动 作;并且,只有当线程T对变量V执行的后一个动作是store的时候,线程T才能对变量V执行 assign动作。线程T对变量V的assign动作可以认为是和线程T对变量V的store、write动作相关联,必须连续一起出现(这条规则要求在工作内存中,每次修改V后都必须立刻同步回主内存中,用于保证其他线程可以看到自己对变量V所做的修改)。
  3. 假定动作A是线程T对变量V实施的use或assign动作,假定动作F是和动作A相关联的load 或store动作,假定动作P是和动作F相应的对变量V的read或write动作;类似的,假定动作B是 线程T对变量W实施的use或assign动作,假定动作G是和动作B相关联的load或store动作,假定动作Q是和动作G相应的对变量W的read或write动作。如果A先于B,那么P先于Q(这条规 则要求volatile修饰的变量不会被指令重排序优化,保证代码的执行顺序与程序的顺序相同)。

最后提一嘴,由于volatile变量只能保证可见性和有序性,在某些场景下,我们仍然要通过加锁(使用synchronized或java.util.concurrent中的原子类)来保证原子性去达到线程安全的效果。

  • 16
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 11
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值