JAVA并发编程--3 理解volatile

前言:
在高并发的情况下,如果一个线程对于共享数据做出了修改后,其余线程无法及时感受到数据态的变化,会造成一些意外的情况出现,那么在java 层面怎么来解决共享资源对于线程的可见性问题呢?

1 volatile 背景:
我们知道jvm参照计算机模型设计,然而CPU的运算速度是很快的,所以如果每次都去主内存去进行存取数据,会受到主内存的限制从而影响效率;所以会在每个CPU配置高速缓冲区,CPU和高速缓冲区进行数据交换以此来弥补之前和内存直接进行数据交换;
cpu通过增加自己的高速缓冲区解决了主内存的效率问题,如果在主内存中存在一个数据,当多个cpu 都加载这个数据后,某个cpu 通过运算改变了这个数据,其他cpu 无法感知到更新后的数据,还以以前旧的数据进行操作,这个问题又怎么处理?
cpu层面通过缓存一致性协议来解决缓存不一致的问题:
1.1 总线仲裁机制:
在计算机中,数据通过总线在处理器和内存之间传递。每次处理器和内存之间的数据传递都是通过一系列步骤来完成的,这一系列步骤称之为总线事务(Bus Transaction)。总线事务包括读事务(Read Transaction)和写事务(WriteTransaction)。读事务从内存传送数据到处理器,写事务从处理器传送数据到内存,每个事务会读/写内存中一个或多个物理上连续的字。这里的关键是,总线会同步试图并发使用总线的事务。在一个处理器执行总线事务期间,总线会禁止其他的处理器和I/O设备执行内存的读/写。
总线的这种工作机制可以把所有处理器对内存的访问以串行化的方式来执行。在任意时间点,最多只能有一个处理器可以访问内存。这个特性确保了单个总线事务之中的内存读/写操作具有原子性。
总线锁定:
总线锁定就是使用处理器提供的一个 LOCK#信号,当其中一个处理器在总线上输出此信号时,其它处理器的请求将被阻塞住,那么该处理器可以独占共享内存。

缓存锁定:
由于总线锁定阻止了被阻塞处理器和所有内存之间的通信,而输出LOCK#信号的CPU可能只需要锁住特定的一块内存区域,因此总线锁定开销较大。
缓存锁定是指内存区域如果被缓存在处理器的缓存行中,并且在Lock操作期间被锁定,那么当它执行锁操作回写到内存时,处理器不会在总线上声言LOCK#信号(总线锁定信号),而是修改内部的内存地址,并允许它的缓存一致性机制来保证操作的原子性,因为缓存一致性机制会阻止同时修改由两个以上处理器缓存的内存区域数据,当其他处理器回写已被锁定的缓存行的数据时,会使缓存行无效。
缓存锁定不能使用的特殊情况:
当操作的数据不能被缓存在处理器内部,或操作的数据跨多个缓存行时,则处理器会调用总线锁定。
有些处理器不支持缓存锁定

1.2 总线窥探机制(Bus Snooping)
总线窥探(Bus snooping)是缓存中的一致性控制器(snoopy cache)监视或窥探总线事务的一种方案,其目标是在分布式共享内存系统中维护缓存一致性。包含一致性控制器(snooper)的缓存称为snoopy缓存。该方案由Ravishankar和Goodman于1983年提出。
1.2.1 工作原理:
当特定数据被多个缓存共享时,处理器修改了共享数据的值,更改必须传播到所有其他具有该数据副本的缓存中。这种更改传播可以防止系统违反缓存一致性。数据变更的通知可以通过总线窥探来完成。所有的窥探者都在监视总线上的每一个事务。如果一个修改共享缓存块的事务出现在总线上,所有的窥探者都会检查他们的缓存是否有共享块的相同副本。如果缓存中有共享块的副本,则相应的窥探者执行一个动作以确保缓存一致性。这个动作可以是刷新缓存块或使缓存块失效。它还涉及到缓存块状态的改变,这取决于缓存一致性协议(cache coherence protocol)。
1.2.2 窥探协议类型:
根据管理写操作的本地副本的方式,有两种窥探协议:
Write-invalidate
当处理器写入一个共享缓存块时,其他缓存中的所有共享副本都会通过总线窥探失效。这种方法确保处理器只能读写一个数据的一个副本。其他缓存中的所有其他副本都无效。这是最常用的窥探协议。MSI、MESI、MOSI、MOESI和MESIF协议属于该类型。
Write-update
当处理器写入一个共享缓存块时,其他缓存的所有共享副本都会通过总线窥探更新。这个方法将写数据广播到总线上的所有缓存中。它比write-invalidate协议引起更大的总线流量。这就是为什么这种方法不常见。Dragon和firefly协议属于此类别。
1.2.3 一致性协议(Coherence protocol)
一致性协议在多处理器系统中应用于高速缓存一致性。为了保持一致性,人们设计了各种模型和协议,如MSI、MESI(又名Illinois)、MOSI、MOESI、MERSI、MESIF、write-once、Synapse、Berkeley、Firefly和Dragon协议。
MESI协议
MESI协议是一个基于写失效的缓存一致性协议,是支持回写(write-back)缓存的最常用协议。也称作伊利诺伊协议 (Illinois protocol,因为是在伊利诺伊大学厄巴纳-香槟分校被发明的)。与写通过(write through)缓存相比,回写缓冲能节约大量带宽。总是有“脏”(dirty)状态表示缓存中的数据与主存中不同。MESI协议要求在缓存不命中(miss)且数据块在另一个缓存时,允许缓存到缓存的数据复制。与MSI协议相比,MESI协议减少了主存的事务数量。这极大改善了性能。
缓存行有4种不同的状态:
已修改Modified (M):缓存行是脏的(dirty),与主存的值不同。如果别的CPU内核要读主存这块数据,该缓存行必须回写到主存,状态变为共享(S).
独占Exclusive (E):缓存行只在当前缓存中,但是干净的–缓存数据同于主存数据。当别的缓存读取它时,状态变为共享;当前写数据时,变为已修改状态。
共享Shared (S):缓存行也存在于其它缓存中且是未修改的。缓存行可以在任意时刻抛弃。
无效Invalid (I):缓存行是无效的

1.2.4 一致性协议工作过程:
(1) 当一个cpu 从内存种加载了改数据,判断此时其他cpu 是否已经加载了改数据,如果没有加载,则此时改自己加载数据的状态值为改cpu独自占用;
(2)如果其他cpu已经加载则标记则标记自己加载的数据状态值为共享状态,修改主内存数据,并且通知其他CPU 修改自己的数据状态为共享状态;
(3)如果当某个cpu 修改了该数据,则将自己加载的数据状态修改为修改状态,并且通知其他CPU 修改自己的数据状态为失效状态;
(4)其他cpu在使用到改数据时发现此时数据已经失效,则从主内存重新都去数据;
1.2.5 数据失效过程:
在这里插入图片描述
CPU0 在写入之前会向其他cpu 发送改数据的失效指令,其他cpu 在接收到改指令将自己加载的该数据置为失效状态,并且发送ack 到CPU0 ,cpu0 在进行写入内存操作;在发送失效指令到接收其他到cpu 的回复指令过程中cpu0 是阻塞的;
但是cpu 的资源非常宝贵,显然这种强一直性的阻塞是不好的;优化:给cpu增加StoreBuffer
在这里插入图片描述
当CPU0发生修改操作后,将值放入storeBuffer,发送一个异步请求到其他cpu中,其他cpu回复ack后,storeBuffer 将数据写入到自己的高速缓存区,然后写入到主内存中,这样CPU0就可以不需要阻塞,可以在这个storeBuffer 异步过程中执行其他指令;
这样异步处理虽然提高了效率但是会带来指令重排序的问题:
1.2.5 指令重排序处理:
在这个storeBuffer 异步过程中执行其他指令,会有可能造成顺序上后面的指令比它之前的指令先进行执行;会有指令重排序的情况出现;
在这里插入图片描述
假设,a,b是共享的变量,正常cpu 将执行指令时 ,先读取到a(初始为0),然后修改a的值为1 ;此时cpu0将a=1 放入到storeBuffer中,然后发起异步请求到其他cpu 中(其他cpu在ack 后将a=1写入到高速缓冲区然后写入到主内存),因为时异步所有cpu0 可以继续向下执行,执行b=1(此时发起的异步请求到其他cpu并且时间上比第一个先行完成);
多线程情况下,此时CPU1,在执行while(b==1) 时,向cpu0读取b的值,有可能为1;然后读取从自己的高速缓冲区获取a的值,有可能还是初始的0;出现 a!=1 的情况;
cpu层面怎么来处理指令重排序问题:
写内存屏障:
CPU 会在写指令后,插入 Store Barrier,能让写入 CPU缓存 中的数据立即写入主存
强制写入主存,CPU 就不会因为性能问题,而考虑指令重排;
读内存屏障:
CPU 会在读指令之前,插入 Load Barrier,会强制使缓存数据失效,读取主存中最新的数据
强制读取主存,避免了缓存与主存的数据一致性问题;

1.2.5 volatile :
基于多线程对共享资源修改后的可见性问题,java 层面定义volatile关键字来处理,volatile可以保证多个线程下 ,某个线程在对共享的变量进行修改后,其它线程可以在使用到改变量时可以获得修改后的变量值,及保证之前线程的操作对之后的线程都可见;

2 volatile:使用:
在被声明为static 的共享资源之前增加volatile 关键字来修饰,以达到线程对共享资源的可见性:

volatile  static  boolean stop = true;

tip:volatile 保证了可见性,但是并没有保证原子性;
3volatile 原理:
既然使用volatile 修饰后,共享资源就可以实现线程的可见性,那么volatile 做了什么事情,使得线程之间对临界资源的修改可见了,答案时通过汇编指令lock 实现。
volatile 编译后生成lock指令,jvm层面保证指令不会重排序,在硬件层面通过不同的系统架构,底层通过各自系统来生成内存屏障;保证指令不会重排序,以此来保证数据的可见性;
volatile 使用条件:
对变量的写入操作不依赖变量的当前值,或者你能确保只有单个线程更新变量的值。
该变量没有包含在具有其他变量的不变式中。

4 volatile和synchronized的区别:
(1)volatile本质是在告诉jvm当前变量在寄存器(工作内存)中的值是不确定的,需要从主存中读取; synchronized则是锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞住;
(2)volatile仅能使用在变量级别;synchronized则可以使用在变量、方法、和类级别的;
(3)volatile仅能实现变量的修改可见性,不能保证原子性;而synchronized则可以保证变量的修改可见性和原子性;
(4)volatile不会造成线程的阻塞;synchronized可能会造成线程的阻塞;
(5)volatile标记的变量不会被编译器优化(内存屏障,禁止指令重排);synchronized标记的变量可以被编译器优化;
(6)在一般的程序中只要加上了 synchronized 就不需要再加上volatile,但是在单例模式则需要。这是因为为了防止指令重排;

5 扩展:
5.1 指令重排序:
当CPU需要写缓存时,发现缓存区被其他CPU占用,则可以先执行后面的读缓存指令(跟异步IO的处理方式类似),指令重排序是一种优化可以提高命令执行的效率;
指令重排序 遵从一定的原则:
(1)程序顺序性规则(as if-serial 语义):
单线程环境下不能改变程序的运行结果;
如果两个指令存在依赖关系也是不能够重排序;
(2)传递性规则:
A 对B可见,B对C 可见;A对C 可见;
(3)Volatile 变量规则:
Volatile 修改变量的写操作,对于后续对改变量的读操作是可见的;
在这里插入图片描述
在这里插入图片描述
对于第一个变量a 的修改,对于第二个volatile 的变量写操作,(No)是不允许重排序的
所以,当flag 修改为true 时,a一定会被修改为1;

(4)监视器锁规则:后续线程获取锁,一定是在之前线程释放了锁;‘
在这里插入图片描述
(5)线程start 规则:
在这里插入图片描述
(6)Join规则:在join 之后对线程中对共享变量的修改时可见的
在这里插入图片描述
Java Happens Before 原则,用于规范如何允许 Java VM 和 CPU 指令重排序以提高性能 。Java Happens Before 原则来定义变量怎么样从主内存中读到高速缓存,或者何时从高速缓存写到主缓存。在java中Java Happens Before 原则主要围绕volatile、synchronized 来实现。

5.2 单例初始化问题:
volatile除了比synchronized性能好以外,还有一个很好的附加功能——禁止指令重排。
不使用volatile:

public class Singleton {  
    private static Singleton singleton;  
    private Singleton (){}  
    public static Singleton getSingleton() {  
    if (singleton == null) {  
        synchronized (Singleton.class) {  
            if (singleton == null) {  
                singleton = new Singleton();  
            }  
        }  
    }  
    return singleton;  
    }  
}  

以上代码,我们通过使用synchronized对Singleton.class进行加锁,可以保证同一时间只有一个线程可以执行到同步代码块中的内容,也就是说singleton = new Singleton()这个操作只会执行一次,这就是实现了一个单例。
但是,当我们在代码中使用上述单例对象的时候有可能发生空指针异常。
假设Thread1 和 Thread2两个线程同时请求Singleton.getSingleton方法:
在这里插入图片描述
Step1:Thread1执行到第8行,开始进行对象的初始化;
Step2:Thread2执行到第5行,判断singleton == null;
Step3:Thread2经过判断发现singleton != null,所以执行第12行,返回singleton;
Step4:Thread2拿到singleton对象之后,开始执行后续的操作,比如调用singleton.call();
以上过程,看上去并没有什么问题,但是在Step4,Thread2在调用singleton.call()的时候,是有可能抛出空指针异常的,因为在Step3,Thread2拿到的singleton对象并不一定是一个完整的对象。
分析singleton = new Singleton()这行代码,其执行包括3个步骤:
JVM为对象分配一块内存M;
在内存M上为对象进行初始化;
将内存M的地址赋值给singleton变量;
在这里插入图片描述
由于以上过程并不是一个原子操作,并且编译器可能会进行重排序,如果以上步骤被重排成:
JVM为对象分配一块内存M;
将内存的地址复制给singleton变量;
在内存M上为对象进行初始化;
在这里插入图片描述
也就是说,在Thread1还没有为对象进行初始化的时候,Thread2进来判断singleton==null就可能提前得到一个false,则会返回一个不完整的sigleton对象。
用volatile可以避免这个问题,因为volatile可以避免指令重排。

public class Singleton {  
    private volatile static Singleton singleton;  
    private Singleton (){}  
    public static Singleton getSingleton() {  
    if (singleton == null) {  
        synchronized (Singleton.class) {  
            if (singleton == null) {  
                singleton = new Singleton();  
            }  
        }  
    }  
    return singleton;  
    }  
}  

参考:
1 CPU缓存架构&缓存一致性协议详解
2 为什么有了synchronized,还需要volatile?

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值