Volatile原理解读

一、JMM内存模型

JMM就是Java内存模型的意思,也可以说给Java并发内存模型。Java内存模型是一个抽象的模型,并不真实存在。我们看一下它的模型图:

img

由这个图可以得出,每个线程对共享变量有一个自己本地的副本,然后真正的变量其实是存在主内存当中的。线程之间的通信就是通过修改主内存的共享变量来实现通信的。通信的话A首先把自己本地副本写入主内存,然后B再去主内存读A写的值。

通过我的描述你是不是会觉得线程之间通信很简单,其实不然,我们来看一下通信的八个小步骤

  • read 读取,作用于主内存把变量从主内存中读取到本本地内存。
  • load 加载,主要作用本地内存,把从主内存中读取的变量加载到本地内存的变量副本中
  • use 使用,主要作用本地内存,把工作内存中的一个变量值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。、
  • assign 赋值 作用于工作内存的变量,它把一个从执行引擎接收到的值赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
  • store 存储 作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的write的操作。
  • write 写入 作用于主内存的变量,它把store操作从工作内存中一个变量的值传送到主内存的变量中。
  • lock 锁定 :作用于主内存的变量,把一个变量标识为一条线程独占状态。
  • unlock 解锁:作用于主内存变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。

有没有突然之间感觉特别严谨,让我们来看看更严谨的附加属性

img

上面的属性我们是要严格遵守的,如果你之前了解过volatile的特性可能会有点冲突,assign之前必须写到主内存共享变量,那为什么还需要Volatile来保持可见性呐,我们到Volatile中看一下原因吧。

二、Volatile

Java内存模型的三大特性:可见性、原子性、有序性。

首先Volatile是一种轻量级的同步机制(在访问Volatile修饰的字段时不会加锁,也不会阻塞所以是一种轻量级的同步机制),它保证了线程的可见性和有序性。他是怎么实现的我们来看一下。

当我们对非Volatile变量进行读写的时候,每个线程先从内存拷贝变量到CPU缓存(可以理解为上图的线程本地副本)中。如果计算机有多个CPU,每个线程可能在不同的CPU上被处理,这就意味着每个线程可以拷贝到不同的CPU cache中。

声明变量Volatile变量之后,JVM保证了每次读变量都从内存中读,跳过CPU cache这一步。

可见性:

当我们不加Volatile的时候我们的线程A去更新X这个值,它首先会更改本地内存然后再同步到主内存,如果单核CPU的话这个地方是没问题的,但是当多核的话线程B的本地内存没有改变,所以会有不可见性,当我们加了Volatile之后,我们就会绕过本地内存直接去主内存获取所以保证了可见性。

细化:

1、Lock前缀指令会引起处理器缓存会写到内存

当对volatile变量进行写操作的时候,JVM会向处理器发送一条lock前缀的指令,将这个缓存中的变量回写到系统主存中

2、一个处理器的缓存回写到内存会导致其他处理器的缓存失效

处理器使用嗅探技术保证内部缓存 系统内存和其他处理器的缓存的数据在总线上保持一致。

综合上面两条实现原则,我们了解到:如果一个变量被volatile所修饰的话,在每次数据变化之后,其值都会被强制刷入主存。而其他处理器的缓存由于遵守了缓存一致性协议,也会把这个变量的值从主存加载到自己的缓存中。这就保证了一个volatile在并发编程中,其值在多个缓存中是可见的。
为了保证内存的可见性,除了缓存一致性协议还有一个happends-before关系(后期补充)

有序性

我们都知道多线程通过抢占时间片来执行自己的代码体,所以我们会感觉到线程是同时执行完的,除了引入了时间片以外,由于处理器优化和指令重排等,CPU还可能对输入代码进行乱序执行,比如我们拿到数据要执行写库,查询,删除这三个操作,这就会可能要涉及到有序性的问题了。

volatile可以禁止指令重排,这就保证了代码的程序会严格按照代码的先后顺序执行。这就保证了有序性。被volatile修饰的变量的操作,会严格按照代码顺序执行接下来我们就说一下为了实现volatile内存语义JMM是怎样限制重排序(包括编译器重排序和处理器重排序)的。

编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。(首先保证了正确性,再去追求执行效率)
1.在每个volatile写操作前插入StoreStore屏障;对于这样的语句Store1; StoreLoad; Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。
2.在每个volatile写操作后插入StoreLoad屏障;对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。
3.在每个volatile读操作前插入LoadLoad屏障;对于这样的语句Load1;LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
4.在每个volatile读操作后插入LoadStore屏障;对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。
如果编译器无法确定后面是否还会有volatile读或者写的时候,为了安全,编译器通常会在这里插入一个StoreLoad屏障

总结:

volatile 的读性能消耗与普通变量几乎相同,但是写操作稍慢,因为它需要在本地代码中插入许多内存屏障指令来保证处理器不发生乱序执行。

再来一个小补充:

happends-before:

这个的由来是JVM为了屏蔽复杂的编译器和处理的优化给程序员提供的一个友好的规则。相当于对程序员的一个承诺。

是JMM对编译器和处理器重排序的约束原则。通俗来说:只要不改变程序的执行结果(指的是单线程程序和正确同步的多线程程序),编译器和处理器怎么优化都行。

实际案例:

  • 程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。
  • 监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。
  • volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。
  • 传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。
  • start()规则:如果线程A执行操作ThreadB.start()(启动线程B),那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作。
  • join()规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回。

as-if-serial:

对于单线程来说,不管怎么重排序,要保证程序的执行结果不能改变。编译器、runtime和处理器都必须遵守as-if-serial语义。

编译器和处理器不会对存在数据依赖关系的操作做重排序,对于不存在数据依赖的便可以重排序

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

AC、BC不能排、但AB就可以排。

as-if-serial和happends-before的关系

  • as-if-serial语义保证单线程内程序的执行结果不被改变,happens-before关系保证正确同步的多线程程序的执行结果不被改变。
  • as-if-serial语义给编写单线程程序的程序员创造了一个幻境:单线程程序是按程序的顺序来执行的。happens-before关系给编写正确同步的多线程程序的程序员创造了一个幻境:正确同步的多线程程序是按happens-before指定的顺序来执行的。
  • as-if-serial语义和happens-before这么做的目的,都是为了在不改变程序执行结果的前提下,尽可能地提高程序执行的并行度。
posted @ 2021-01-11 12:31  红警贼秀  阅读(56)  评论(0编辑  收藏  举报