说一下对volatile的理解

volatile关键字是Java虚拟机提供的的最轻量级的同步机制,它作为一个修饰符,用来修饰变量。它保证变量对所有线程可见性,禁止指令重排,但是不保证原子性。

Java 内存模型/JMM(Java Memory Model)

Java 内存模型(JSR-133)屏蔽了硬件、操作系统的差异,实现让Java程序在各种平台下都能达到一致的并发效果,规定了一个线程如何和何时可以看到由其他线程修改过后的共享变量的值,以及在必须时如何同步的访问共享变量,JMM使用内存屏障提供了java程序运行时统一的内存模型。

JMM 最重要的三点内容:重排序、原子性、内存可见性。

原子性

由Java内存模型来直接保证的原子性变量操作包括read、load、use、assign、store、write这六个, 我们大致可以认为,基本数据类型的访问、读取和赋值都是具备原子性的。

内存可见性

可见性就是指当一个线程修改了共享变量的值时,其他线程能够立即得知这个修改。Java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性的。

重排序

指令重排序是JVM为了优化指令,提高程序运行效率,在不影响 单线程程序 执行结果的前提下,尽可能地提高并行度。编译器、处理器也遵循这样一个目标。注意是单线程。多线程的情况下指令重排序就会给程序带来问题。编译器和处理器都有可能优化执行顺序。

volatile关键字是怎么解决内存可见性和重排序的?

操作系统层面

CPU层提供了两种解决方案:总线锁和缓存一致性(缓存锁)。

1、总线锁

前端总线(也叫CPU总线)是所有CPU与芯片组连接的主干道,负责CPU与外界所有部件的通信,包括高速缓存、内存、北桥,其控制总线向各个部件发送控制信号、通过地址总线发送地址信号指定其要访问的部件、通过数据总线双向传输。

比如CPU1要操作共享内存数据时,先在总线上发出一个LOCK#信号,其他处理器就不能操作缓存了该共享变量内存地址的缓存,也就是阻塞了其他CPU,使该处理器可以独享此共享内存。

很显然,这样的做法代价十分昂贵,于是为了降低锁粒度,CPU引入了缓存锁。

2、缓存一致性(缓存锁)

缓存一致性:缓存一致性机制整体来说,就是当某块CPU对缓存中的数据进行操作了之后,会通知其他CPU放弃储存在它们内部的缓存,或者从主内存中重新读取。

缓存锁的核心机制就是基于缓存一致性协议来实现的,即一个处理器的缓存回写到内存会导致其他处理器的缓存无效,IA-32处理器和Intel 64处理器使用MESI实现缓存一致性协议。

缓存一致性是一个协议,不同处理器的具体实现会有所不同,MESI是一种比较常见的缓存一致性协议实现。

缓存一致性协议的实现方式

写缓存(Store Buffer)

基于存储缓存,CPU将要写入内存数据先写入Store Bufferes中,同时发送消息,然后就可以继续处理其他指令了。当收到所有其他CPU的失效确认(Invalidate Acknowledge)时,数据才会最终被提交。可以理解为异步写入不需要等待其他CPU响应结果。

举例说明一下Store Bufferes的执行流程:比如将内存中共享变量a的值由1修改为66。

第一步,CPU-0把a=66写入Store Bufferes中,然后发送Invalid消息给其他CPU,无需等待其他CPU相应,便可继续执行其他指令了。

第二步,当CPU-0收到其他所有CPU对Invalid通知的相应之后,再把Store Bufferes中的共享变量同步到缓存和主内存中。

Store Forward(存储转发)

Store Bufferes的引入提升了CPU的利用效率,但又带来了新的问题。在上述第一步中,Store Bufferes中的数据还未同步到CPU-0自己的缓存中,如果此时CPU-0需要读取该变量a,缓存中的数据并不是最新的,所以CPU需要先读取Store Bufferes中是否有值。如果有则直接读取,如果没有再到自己缓存中读取,这就是所谓的”Store Forward“。

无效化队列(Invalidate Queue)

CPU将数据写入Store Bufferes的同时还会发消息给其他CPU,由于Store Bufferes空间较小,且其他CPU可能正在处理其他事情,没办法及时回复,这个消息就会陷入等待。

为了避免接收消息的CPU无法及时处理Invalid失效数据的消息,造成CPU指令等待,就在接收CPU中添加了一个异步消息队列。消息发送方将数据失效消息发送到这个队列中,接收CPU返回已接收,发送方CPU就可以继续执行后续操作了。而接收方CPU再慢慢处理”失效队列“中的消息。

由于缓存一致性协议可能还会存在乱序问题,所有就需要通过内存屏障来解决乱序问题。

使用内存屏障后,写入数据时会保证所有指令都执行完毕,这样就能保证修改过的数据能够即时暴露给其他CPU。而读取数据时,能够保证所有“失效队列”消息都消费完毕。然后,CPU根据Invalid消息判断自己缓存状态,正确读写数据。

3、内存屏障

CPU层面提供了三类内存屏障:

写屏障(Store Memory Barrier):告诉处理器在写屏障之前将所有存储在存储缓存(store bufferes)中的数据同步到主内存。也就是说当看到Store Barrier指令,就必须把该指令之前所有写入指令执行完毕才能继续往下执行。

读屏障(Load Memory Barrier):处理器在读屏障之后的读操作,都在读屏障之后执行。也就是说在Load屏障指令之后就能够保证后面的读取数据指令一定能够读取到最新的数据。

全屏障(Full Memory Barrier):兼具写屏障和读屏障的功能。确保屏障前的内存读写操作的结果提交到内存之后,再执行屏障后的读写操作。

总之,内存屏障的作用可以通过防止CPU乱序执行来保证共享数据在多线程下的可见性。

Jave层面:

JVM规范的happens-before规则,一共八种,volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。说白了就是JVM规定如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。

HotSpot实现是用“内存屏障” 的方式来防止指令被重排序,为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。大多数的处理器都支持内存屏障的指令。

  • LoadLoad Barriers:示例,Load1;LoadLoad;Load2,确保Load1数据的装载先于Load2及所有后续指令的装载;
  • StoreStore Barriers:示例,Store1;StoreStore;Store2,确保Store1数据对其他处理器可见(刷新到内存)先于Store2及所有后续存储指令的存储;
  • LoadStore Barriers:示例,Load1;LoadStore;Store2,确保Load1数据装载先于Store2及所有后续存储指令刷新到内存;
  • StoreLoad Barriers:示例,Store1;StoreLoad;Load2,确保Store1数据对其他处理器变得可见(刷新到内存)先于Load2及所有后续装载指令的装载。StoreLoad Barriers会使该屏障之前的所有内存访问指令(存储和装载指令)完成之后,才执行该屏障之后的内存访问指令。

JMM内存屏障插入策略:

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

volatile的使用场景?

项目当中的使用场景

1.状态标志,比如我们工程中经常用一个变量标识程序是否启动、初始化完成、是否停止等

2.懒汉式单例模式,我们常用的 double-check 的单例模式

各个框架当中的使用场景

1.AQS中的state字段是用volatile修饰的,配合CAS操作做到无锁安全修改共享变量。

2.J.U.C包中的各种原子操作类,里面的操作的数据也是用volatile修饰的,配合CAS操作做到无锁安全修改共享变量。

3.线程池中的ctl字段,也就是线程状态+线程数量字段。

基本上有CAS操作的属性都需要用volatile修饰。volatile(可见性、有序性)+CAS(原子性)

参考文章:

讲真,你是没懂 volatile 的设计原理,所以不会用

你可能感兴趣的:(说一下对volatile的理解)