volatile的原理解析

在分析volatile原理之前,要先会以下知识点:

1、JMM的基本知识

2、CPU缓存

3、happens-before规则

4、指令重排

一、JMM(java的内存模型)

1 并发编程模型的两个关键问题

  • 线程间如何通信?即:线程之间以何种机制来交换信息

  • 线程间如何同步?即:线程以何种机制来控制不同线程间操作发生的相对顺序

有两种并发模型可以解决这两个问题:

  • 消息传递并发模型

  • 共享内存并发模型

这两种模型之间的区别如下表所示:

volatile的原理解析_第1张图片

 

在Java中,使用的是共享内存并发模型

2、 Java内存模型的抽象结构

2.1 运行时内存的划分

先谈一下运行时数据区,下面这张图相信大家一点都不陌生: volatile的原理解析_第2张图片

对于每一个线程来说,栈都是私有的,而堆是共有的。

也就是说在栈中的变量(局部变量、方法定义参数、异常处理器参数)不会在线程之间共享,也就不会有内存可见性(下文会说到)的问题,也不受内存模型的影响。而在堆中的变量是共享的,本文称为共享变量。

所以,内存可见性是针对的共享变量

2.2 既然堆是共享的,为什么在堆中会有内存不可见问题?

这是因为现代计算机为了高效,往往会在高速缓存区中缓存共享变量,因为cpu访问缓存区比访问内存要快得多。

线程之间的共享变量存在主内存中,每个线程都有一个私有的本地内存,存储了该线程以读、写共享变量的副本。本地内存是Java内存模型的一个抽象概念,并不真实存在。它涵盖了缓存、写缓冲区、寄存器等。

Java线程之间的通信由Java内存模型(简称JMM)控制,从抽象的角度来说,JMM定义了线程和主内存之间的抽象关系。JMM的抽象示意图如图所示:

volatile的原理解析_第3张图片

 

从图中可以看出: 1. 所有的共享变量都存在主内存中。 2. 每个线程都保存了一份该线程使用到的共享变量的副本。 3. 如果线程A与线程B之间要通信的话,必须经历下面2个步骤: 1. 线程A将本地内存A中更新过的共享变量刷新到主内存中去。 2. 线程B到主内存中去读取线程A之前已经更新过的共享变量。

所以,线程A无法直接访问线程B的工作内存,线程间通信必须经过主内存。

注意,根据JMM的规定,线程对共享变量的所有操作都必须在自己的本地内存中进行,不能直接从主内存中读取

所以线程B并不是直接去主内存中读取共享变量的值,而是先在本地内存B中找到这个共享变量,发现这个共享变量已经被更新了,然后本地内存B去主内存中读取这个共享变量的新值,并拷贝到本地内存B中,最后线程B再读取本地内存B中的新值。

那么怎么知道这个共享变量的被其他线程更新了呢?这就是JMM的功劳了,也是JMM存在的必要性之一。JMM通过控制主内存与每个线程的本地内存之间的交互,来提供内存可见性保证

Java中的volatile关键字可以保证多线程操作共享变量的可见性以及禁止指令重排序,synchronized关键字不仅保证可见性,同时也保证了原子性(互斥性)。在更底层,JMM通过内存屏障来实现内存的可见性以及禁止重排序。为了程序员的方便理解,提出了happens-before,它更加的简单易懂,从而避免了程序员为了理解内存可见性而去学习复杂的重排序规则以及这些规则的具体实现方法。这里涉及到的所有内容后面都会有专门的章节介绍。

2.3 JMM与Java内存区域划分的区别与联系

上面两小节分别提到了JMM和Java运行时内存区域的划分,这两者既有差别又有联系:

  • 区别

    两者是不同的概念层次。JMM是抽象的,他是用来描述一组规则,通过这个规则来控制各个变量的访问方式,围绕原子性、有序性、可见性等展开的。而Java运行时内存的划分是具体的,是JVM运行Java程序时,必要的内存划分。

  • 联系

    都存在私有数据区域和共享数据区域。一般来说,JMM中的主内存属于共享数据区域,他是包含了堆和方法区;同样,JMM中的本地内存属于私有数据区域,包含了程序计数器、本地方法栈、虚拟机栈。

实际上,他们表达的是同一种含义,这里不做区分。

二、CPU的缓存机制

下面一个关于数据存储的结构图:

volatile的原理解析_第4张图片

由上图可以知道cpu读取数据有以下规律:

1、CPU的运存性能远大于内存的,所以增加缓存区以提高性能。

2、缓存区中一般有三层:性能L1>L2>L3。其中一二层是线程独占的,第三层是线程共享的。(缓存是集成到cpu上,但和cpu是相对独立存在的)

3、缓存区和主内存的数据交换是以缓存行(一个缓存行一般 64 Bytes)为单位的,一个缓存行可能存在多个变量。

(所以数组的速度>链表:数组的内存一般是连续存储的,所以每次在主内存取时会多个一起放在缓存行取过来,而链表是分散存储的,则要取多次)(这里的缓存行也导致了所谓的伪内存问题,后面会讲)

4、上面的JMM的线程工作空间可以比拟这里的寄存器+高级缓存,但要注意不是==关系,因为JMM本就是一个虚幻的概念并没有具体的物理划分。

注意:这里为什么说一般最后一层为共享缓存(L3)?因为各个cpu的缓存区域的最后一层缓存是和总线连接的,所以就形成的多核cpu对应的缓存区的最后一层都和总线连接,而总线存在嗅探机制,所以就形成了多核cpu的L3连成一个

下面对于该存储结构存在的问题进行解析

1、各个存储区域大概存的是什么数据?

volatile的原理解析_第5张图片

即上一层的存储区域会存储下一层取来的数据,则存在一个x变量同时存在L0-L6。

2、多核cpu下大概交换数据图的:

volatile的原理解析_第6张图片

由图可知每个cpu会有对应的寄存器和缓存,每个缓存区的L3缓存层与主内存交互,而其中的缓存一致性是通过MSEI协议实现的(保证缓存区中的数据的一致性)。且图中的L3是多核cpu共享的。

3、什么是MSEI协议?

volatile的原理解析_第7张图片

此时用一个例子来解释MSEI协议的原理(同时这也是伪内存出现的时候):

以上面多线程中计算 x 加1的操作为例;

(1)T1 从主存中读取 x = 1,放到 CPU1 的缓存,状态标记为独占(E),并且 CPU1 时刻监听总线当中其他CPU对这块内存的操作(总线嗅探机制);

(2)若 T1 计算之后还没有会写到主存中,此时 T2 由CPU2计算x加1的操作,从主存中读取x = 1,放入CPU2的缓存中;

(3)当 T2 通过总线从 主存中读取了 x = 1,T1的CPU1通过总线嗅探机制知道了T2从主存中读取了同块内存,则将 CPU1 中的状态修改为共享(S)

CPU2 中的状态标记为共享(S),同时 CPU2 时刻监听总线当中其他CPU对这块内存的操作;

(4)此时,T1通过 CPU1 计算完成了的结果:x = 2,需要将结果回写到主存中;首先锁住CPU1的缓存行,将CPU1 的缓存的状态标记为修改(M),接着发送消息给总线,其他的CPU一直在嗅探,嗅探到消息之后将其他的CPU的缓存状态标记为无效(I),此时CPU2的缓存状态被修改的无效;

(5)接着,CPU1 将计算结果 x = 2 回写到主存中(主存中的数据此时变为 x = 2),接着CPU1 将缓存的状态标记为独占(E), 丢弃掉其他CPU缓存状态为无效的数据;

(6)接着CPU2 重新从主存中读取数据(x = 2),通过总线嗅探机制CPU1又嗅探到CPU2读取了主存中的这块内存区域,则将 CPU1的缓存状态和CPU2的缓存状态都设置为共享(S)....(这里是对缓存行的同一个变量进行读写)

4、伪共享问题?

以上面多线程中多个线程修改L3中同一缓存行的数据为例:即修改同一缓存行的x,y值

volatile的原理解析_第8张图片

(1)线程1和线程2都从主内存中获取同一缓存行的x,y值;

(2)此时线程1对x进行++操作,操作完刷新到二三层缓存/主内存,而由于总线嗅探机制(L3共享缓存)此时cpu2发现L3的缓存行出现变化,由于MSEI协议,此时cpu2嗅探到消息后则会将cpu2的一二层缓存的x、y缓存行置为无效)。

(3)如果此时线程2要去获取y值,此时先去L1和L2缓存看发现都无效,则会去L3获取刷新了的缓存行。(更甚者要去主内存获取)

因为MSEI协议保证了缓存一致性,但这导致多个线程访问同一缓存行的不同变量时独占的一二级缓存会失效导致性能降低。

 这就是伪共享的问题。

那怎么解决该问题?

前面说到缓存和主内存的交互单位是缓存行,而缓存行一般是64位(当然不一定,看cpu),此时只要填满一个缓存行剩余的空间即可(即如果此时剩下32位则将32位填满,可以避免该变量出现伪内存现象)

java8用了@Contended注解帮我们解决了伪共享问题(concurrenthashmap里面的cell数组就用了:在计算size中的cell数组)具体可以看https://www.jianshu.com/p/a4358d39adac

三、happens-before规则

1、JMM中的happens-before是什么?

A happens-before B:则表示A运行后的结果对B可见。例如A中对变量x值改为2,此时B知道A的结果就知道此时x为2。

2、哪些情况下运用了happens-before?

根据Java内存模型中的规定,可以总结出以下几条happens-before规则。Happens-before的前后两个操作不会被重排序且后者对前者的内存可见。

  • 程序次序法则:线程中的每个动作A都happens-before于该线程中的每一个动作B,其中,在程序中,所有的动作B都能出现在A之后。(即按照代码的自然顺序)
  • 监视器锁(管程)法则:对一个监视器锁的解锁 happens-before于每一个后续对同一监视器锁的加锁。
  • volatile变量法则:对volatile域的写入操作happens-before于每一个后续对同一个域的读写操作。
  • 线程启动法则:在一个线程里,对Thread.start的调用会happens-before于每个启动线程的动作。
  • 线程终结法则:线程中的任何动作都happens-before于其他线程检测到这个线程已经终结、或者从Thread.join调用中成功返回,或Thread.isAlive返回false。
  • 中断法则:一个线程调用另一个线程的interrupt happens-before于被中断的线程发现中断。
  • 终结法则:一个对象的构造函数的结束happens-before于这个对象finalizer的开始。
  • 传递性:如果A happens-before于B,且B happens-before于C,则A happens-before于C。

除此之外,Java内存模型对volatile和final的语义做了扩展。对volatile语义的扩展保证了volatile变量在一些情况下不会重排序,volatile的64位变量double和long的读取和赋值操作都是原子的。对final语义的扩展保证一个对象的构建方法结束前,所有final成员变量都必须完成初始化(前提是没有this引用溢出)。

 

Java内存模型关于重排序的规定,总结后如下表所示。

volatile的原理解析_第9张图片

表中"第二项操作"的含义是指,第一项操作之后的所有指定操作。如,普通读不能与其之后的所有volatile写重排序。另外,JMM也规定了上述volatile和同步块的规则尽适用于存在多线程访问的情景。例如,若编译器(这里的编译器也包括JIT,下同)证明了一个volatile变量只能被单线程访问,那么就可能会把它做为普通变量来处理。
留白的单元格代表允许在不违反Java基本语义的情况下重排序。例如,编译器不会对对同一内存地址的读和写操作重排序,但是允许对不同地址的读和写操作重排序。

除此之外,为了保证final的新增语义。JSR-133对于final变量的重排序也做了限制。

  • 构建方法内部的final成员变量的存储,并且,假如final成员变量本身是一个引用的话,这个final成员变量可以引用到的一切存储操作,都不能与构建方法外的将当期构建对象赋值于多线程共享变量的存储操作重排序。例如对于如下语句
    x.finalField = v; ... ;构建方法边界sharedRef = x;
    v.afield = 1; x.finalField = v; ... ; 构建方法边界sharedRef = x;
    这两条语句中,构建方法边界前后的指令都不能重排序。
  • 初始读取共享对象与初始读取该共享对象的final成员变量之间不能重排序。例如对于如下语句
    x = sharedRef; ... ; i = x.finalField;
    前后两句语句之间不会发生重排序。由于这两句语句有数据依赖关系,编译器本身就不会对它们重排序,但确实有一些处理器会对这种情况重排序,因此特别制定了这一规则。

四、指令重排

在Java中看似顺序的代码在JVM中,可能会出现编译器或者CPU对这些操作指令进行了重新排序;在特定情况下,指令重排将会给我们的程序带来不确定的结果.....带来什么结果?(可能性能更高了,也可能输出结果错误了,而此时带来的性能效益是大于出错的,这时就要看看怎么解决这些结果错误的情况)

1、什么是指令重排

        在计算机执行指令的顺序在经过程序编译器编译之后形成的指令序列,一般而言,这个指令序列是会输出确定的结果;以确保每一次的执行都有确定的结果。但是,一般情况下,CPU和编译器为了提升程序执行的效率,会按照一定的规则允许进行指令优化。

2、为什么指令重排序可以提高性能?

简单地说,每一个指令都会包含多个步骤,每个步骤可能使用不同的硬件。因此,流水线技术产生了,它的原理是指令1还没有执行完,就可以开始执行指令2,而不用等到指令1执行结束之后再执行指令2,这样就大大提高了效率。

但是,流水线技术最害怕中断,恢复中断的代价是比较大的,所以我们要想尽办法不让流水线中断。指令重排就是减少中断的一种技术。

我们分析一下下面这个代码的执行情况:

a = b + c;

d = e - f ;

先加载b、c(注意,即有可能先加载b,也有可能先加载c),但是在执行add(b,c)的时候,需要等待b、c装载结束才能继续执行,也就是增加了停顿,那么后面的指令也会依次有停顿,这降低了计算机的执行效率。

为了减少这个停顿,我们可以先加载e和f,然后再去加载add(b,c),这样做对程序(串行)是没有影响的,但却减少了停顿。既然add(b,c)需要停顿,那还不如去做一些有意义的事情。

综上所述,指令重排对于提高CPU处理性能十分必要。虽然由此带来了乱序的问题,但是这点牺牲是值得的。

指令重排一般分为以下三种:

  • 编译器优化重排

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

  • 指令并行重排

    现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性(即后一个执行的语句无需依赖前面执行的语句的结果),处理器可以改变语句对应的机器指令的执行顺序。

  • 内存系统重排

    由于处理器使用缓存和读写缓存冲区,这使得加载(load)和存储(store)操作看上去可能是在乱序执行,因为三级缓存的存在,导致内存与缓存的数据同步存在时间差。

指令重排可以保证串行语义一致,但是没有义务保证多线程间的语义也一致。所以在多线程下,指令重排序可能会导致一些问题。

3、数据依赖性

主要指不同的程序指令之间的顺序是不允许进行交换的,即可称这些程序指令之间存在数据依赖性。

那哪些指令不允许重排?(即哪些情况重排后会出现结果错误)

主要的例子如下:

名称       代码示例         说明  
写后读     a = 1;b = a;    写一个变量之后,再读这个位置。  
写后写     a = 1;a = 2;    写一个变量之后,再写这个变量。  
读后写     a = b;b = 1;    读一个变量之后,再写这个变量。

进过分析,发现这里每组指令中都有写操作,这个写操作的位置是不允许变化的,否则将带来不一样的执行结果。
编译器将不会对存在数据依赖性的程序指令进行重排,这里的依赖性仅仅指单线程情况下的数据依赖性;多线程并发情况下,此规则将失效。(即多线程下重排还是没解决)

4、as-if-serial保证单线程的重排序

单线程下as-if-serial语义来控制指令重排的错误

as-if-serial语义的意思是,所有的动作(Action)都可以为了优化而被重排序,但是必须保证它们重排序后的结果和程序代码本身的应有结果是一致的。Java编译器、运行时和处理器都会保证单线程下的as-if-serial语义
比如,为了保证这一语义,重排序不会发生在有数据依赖的操作之中。

double pi = 3.14;         //A
double r = 1.0;           //B
double area = pi * r * r; //C

分析代码:

A->C  B->C; A,B之间不存在依赖关系; 故在单线程情况下, A与B的指令顺序是可以重排的,C不允许重排,必须在A和B之后。
as-if-serial语义把单线程程序保护了起来,遵守as-if-serial语义的编译器,runtime 和处理器共同为编写单线程程序的程序员创建了一个幻觉:单线程程序是按程序的顺序来执行的。as-if-serial语义使单线程程序员无需担心重排序会干扰他们,也无需担心内存可见性问题。核心点还是单线程,多线程情况下不遵守此原则。

5、多线程下的指令重排

例子1:

在线程A中:

context = loadContext();
inited = true;
在线程B中:
while(!inited ){     //根据线程A中对inited变量的修改决定是否使用context变量
    sleep(100);
} doSomethingwithconfig(context);

假设线程A中发生了指令重排序:
inited = true;
context = loadContext();

那么B中很可能就会拿到一个尚未初始化或尚未初始化完成的context,从而引发程序错误。

例子2:指令重排导致单例模式失效
我们都知道一个经典的懒加载方式的双重判断单例模式:(错误的安全单例写法)

public class Singleton {
    private static Singleton instance = null;
    private Singleton() { }
    public static Singleton getInstance() {
        if(instance == null) {
            synchronzied(Singleton.class) {
                if(instance == null) {
                    instance = new Singleton();  //非原子操作
                }
            }
        }
        return instance;
    }
}
看似简单的一段赋值语句:instance= new Singleton(),但是它并不是一个原子操作,其实际上可以抽象为下面几条JVM指令:
memory =allocate();       //1:分配对象的内存空间 
ctorInstance(memory);     //2:初始化对象 
instance =memory;         //3:设置instance指向刚分配的内存地址
上面操作2依赖于操作1,但是操作3并不依赖于操作2,所以JVM是可以针对它们进行指令的优化重排序的,经过重排序后如下:
memory =allocate();     //1:分配对象的内存空间 
instance =memory;       //3:instance指向刚分配的内存地址,此时对象还未初始化
ctorInstance(memory);   //2:初始化对象

可以看到指令重排之后,instance指向分配好的内存放在了前面,而这段内存的初始化被排在了后面。
在线程A执行这段赋值语句,在初始化分配对象之前就已经将其赋值给instance引用,恰好另一个线程进入方法判断instance引用不为null,然后就将其返回使用,导致出错。

volatile的原理解析_第10张图片

注意:这里的锁是安全的,因为线程2没有进去锁代码块中。(这里再提一个问题,如果没有双重检测会怎样?)

(这里提出一个疑问不是加锁了吗?锁不是能保证有序性吗?对的,syn锁是能保证有序性,但它不能禁止指令重排,所以很多时候syn还是要搭配volatile来解决问题指令重排的问题就像安全的单例。volatile和syn都能保证有序性,但volatile还能解决指令重排的问题,具体syn是怎么解决有序性的在后面syn的原理分析的文章会提到。syn具体实现原理是当对对象进行加锁的时候,由于是排它锁(也是可重入锁)所以只有一个线程可拥有该锁,也就是说是单线程控制,也就是锁符合了前面as-if-serial语义(能够保证数据依赖的代码不重排,只能解决单线程重排问题,jvm自己解决的部分不重排,但多线程下非数据依赖的代码重排序也会导致出错,而锁存在被打断切换线程的问题,所以syn在多线程下非依赖性代码的重排问题也就出现了),这里的syn的有序性保证了结果一致。)那怎么解决这个单例安全(syn解决了)且只产生一个对象(volatile解决),解决方案:例子1中的inited和例子2中的instance以关键字volatile修饰之后,就会阻止JVM对其相关代码进行指令重排,这样就能够按照既定的顺序指执行

6、怎么解决指令重排?

那就是传说的volatile了。下面将volatile可以禁止指令重排的原理。

五、volatile原理

volatile关键字可以保证变量的可见性,因为对volatile的操作都在Main Memory(主内存)中,而Main Memory是被所有线程所共享的,这里的代价就是牺牲了性能,无法利用寄存器或Cache,因为它们都不是全局的,无法保证可见性,可能产生脏读

volatile的可见性:强制对缓存的修改写入主内存,然后由于缓存一致性其他线程的数据无效,获取数据从主内存获取。

volatile还有一个作用就是局部阻止重排序的发生(在JDK1.5之后,可以使用volatile变量禁止指令重排序),对volatile变量的操作指令都不会被重排序,因为如果重排序,又可能产生可见性问题。

在保证可见性方面,锁(包括显式锁、对象锁)以及对原子变量的读写都可以确保变量的可见性。

但是实现方式略有不同,例如同步锁保证得到锁时从内存里重新读入数据刷新缓存,释放锁时将数据写回内存以保数据可见,而volatile变量干脆都是读写内存。

volatile关键字通过提供"内存屏障"的方式来防止指令被重排序,为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。
大多数的处理器都支持内存屏障的指令。
对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎不可能,为此,Java内存模型采取保守策略。下面是基于保守策略的JMM内存屏障插入策略:
在每个volatile写操作的前面插入一个StoreStore屏障。
在每个volatile写操作的后面插入一个StoreLoad屏障。
在每个volatile读操作的后面插入一个LoadLoad屏障。
在每个volatile读操作的后面插入一个LoadStore屏障。

 

内存屏障

内存屏障(Memory Barrier,或有时叫做内存栅栏,Memory Fence)是一种CPU指令,用于控制特定条件下的重排序和内存可见性问题。Java编译器也会根据内存屏障的规则禁止重排序。
内存屏障可以被分为以下几种类型(load:读取、加载。store:存储)
volatile的原理解析_第11张图片

volatile的原理解析_第12张图片

volatile写插入屏障示例:

volatile的原理解析_第13张图片

volatile读插入屏障示例:

volatile的原理解析_第14张图片

这样就利用了内存屏障来解决了重排序问题。

store:即将工作内存的值写到主内存里。load:读取主内存的值到工作内存。

六、总结volatile怎么实现可见性+有序性

1、可见性、有序性:

(1)读操作直接从主内存中获取,写操作完后立即将值刷新到主内存,且由于MSEI协议其他cpu缓存的旧值会失效。

写操作完会立即将数据刷新到主内存:底层实现主要通过汇编Lock前缀指令,它会锁住这块内存区域的缓存(缓存行锁定)并回写到主内存中。

用volatile修饰后编译后的代码中会有lock前缀指令,lock前缀指令实际上相当于一个内存屏障,内存屏障会提供3个功能:(禁止重排序和写主内存。对缓存行无效是MESI协议的)

1. 它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;

2. 它会强制将对缓存的修改操作立即写入主存; 

 3. 如果是写操作,它会导致其他CPU中对应的缓存行无效。

java是用volatile实现禁止重排序的(内存屏障),为了解决这个矛盾,我们通过JVM保证的Happens-Before规则,能够确保程序按照我们的预期执行,从而消除了重排序和CPU缓存带来的负面影响,保证了多线程程序的正确性。

(happens-before用来实现操作可见性的,可以不同/同一线程)

到这里提出几个问题:

A happens-before B 即B对A的操作可见性,有一个appens-before规则是对于volatile的,所以是这实现了volatile的可见性?happens-before和内存屏障是什么关系?happens-before是一种规则,而内存屏障是对该规则的实现?

(对于cpu中L3是共享缓存?线程间的MSEI通过总线的?lock指令是对缓存行到写入主内存之间加锁的?为什么volatile可以保证每次都去主内存找数据?MSEI协议是缓存一致性保证,线程1的缓存行改变了会刷新到L1-L3,L3是线程共享,此时其他cpu的L1/L2的缓存行失效,那L3也会失效吗,如果不失效那不是要去L3获取,那volatile是怎么保证去主内存获取的????)

本文主要参照:https://tech.meituan.com/(美团)

 

你可能感兴趣的:(深入了解并发编程)