这里我们先来了解一下内存屏障的概念。
内存屏障也叫做栅栏
,它是一种底层原语。它使得 CPU 或编译器在对内存进行操作的时候, 要严格按照一定的顺序来执行, 也就是说在 memory barrier 之前的指令和 memory barrier 之后的指令不会由于系统优化等原因而导致乱序。
内存屏障提供了两个功能。首先,它们通过确保从另一个 CPU 来看屏障的两边的所有指令都是正确的程序顺序;其次它们可以实现内存数据可见性,确保内存数据会同步到 CPU 缓存子系统。
不同计算机体系结构下面的内存屏障也不一样,通常需要认真研读硬件手册来确定,所以我们的主要研究对象是基于 x86 的内存屏障,通常情况下,硬件为我们提供了四种类型的内存屏障。
- LoadLoad 屏障
它的执行顺序是 Load1 ;LoadLoad ;Load2 ,其中的 Load1 和 Load2 都是加载指令。LoadLoad 指令能够确保执行顺序是在 Load1 之后,Load2 之前,LoadLoad 指令是一个比较有效的防止看到旧数据的指令。
- StoreStore 屏障
它的执行顺序是 Store1 ;StoreStore ;Store2 ,和上面的 LoadLoad 屏障的执行顺序相似,它也能够确保执行顺序是在 Store1 之后,Store2 之前。
- LoadStore 屏障
它的执行顺序是 Load1 ;StoreLoad ;Store2 ,保证 Load1 的数据被加载在与这数据相关的 Store2 和之后的 store 指令之前。
- StoreLoad 屏障
它的执行顺序是 Store1 ;StoreLoad ;Load2 ,保证 Store1 的数据被其他 CPU 看到,在数据被 Load2 和之后的 load 指令加载之前。也就是说,它有效的防止所有 barrier 之前的 stores 与所有 barrier 之后的 load 乱序。
JMM 采取了保守策略来实现内存屏障,JMM 使用的内存屏障如下
下面是一个使用内存屏障的示例
class MemoryBarrierTest { int a, b; volatile int v, u; void f() { int i, j; i = a; j = b; i = v; j = u; a = i; b = j; v = i; u = j; i = u; j = b; a = i; } }
这段代码虽然比较简单,但是使用了不少变量,看起来有些乱,我们反编译一下来分析一下内存屏障对这段代码的影响。
从反编译的代码我们是看不到内存屏障的,因为内存屏障是一种硬件层面的指令,单凭字节码是肯定无法看到的。虽然无法看到内存屏障的硬件指令,但是 JSR-133 为我们说明了哪些字节码会出现内存屏障。
- 普通的读类似 getfield 、getstatic 、 不加 volatile 修饰的数组 load 。
- 普通的写类似 putfield 、 putstatic 、 不加 volatile 修饰的数组 store 。
- volatile 读是可以被多个线程访问修饰的 getfield、 getstatic 字段。
- volatile 写是可以被当个线程访问修饰的 putfield、 putstatic 字段。
这也就是说,只要是普通的读写加上了 volatile 关键字之后,就是 volatile 读写(呃呃呃,我好像说了一句废话),并没有其他特殊的 volatile 独有的指令。
根据这段描述,我们来继续分析一下上面的字节码。
a、b 是全局变量,也就是实例变量,不加 volatile 修饰,u、v 是 volatile 修饰的全局变量;i、j 是局部变量。
首先 i = a、j = b 只是把全局变量的值赋给了局部变量,由于是获取对象引用的操作,所以是字节码指令是 getfield 。
从官方手册就可以知晓原因了。
地址在 https://docs.oracle.com/javase/specs/jvms/se7/html/jvms-6.html
由内存屏障的表格可知,第一个操作是普通读写的情况下,只有第二个操作是 volatile 写才会设置内存屏障。
继续向下分析,遇到了 i = v,这个是把 volatile 变量赋值给局部变量,是一种 volatile 读,同样的 j = u 也是一种 volatile 读,所以这两个操作之间会设置 LoadLoad 屏障。
下面遇到了 a = i ,这是为全局变量赋值操作,所以其对应的字节码是 putfield
地址在 https://docs.oracle.com/javase/specs/jvms/se7/html/jvms-6.html
所以在 j = u 和 a = i 之间会增加 LoadStore 屏障。然后 a = i 和 b = j 是两个普通写,所以这两个操作之间不需要有内存屏障。
继续往下面分析,第一个操作是 b = j ,第二个操作是 v = i 也就是 volatile 写,所以需要有 StoreStore 屏障;同样的,v = i 和 u = j 之间也需要有 StoreStore 屏障。
第一个操作是 u = j 和 第二个操作 i = u volatile 读之间需要 StoreLoad 屏障。
最后一点需要注意下,因为最后两个操作是普通读和普通写,所以最后需要插入两个内存屏障,防止 volatile 读和普通读/写重排序。
《Java 并发编程艺术》里面也提到了这个关键点。
从上面的分析可知,volatile 实现有序性是通过内存屏障来实现的。
关键概念
在 volatile 实现可见性和有序性的过程中,有一些关键概念,cxuan 这里重新给读者朋友们唠叨下。
- 缓冲行:英文概念是 cache line,它是缓存中可以分配的最小存储单位。因为数据在内存中不是以独立的项进行存储的,而是以临近 64 字节的方式进行存储。
- 缓存行填充:cache line fill,当 CPU 把内存的数据载入缓存时,会把临近的共 64 字节的数据一同放入同一个 Cache line,因为局部性原理:临近的数据在将来被访问的可能性大。
- 缓存命中:cache hit,当 CPU 从内存地址中提取数据进行缓存行填充时,发现提取的位置仍然是上次访问的位置,此时 CPU 会选择从缓存中读取操作数,而不是从内存中取。
- 写命中:write hit ,当处理器打算将操作数写回到内存时,首先会检查这个缓存的内存地址是否在缓存行中,如果存在一个有效的缓存行,则处理器会将这个操作数写回到缓存,而不是写回到内存,这种方式被称为写命中。
- 内存屏障:memory barriers,是一组硬件指令,是 volatile 实现有序性的基础。
- 原子操作:atomic operations,是一组不可中断的一个或者一组操作。