深入理解volatile关键字

在多线程并发编程中synchronized和volatile都扮演着重要的角色,volatile可以理解为synchronized的轻量级实现,能够保证在多处理器开发中保证共享变量的“可见性”。volatile的性能比synchronized好,volatile只能用于修饰变量。

可见性:意思是当一个线程修改一个共享变量时,其他线程可以读到这个修改的值。
有序性:即程序执行的顺序按照代码的先后顺序执行。
原子性:指该操作不可再分了,同一时刻只能有一个线程来对它进行操作。

缓存一致性协议
为了提高处理速度,处理器不会直接和内存通信,而是先将内存的数据读取到缓存中,操作完后不一定立即写会到内存。就算写回到内存中了,其它处理器的缓存值还是旧的,用这个值计算就会有问题。
在多处理器的情况下,为了保证各缓存中的数据是一致的,需要实现缓存一致性协议
1.每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是否过期了(CPU缓存不仅仅在做内存传输的时候才与总线打交道,而是不停在嗅探总线上发生的数据交换,跟踪其他缓存在做什么。所以当一个缓存代表它所属的处理器去读写内存时,其它处理器都会得到通知,它们以此来使自己的缓存保持同步。只要某个处理器一写内存,其它处理器马上知道这块内存在它们的缓存段中已失效);
2.当处理器发现自己缓存行的值对应的内存地址被修改就会将当前处理器的缓存值置为无效状态,当前处理器对这个值操作时会重新从内存中读到缓存。

volatile的定义: java语言允许线程访问共享变量,为了确保共享变量能够准确和一致的更新,线程应该确保通过排他锁单独获得这个变量。java提供volatile,在某些情况下比锁更方便,如果一个字段被声明成volatile,java内存模型确保所有线程看到这个变量的值是一致的。

volatile如何保证可见性?

被volatile修饰的变量转变成汇编代码后多出一个lock前缀(相当于一个内存屏障),lock前缀的指令在多核处理器下会引发两件事:
(1).将当前处理器缓存行的数据写回到系统内存中;
(2).写回内存这个操作会使在其他CPU里缓存了该内存地址的数据失效(触发MESI协议);

既然CPU有了MESI协议可以保证cache的一致性,那么为什么还需要volatile这个关键词来保证可见性??
(1)并不是所有的硬件架构都提供了相同的一致性保证,Java作为一门跨平台语言,JVM需要提供一个统一的语义。
(2)操作系统中的缓存和JVM中线程的本地内存并不是一回事,通常我们可以认为:MESI可以解决操作系统缓存层面的可见性问题。使用volatile关键字,可以解决JVM层面的可见性问题。
(3)多核情况下,所有的cpu操作都会涉及缓存一致性的校验,该协议不能保证一个线程修改变量后,其他线程立马可见,也就是说当前CPU可能将数据修改之后又去做其他事情,没有来得及将修改后的变量刷新回内存,而如果此时其他CPU需要使用该变量,则又会从内存中读取到旧的值。而volatile则可以保证可见性,即立即刷新回主存,修改操作和写回操作必须是一个原子操作

volatile如何保证有序性?

volatile变量的第二个语义是禁止指令重排序,通过禁止指令重排序保证有序性。

指令重排
重排序是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段。(好处)

在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序。重排序分3种类型。

1)编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。

2)指令级并行的重排序。现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。

3)内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

从Java源代码到最终实际执行的指令序列,会分别经历下面3种重排序:
在这里插入图片描述

由于编译器和处理器都能执行指令重排优化。如果在指令间插入一条Memory Barrier则会告诉编译器和CPU,不管什么指令都不能和这条Memory Barrier指令重排,也就是说通过插入内存屏障,就能禁止在内存屏障前后的指令执行重排优化。内存屏障另外一个作用就是强制刷出各种CPU的缓存数据,因此任何CPU上的线程都能读取到这些数据的最新版本。

对于编译器,JMM的编译器重排序规则会禁止特定类型的编译器重排序(不是所有的编译器重排序都要禁止)。对于处理器重排序,JMM的处理器重排序规则会要求Java编译器在生成指令序列时,插入特定类型的内存屏障指令,通过内存屏障指令来禁止特定类型的处理器重排序(有序性)。JMM属于语言级的内存模型,它确保在不同的编译器和不同的处理器平台之上,通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。

一个非常经典的指令重排序例子:

public class SingletonTest {
    private volatile static SingletonTest instance = null;
    private SingletonTest() { }
    public static SingletonTest getInstance() {
        if(instance == null) {
            synchronized (SingletonTest.class){
                if(instance == null) {
                    instance = new SingletonTest();  //非原子操作
                }
            }
        }
        return instance;
    }
}

这是单例模式中的“双重检查加锁模式”,我们看到instance用了volatile修饰,由于 instance = new SingletonTest();可分解为:

1.memory =allocate(); //分配对象的内存空间
2.ctorInstance(memory); //初始化对象
3.instance =memory; //设置instance指向刚分配的内存地址

操作2依赖1,但是操作3不依赖2,所以有可能出现1,3,2的顺序,当出现这种顺序的时候,虽然instance不为空,但是对象也有可能没有正确初始化,会出错。解决办法是对singletondemo对象添加上volatile关键字,禁止指令重排。

volatile内存语义

从内存语义的角度来看:

   1. volatile的写-读与锁的释放-获取有相同的内存效果
   
   2. volatile写和锁的释放有相同的内存语义
   
   3. volatile读与锁的获取有相同的内存语义

volatile读和写的内存语义:

  1. 线程A写一个volatile变量,实质上是线程A向接下来要读这个volatile变量的某个线程发出了(对共享变量所做修改)消息;
  2. 线程B读一个volatile变量,实质上是线程B接收了之前某个线程发出的(对volatile共享变量做修改的)消息;
  3. 线程A写一个volatile变量,随后线程B读这个volatile变量,这个过程实质上是线程A通过内存向B线程发送消息(线程间的通信);

volatile与内存屏障:

内存屏障:是指一组处理器指令,用于实现对内存操作的顺序限制。
为了实现volatile的内存语义,编译器生成字节码的时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。

屏障分类指令示例说明
LoadLoad Barriersload1; LoadLoad;load2确保load1数据的装载优先于load2及所有后续装载指令的装载
StoreStore Barriersstore1;StoreStore;store2确保store1数据对其他处理器可见优先于store2及所有后续存储指令的存储
LoadStore Barriersload1;LoadStore;store2确保load1数据装载优先于store2以及后续的存储指令刷新到内存
StoreLoad Barriersstore1; StoreLoad;load2确保store1数据对其他处理器变得可见, 优先于load2及所有后续装载指令的装载

volatile基于保守策略的JMM内存屏障插入策略:

volatile写:
在这里插入图片描述
volatile读:
在这里插入图片描述

在实际执行时,只要不改变volatile写读的内存语义,编译器可以根据具体情况省略不必要的屏障。

总结:内存屏障, 相当于告诉CPU和编译器先于这个命令的必须先执行,后于这个命令的必须后执行。内存屏障另一个作用是强制更新一次不同CPU的缓存。可以理解是内存屏障保证了volatile的可见性和有序性。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值