Java关键字----volatile

volatile简介

​ 通过一篇我的博客可以了解到synchronized关键字是阻塞式同步,在线程竞争激烈的情况下会升级为重量级锁。而volatile就可以说是java虚拟机提供的最轻量级的同步机制。但它同时不容易被正确理解,也至于在并发编程中很多程序员遇到线程安全的问题就会使用synchronized。

​ 在JVM虚拟机内存模型中,说明了当多个线程掌握同一个共享变量时,各个线程会将共享变量从主内存中拷贝到工作内存也就是相应的cpu缓存中,然后cpu会基于当前线程工作内存中的数据进行操作。但是我们不能确定的一点是当cpu进行数据操作后,我们不知道何时cpu会将操作后的变量写到主内存中。这个时机对普通变量是没有规定的。

​ 但是当一个变量被volatile关键字修饰时,线程对volatile变量的修改会立刻被其他线程所感知,即不会出现数据脏读的现象,从而保证数据的“可见性”。

volatile的具体特点

volatile关键字修饰的共享变量拥有可见性和有序性,但是不保证原子性。

img

volatile的可见性

  • 具体描述也就是volatile关键字修饰的属性保证每次读取都能读到最新的值,但是不会更新已经读了的值。也就是说,每次的工作内存从主内存中读的值都是最新的,但是一旦线程从工作内存中读取了值A,然后工作内存的值再变化为B,这就不会改变读过的A值。

实现可见性的大体步骤

任意一个线程修改了 volatile 修饰的变量之后:线程1在工作内存中修改的共享属性值会立即刷新到主存,线程2/3/4每次通过读写栅栏来达到类似于直接从主存中读取属性值。

  • 读写栅栏是一条CPU指令;插入一个读写栅栏=告诉CPU&编译器先于这个命令的必须先执行,后于这个命令的必须后执行(有序性)。强制更新一次不同CPU的缓存。例如:一个写栅栏会把这个栅栏前写入的数据刷新到缓存,以此保证可见性。

步骤一:修改本地内存,强制刷回主内存

img

步骤二:强制其他线程的工作内存失效过期

img

步骤三:其他线程重新从主内存加载最新值

img

  • 注:网上有些说volatile修饰的变量读写直接在主存中操作,这种说法是不对的,只是表现出类似的行为。

实现可见性的原理

当对volatile修饰的共享变量进行写操作,生成汇编代码时会多出一个Lock前缀的指令,那么Lock前缀的指令在多核处理器下会发现什么事情了?主要有这两个方面的影响:

  1. 将当前处理器缓存行的数据写回系统内存;
  2. 这个写回内存的操作会使得其他CPU里缓存了该内存地址的数据无效

当对声明了volatile的变量进行写操作,JVM就会向处理器发送一条Lock前缀的指令,将这个变量所在缓存行的数据写回到系统内存。但是,就算写回到内存,如果其他处理器缓存的值还是旧的,再执行计算操作就会有问题。所以,在多cpu处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里。因此,经过分析我们可以得出如下结论:

  1. Lock前缀的指令会引起处理器缓存写回内存;
  2. 一个处理器的缓存回写到内存会导致其他处理器的缓存失效;
  3. 当处理器发现本地缓存失效后,就会从内存中重读该变量数据,即可以获取当前最新值。

这样针对volatile变量通过这样的机制就使得每个线程都能获得该变量的最新值。

volatile的有序性&禁止指令重排序

什么是有序性?

  • 具体描述
    当对volatile修饰的属性进行读/写操作时,其前面的代码必须已执行完成 & 结果对后续的操作可见

在说明有序性的原理之前,我们要了解一下什么是指令重排序。

什么是指令重排序?

为了提高性能,编译器和处理器常常会对指令做重排序。

一般重排序可以分为如下三种类型:

  • 编译器优化重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
  • 指令级并行重排序。现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
  • 内存系统重排序。由于处理器使用缓存和读 / 写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

例如:

int a = 0;
// 线程 A
a = 1;           // 1
flag = true;     // 2

// 线程 B
if (flag) { // 3
  int i = a; // 4
}

这个代码在编译时,1和2指令就可能发生重排序,导致2语句先执行,然后是1语句。

指令重排序导致的问题:

  • 如果线程 A 在执行时被重排序成先执行代码 2,再执行代码 1;而线程 B 在线程 A 执行完代码 2 后,读取了 flag 变量。由于条件判断为真,线程 B 将读取变量 a。此时,变量 a 还根本没有被线程 A 写入,那么 i 最后的值是 0,导致执行结果不正确。

这时就要使用volatile关键字来修饰,使用 volatile 不仅保证了变量的内存可见性,还禁止了指令的重排序,即保证了 volatile 修饰的变量编译后的顺序与程序的执行顺序一样。那么使用 volatile 修饰 flag 变量后,在线程 A 中,保证了代码 1 的执行顺序一定在代码 2 之前。

保证有序性的原理

volatile来禁止重排序以致于达到有序性的方法是添加内存屏障指令。那么什么是内存屏障指令呢?

内存屏障指令

内存屏障是一组处理器指令,它的作用是禁止指令重排序和解决内存可见性的问题。

JMM 把内存屏障指令分为下列四类:

img内存屏障

StoreLoad 屏障是一个全能型的屏障,它同时具有其他三个屏障的效果。所以执行该屏障开销会很大,因为它使处理器要把缓存中的数据全部刷新到内存中。

​ 为了实现 volatile 内存语义(即内存可见性),JMM 会限制特定类型的编译器和处理器重排序。为此,JMM 针对编译器制定了 volatile 重排序规则表,如下所示:

imgvolatile 重排序规则

​ "NO"表示禁止重排序。为了实现volatile内存语义时,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎是不可能的,为此,JMM采取了保守策略:

下面我们来看看 volatile 读 / 写时是如何插入内存屏障的,见下图:

volatile读插入内存屏障示意图

从上图,我们可以知道 volatile 读 / 写插入内存屏障规则:

  • 在每个volatile写操作的前面插入一个StoreStore屏障;
  • 在每个volatile写操作的后面插入一个StoreLoad屏障;
  • 在每个volatile读操作的后面插入一个LoadLoad屏障;
  • 在每个volatile读操作的后面插入一个LoadStore屏障。

也就是说,编译器不会对 volatile 读与 volatile 读后面的任意内存操作重排序;编译器不会对 volatile 写与 volatile 写前面的任意内存操作重排序。

关于volatile的原子性问题

volatile的单个读/写具有原子性

理由如下:数据的单个读写,都是从工作内存中读数据的,工作内存中储存的数据一定是最新的,所以每次读写都是主内存中最新的数据。

volatile的复合操作不具有原子性

举一个例子:

public class Part implements Runnable {
    private volatile static int a = 0;
    @Override
    public void run() {
        for (int i = 0; i < 10000; i++) {
        // 数字大一些,不然看不到效果
        // 这里在a++前后打印变量值
            System.out.println(Thread.currentThread().getId()+" before a = " + a);
            a++;
            System.out.println(Thread.currentThread().getId()+" after a = " + a);
        }
    }
}

public class Test {
    public static void main(String[] args) throws Exception {
        Part a = new Part();
        Part b = new Part();
        Thread t1 = new Thread(a);
        Thread t2 = new Thread(b);  
        t1.start();
        t2.start();
    }
}

有两个线程,a最后的结果预期是20000。但是运行完的结果会小于20000,并且每次都不一样,这里就是原子性的问题。

  • 原因:a++这个操作有三个步骤,读取值,再自增然后再写入。多线程操作时就会出现一种情况,比如a线程从工作内存拿到了a,b线程也从工作内存拿到了a,此时还没自增。然后他们分别执行操作,最后操作完a写入主存是2,b也是2,所以这就没有保证原子性。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值