volatile的作用/特性?

  1. 保证内存可见性;
  2. 禁止指令重排序;

使用volatile的例子

  1. 共享变量的标记
  2. 单例模式中的double check

volatile可以将long或者double类型的变量的读写变为原子操作

JMM模型

Java虚拟机规范定义了一种Java内存模型(JMM),目的是为了屏蔽掉各种硬件和底层操作系统的内存访问差异,让Java程序在各种平台上都能达到一致的内存访问效果。

简单来说,CPU和内存的速度不是数量级的,所以在CPU中加了一些高速缓存,尽量降低内存访问拖CPU的后腿。

在Java内存模型中,就对上述的优化进行了一波抽象。JMM规定所有的变量都是存储的主存中的,每个线程中还包括自己的工作内存(可以理解为高速缓存),所以线程的操作都是以自己的工作内存为主,它们只能访问自己的工作内存,工作前后都要把值同步会主存中。

JMM就是围绕如果在并发的过程中处理原子性、可见性、有序性建立的。

原子性

原子性就是指操作不可中断,要么没有执行,执行了就一定要做完。

JMM只实现了基本的原子性,像i++这样的操作,必须借助于snychronized或者Lock来保证代码的原子性。线程在释放锁之前,会把变量的值刷回到主存中。

虚拟机规范中允许64位的数据类型(long和double)分为2次32位的操作来处理,但是最新的JDK实现还是实现了原子性操作。

可见性

volatile就是用来保证可见性的。当一个变量被volatile修饰时,那么对它的修改会立即刷新到主存中,其它线程读取该变量也会读取到最新的值。

通过synchronized或Lock也能够保证可见性,线程在释放锁前,也会把值刷会主内存,但是开销比较大。

有序性

JMM是允许编辑器和处理器对指令进行重排序的,但是规定了as-if-serial语义,即不管怎么重排序,都不能改变程序的执行结果(针对单线程)。

JMM的happens-before原则

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()操作成功返回。

interrupt()原则: 对线程interrupt()方法的调用先行发生于被中断线程代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测是否有中断发生

finalize()原则:一个对象的初始化完成先行发生于它的finalize()方法的开始

volatile底层实现机制

有volatile关键字生成的汇编代码会多出一个Lock指令,Lock指令实际上相当于一个内存屏障,有以下功能:

  1. 重排序时不能把后面的指令重排序到内存屏障之前的位置;
  2. 使得本CPU的Cache写入内存;
  3. 写入动作也会引起别的CPU或者别的内核无效化其Cache,相当于让新写入的值对别的线程可见。

MESI和内存屏障都是做了处理?

内存屏障的标准中,讨论的是缓存与内存间的相干性,实际上,同样适用于寄存器与缓存、甚至寄存器与内存间等多级缓存之间。x86架构使用了MESI协议的一个变种,由协议保证三层缓存与内存间的相关性,则内存屏障只需要保证store buffer(可以认为是寄存器与L1 Cache间的一层缓存)与L1 Cache间的相干性。

既然有了MESI协议,是不是就不需要volatile的可见性语义了?

当然不是。

还有三个问题:

并不是所有的硬件架构都提供了相同的一致性保证,JVM需要volatile统一语义(就算是MESI,也只解决CPU缓存层面的问题,没有涉及其他层面)。

可见性问题不仅仅局限于CPU缓存内,JVM自己维护的内存模型中也有可见性问题。使用volatile做标记,可以解决JVM层面的可见性问题。

如果不考虑真·重排序,MESI确实解决了CPU缓存层面的可见性问题;然而,真·重排序也会导致可见性问题。

有了这套协议,不同的cpu之间的缓存就可以保证数据的一致性了。但是依赖这套协议,会大大的降低性能,比如一个核心上某个Shared的cache line打算写,则必须先RFO来获取独占权,当其它核心确认了之后才能转为Exclusive状态来进行修改,假设其余的核心正在处理别的事情而导致一段时间后才回应,则会当申请RFO的核心处于无事可做的状态,这是不可接受的。

于是在每个cpu中,又加入了两个类似于缓存的东西,分别称为Store buffer 与 Invalidate queue。

Store buffer用于缓存写指令,当cpu需要写cache line的时候,并不会执行上述的流程,而是将写指令丢入Store buffer,当收到其它核心的RFO回应后,该指令才会真正执行。

Invalidate queue用于缓存Shared->Invalid状态的指令,当cpu收到其它核心的RFO指令后,会将自身对应的cache line无效化,但是当核心比较忙的时候,无法立刻处理,所以引入Invalidate queue,当收到RFO指令后,立刻回应,将无效化的指令投入Invalidate queue。

这套机制大大提升了性能,但是很多操作其实也就异步化了,某个cpu写入了东西,则该写入可能只对当前CPU可见(读缓存机制会先读Store buffer,再读缓存),而其余的cpu可能无法感知到内存发生了改变,即使Invalidate queue中已有该无效化指令。

为了解决这个问题,引入了读写屏障。写屏障主要保证在写屏障之前的在Store buffer中的指令都真正的写入了缓存,读屏障主要保证了在读屏障之前所有Invalidate queue中所有的无效化指令都执行。有了读写屏障的配合,那么在不同的核心上,缓存可以得到强同步。

所以在锁的实现上,一般lock都会加入读屏障,保证后续代码可以读到别的cpu核心上的未回写的缓存数据,而unlock都会加入写屏障,将所有的未回写的缓存进行回写。

mfence vs lock

LINUX对于x86而言,在为UP体系统架构下,调用barrier()进行通用内存屏障。在SMP体系架构下,若为64位CPU或支持mfence、lfence、sfence指令的32位CPU,则smp_mb()、smp_rmb()、smp_smb()对应通用内存屏障、写屏障和读屏障;而不支持mfence、lfence、sfence指令的32位CPU则smp_mb()、smp_rmb()、smp_smb()对应LOCK操作

lock指令前缀和mutex锁的区别

mutex是操作系统实现互斥锁的一种方式,而lock指令前缀是保证mutex互斥锁实现原子性操作的更底层的机制。

synchronized的重量级锁与mutex锁

重量级锁通过对象内部的监视器(monitor)实现,其中monitor的本质是依赖于底层操作系统的Mutex Lock实现,这需要从用户态切换到内核态,切换成本非常高。

参考文章: 一文解决内存屏障