volatile 关键字

参考:

volatile 关键字

Java并发编程--Volatile详解

Java并发编程:volatile关键字解析

一、内存模型的相关概念

1. CPU 缓存模型

为什么要弄一个 CPU 高速缓存呢?

类比我们开发网站后台系统使用的缓存(比如 Redis)是为了解决程序处理速度和访问常规关系型数据库速度不对等的问题。 CPU 缓存则是为了解决 CPU 处理速度和内存处理速度不对等的问题。

我们甚至可以把 内存可以看作外存的高速缓存,程序运行的时候我们把外存的数据复制到内存,由于内存的处理速度远远高于外存,这样提高了处理速度。

总结:CPU Cache 缓存的是内存数据用于解决 CPU 处理速度和内存不匹配的问题,内存缓存的是硬盘数据用于解决硬盘访问速度过慢的问题。

为了更好地理解,我画了一个简单的 CPU Cache 示意图如下(实际上,现代的 CPU Cache 通常分为三层,分别叫 L1,L2,L3 Cache):

CPU Cache

CPU Cache 的工作方式:

先复制一份数据到 CPU Cache 中,当 CPU 需要用到的时候就可以直接从 CPU Cache 中读取数据,当运算完成后,再将运算得到的数据写回 Main Memory 中。但是,这样存在 内存缓存不一致性的问题 !比如我执行一个 i++操作的话,如果两个线程同时执行的话,假设两个线程从 CPU Cache 中读取的 i=1,两个线程做了 1++运算完之后再写回 Main Memory 之后 i=2,而正确结果应该是 i=3。

CPU 为了解决内存缓存不一致性问题可以通过制定缓存一致协议或者其他手段来解决。

为了解决缓存不一致性问题,通常来说有以下2种解决方法:

  1)通过在总线加 LOCK 锁的方式

  2)通过缓存一致性协议

  这2种方式都是硬件层面上提供的方式。

  在早期的 CPU 当中,是通过在总线上加 LOCK 锁的形式来解决缓存不一致的问题。因为CPU和其他部件进行通信都是通过总线来进行的,如果对总线加 LOCK 锁的话,也就是说阻塞了其他CPU对其他部件访问(如内存),从而使得只能有一个 CPU 能使用这个变量的内存。比如上面例子中 如果一个线程在执行 i = i +1,如果在执行这段代码的过程中,在总线上发出了 LCOK 锁的信号,那么只有等待这段代码完全执行完毕之后,其他CPU才能从变量i所在的内存读取变量,然后进行相应的操作。这样就解决了缓存不一致的问题。

  但是上面的方式会有一个问题,由于在锁住总线期间,其他 CPU 无法访问内存,导致效率低下。

  所以就出现了缓存一致性协议。最出名的就是Intel 的MESI协议,MESI 协议保证了每个缓存中使用的共享变量的副本是一致的。它核心的思想是:当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态,因此当其他CPU需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取。

img

2. JMM(Java 内存模型

在 JDK1.2 之前,Java 的内存模型实现总是从主存(即共享内存)读取变量,是不需要进行特别的注意的。而在当前的 Java 内存模型下,线程可以把变量保存本地内存(比如机器的寄存器)中,而不是直接在主存中进行读写。这就可能造成一个线程在主存中修改了一个变量的值,而另外一个线程还继续使用它在寄存器中的变量值的拷贝,造成数据的不一致

JMM(Java内存模型)

3、内存间交互操作

  • lock(锁定):作用于主内存的变量,它把一个变量标识为一个线程独占的状态。
  • unlock (解锁)
  • read(读取): 将一个变量的值从主内存传输到线程的工作内存
  • load(载入):将read操作得到的变量值放入工作内存的变量副本中。
  • use(使用):将工作内存中变量的值传递给执行引擎。
  • assign(赋值):把从执行引擎接收到的值赋给工作内存的变量。
  • store(存储):将工作内存中的一个变量的值传递到主内存
  • write(写入):将store操作得到的值放入主内存的变量中。

二、volatile 的两项特性

1、可见性

  • 保证被修饰的变量,对所有线程的可见性。即当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。
  • volatile 变量在各个线程的工作内存中不存在一致性问题,从物理存储的角度来看,各个线程的工作内存中volatile 变量也可以存着不一致的情况,但由于每次使用之前都要先刷新,执行引擎看不到不一致的情况,因此可以认为不存在不一致问题。
  • 但是 Java 里面的运算操作符并非原子操作,导致volatile变量的运算在并发下一样是不安全的。以自增运算为例,一条 i++ 自增运算,在class文件中是由4条字节码指令构成。

2、禁止指令重排序优化

  • 保证变量赋值操作的顺序和程序代码中的执行顺序一致。
  • 以单例模式中双重检验所锁为例
private volatile static Singleton uniqueInstance;

uniqueInstance 采用 volatile 关键字修饰也是很有必要的, uniqueInstance = new Singleton(); 这段代码其实是分为三步执行:

  1. 为 uniqueInstance 分配内存空间
  2. 初始化 uniqueInstance
  3. 将 uniqueInstance 指向分配的内存地址

但是由于 JVM 具有指令重排的特性,执行顺序有可能变成 1>3>2。指令重排在单线程环境下不会出现问题,但是在多线程环境下会导致一个线程获得还没有初始化的实例。例如,线程 T1 执行了 1 和 3,此时 T2 调用 getUniqueInstance() 后发现 uniqueInstance 不为空,因此返回 uniqueInstance,但此时 uniqueInstance 还未被初始化。

使用 volatile 可以禁止 JVM 的指令重排,保证在多线程环境下也能正常运行。

三、对于 volatile 型变量的特殊规则

  • 在工作内存中,每次使用 volatile 型变量前都必须先从主内存刷新最新的值,用于保证能看到其他线程对变量所做的修改。(use操作前必须上一个动作是load操作;load操作的下一个操作必须是use操作;必须连续且一起出现。)
  • 工作内存中,每次修改该变量都必须立刻同步回主内存中,用于保证其他线程可以看到自己对变量的修改。(assign操作 ---- store操作)
  • volatile修饰的变量不会被指令重排序优化,从而保证代码的执行顺序和程序的顺序相同。

四、针对 long 和double型变量的特殊规则

  • 允许虚拟机将没有被volatile修饰的64位数据类型的变量(long,double)的读写操作划分为两次32位操作进行。即允许虚拟机保证自行选择是否保证long,double的读写操作(load,store,read,write)的原子性。
  • 在64位的Java虚拟机中不会出现这种非原子性方法行为,并且在32位机器上,也是可能存在问题,有非原子性访问的风险,但不是一定出现。因为在JDK9 起,Hotspot 增加了对于所有数据类型进行原子性访问的约束,保证原子性,针对double类型也有专门的处理浮点数据的浮点运算器。

五、volatile的原理和实现机制

  下面这段话摘自《深入理解Java虚拟机》:

  “观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令”

  lock前缀指令实际上相当于一个内存屏障(也成内存栅栏),内存屏障会提供3个功能:

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

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

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

六、happens-before原则(先行发生原则)

  • 程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作
  • 锁定规则:一个unLock操作先行发生于后面对同一个锁额lock操作
  • volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作
  • 传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C
  • 线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作
  • 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生
  • 线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行
  • 对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始
posted @ 2021-04-13 20:23  Jayzou11223  阅读(75)  评论(0编辑  收藏  举报