volatile与内存屏障

 

Intel Processor x86 has a strong memory model.

Therefore all barrier StoreStore , LoadLoad, LoadStore are no-op on x86. Except StoreLoad which can be realized via mfence or cpuid or locked insn. Which you can already confirm with your assembly code. Other barriers just mean restriction to compilers optimization and transformation so that they don't break java memory model spec.

As you ran on intel Processor i am assuming its x86.

Please read

  1. http://gee.cs.oswego.edu/dl/jmm/cookbook.html for reference.

  2. http://psy-lob-saw.blogspot.com/2013/08/memory-barriers-are-not-free.html

  3. http://jsr166-concurrency.10961.n7.nabble.com/x86-NOOP-memory-barriers-td9991.html

Lock is not an instruction but moreof a instruction prefix (behaves as a storeLoad barrier).

  1. What does the "lock" instruction mean in x86 assembly?
  2. Why we need lock prefix before CMPXCHG

来源https://stackoverflow.com/questions/27772910/where-is-the-load-barrier-for-the-volatile-statement

StoreLoad Barriers同时具备StoreStore、LoadLoad、LoadStore三个屏障的效果,因此也称之为全能屏障(mfence)

在x86架构中,Lock前缀指令可以用作 storeLoad barrier。在支持SSE2拓展指令集的cpu上还可以使用mfence指令。
关于SSE2指令集的介绍可以看SSE2 Instructions (x86 Assembly Language Reference Manual)

里面对mfence指令的介绍是:serialize load and store operation 

关于x86指令集的介绍可以看x86 and amd64 instruction reference ,它有一节是关于lock前缀指令的介绍:LOCK — Assert LOCK# Signal Prefix ,里面介绍到:

lock前缀指令和其它指令伴随使用,将伴随的指令转换成原子指令。在多核处理器环境中,lock前缀指令保证了某个处理器对共享内存的独占使用。
从P6处理器家族开始,如果使用了LOCK指令前缀的指令要访问的目的地址的内容已经缓存在了cache中,那么LOCK#信号一般就不会被设置,但是当前处理器的cache会被锁定,然后缓存一致性(cache coherency )机制会自动确保操作的原子性。关于缓存锁定的更多内容,可以查看“Effects of a Locked Operation on Internal Processor Caches” in Chapter 8.1.4 of Intel® 64 and IA-32 Architectures Software Developer’s Manual, Volume 3A

8.1.4 Effects of a LOCK Operation on Internal Processor Caches
For the Intel486 and Pentium processors, the LOCK# signal is always asserted on the bus during a LOCK operation,
even if the area of memory being locked is cached in the processor.
For the P6 and more recent processor families, if the area of memory being locked during a LOCK operation is
cached in the processor that is performing the LOCK operation as write-back memory and is completely contained
in a cache line, the processor may not assert the LOCK# signal on the bus. Instead, it will modify the memory location
internally and allow it’s cache coherency mechanism to ensure that the operation is carried out atomically. This
operation is called “cache locking.” The cache coherency mechanism automatically prevents two or more processors
that have cached the same area of memory from simultaneously modifying data in that area.

ps: x86_64是x86的扩充,并且兼容x86

在80486以后Intel推出的绝大多数CPU都是“x86”的,包括Pentium 、Pentium Pro 、Pentium  MMX ,Pentium 2 ,还有后面的Pentium 3、Pentium 4 、Pentium D 、Core 全系列,(x86系列的)  Xeon   (摘自 知乎

X86是一种处理器架构,我们用的大多数桌面CPU和笔记本电脑中的CPU是X86架构,Intel和AMD生产的绝大多数CPU是X86架构,比如奔腾(Pentium),赛扬(Celeron),酷睿(core)等等 

========================

 

不同处理器对内存屏障的支持和实现如下:

 

Here's how these processors support barriers and atomics:

ProcessorLoadStoreLoadLoadStoreStoreStoreLoadData
dependency
orders loads?
Atomic
Conditional
Other
Atomics
Atomics
provide
barrier?
sparc-TSOno-opno-opno-opmembar
(StoreLoad)
yesCAS:
casa
swap,
ldstub
full
x86no-opno-opno-opmfence or 
cpuid or
locked insn
yesCAS:
cmpxchg
xchg,
locked insn
full
ia64combine
with

st.rel or 
ld.acq
ld.acqst.relmfyesCAS:
cmpxchg
xchg,
fetchadd
target +
acq/rel
armdmb
(see below)
dmb
(see below)
dmb-stdmbindirection
only
LL/SC:
ldrex/strex
 target
only
ppclwsync
(see below)
hwsync
(see below)
lwsynchwsyncindirection
only
LL/SC:
ldarx/stwcx
 target
only
alphambmbwmbmbnoLL/SC:
ldx_l/stx_c
 target
only
pa-riscno-opno-opno-opno-opyesbuild
from

ldcw
ldcw(NA)

Notes

  • On x86, any lock-prefixed instruction can be used as a StoreLoad barrier. (The form used in linux kernels is the no-op lock; addl $0,0(%%esp).) Versions supporting the "SSE2" extensions (Pentium4 and later) support the mfence instruction which seems preferable unless a lock-prefixed instruction like CAS is needed anyway. The cpuid instruction also works but is slower.

========================================

 

内存屏障与JVM并发

 

内存屏障,又称内存栅栏,是一组处理器指令,用于实现对内存操作的顺序限制。本文介绍了内存屏障对多线程程序的影响。我们将研究内存屏障与JVM并发机制 的关系,如易变量(volatile)、同步(synchronized)和原子条件式(atomic conditional)。本文假定读者已经充分掌握了相关概念和Java内存模型,不讨论并发互斥、并行机制和原子性。内存屏障用来实现并发编程中称为 可见性(visibility)的同样重要的作用。

以下将通过安腾处理器(Itanium,ia64)和在Intel x86 Xeon处理器和sparc处理器上的实验,理解JMM虚拟出来的4种内存屏障:StoreStore、StoreLoad、LoadLoad、LoadStore在不同硬件平台的实现。JMM如何屏蔽强内存模型的处理器和弱内存模型的处理器的差异。为什么在x86架构中能避免使用内存屏障。x86的强内存模型等。

内存屏障为何重要?

对主存的一次访问一般花费硬件的数百次时钟周期。处理器通过缓存(caching)能够从数量级上降低内存延迟的成本这些缓存为了性能重新排列待定内存操 作的顺序。也就是说,程序的读写操作不一定会按照它要求处理器的顺序执行。当数据是不可变的,同时/或者数据限制在线程范围内,这些优化是无害的。如果把 这些优化与对称多处理(symmetric multi-processing)和共享可变状态(shared mutable state)结合,那么就是一场噩梦。当基于共享可变状态的内存操作被重新排序时,程序可能行为不定。一个线程写入的数据可能被其他线程可见,原因是数据 写入的顺序不一致。适当的放置内存屏障通过强制处理器顺序执行待定的内存操作来避免这个问题。

 

内存屏障的协调作用

内存屏障不直接由JVM暴露,相反它们被JVM插入到指令序列中以维持语言层并发原语的语义。我们研究几个简单Java程序的源代码和汇编指令。首先快速 看一下Dekker算法中的内存屏障。该算法利用volatile变量协调两个线程之间的共享资源访问。

请不要关注该算法的出色细节。哪些部分是相关的?每个线程通过发信号试图进入代码第一行的关键区域。如果线程在第三行意识到冲突(两个线程都要访问),通 过turn变量的操作来解决。在任何时刻只有一个线程可以访问关键区域

      // code run by first thread   		// code run by second thread

 1    intentFirst = true;		        intentSecond = true;
 2
 3    while (intentSecond)			while (intentFirst)       // volatile read
 4    	if (turn != 0) {			   if (turn != 1) {       // volatile read
 5    	  intentFirst = false;			     intentSecond = false;
 6    	  while (turn != 0) {}			     while (turn != 1) {}
 7    	  intentFirst = true;			     intentSecond = true;
 8    	}				           }
 9
10    criticalSection();			criticalSection();
11
12    turn = 1;					turn = 0;                 // volatile write
13    intentFirst = false;			intentSecond = false;     // volatile write

硬件优化可以在没有内存屏障的情况下打乱这段代码,即使编译器按照程序员的想法顺序列出所有的内存操作。考虑第三、四行的两次顺序volatile读操 作。每一个线程检查其他线程是否发信号想进入关键区域,然后检查轮到谁操作了。考虑第12、13行的两次顺序写操作。每一个线程把访问权释放给其他线程, 然后撤销自己访问关键区域的意图。读线程应该从不期望在其他线程撤销访问意愿后观察到其他线程对turn变量的写操作。这是个灾难。但是如果这些变量没有 volatile修饰符,这的确会发生!例如,没有volatile修饰符,第二个线程在第一个线程对turn执行写操作(倒数第二行)之前可能会观察到 第一个线程对intentFirst(倒数第一行)的写操作。关键词volatile避免了这种情况,因为它在对turn变量的写操作和对 intentFirst变量的写操作之间创建了一个先后关系。编译器无法重新排序这些写操作,如果必要,它会利用一个内存屏障禁止处理器重排序。让我们来 看看一些实现细节。

PrintAssembly HotSpot选项是JVM的一个诊断标志,允许我们获取JIT编译器生成的汇编指令。这需要最新的OpenJDK版本或者新HotSpot update14或者更高版本。通过需要一个反编译插件。Kenai项目提供了用于Solaris、Linux和BSD的插件二进制文件。hsdis是另 一款可以在Windows通过源码构建的插件。

两次顺序读操作的第一次(第三行)的汇编指令如下。指令流基于Itanium 2多处理硬件、JDK 1.6 update 17。本文的所有指令流都在左手边以行号标记。相关的读操作、写操作和内存屏障指令都以粗体标记。建议读者不要沉迷于每一行指令。

1  0x2000000001de819c:      adds r37=597,r36;;  ;...84112554
2  0x2000000001de81a0:      ld1.acq r38=[r37];;  ;...0b30014a a010
3  0x2000000001de81a6:      nop.m 0x0     ;...00000002 00c0
4  0x2000000001de81ac:      sxt1 r38=r38;;  ;...00513004
5  0x2000000001de81b0:      cmp4.eq p0,p6=0,r38  ;...1100004c 8639
6  0x2000000001de81b6:      nop.i 0x0     ;...00000002 0003
7  0x2000000001de81bc:      br.cond.dpnt.many 0x2000000001de8220;;

简短的指令流其实内容丰富。第一次volatile位于第二行。Java内存模型确保了JVM会在第二次读操作之前将第一次读操作交给处理器,也就是按照 “程序的顺序”——但是这单单一行指令是不够的,因为处理器仍然可以自由乱序执行这些操作。为了支持Java内存模型的一致性,JVM在第一次读操作上添 加了注解ld.acq,也就是“载入获取”(load acquire)。通过使用ld.acq,编译器确保第二行的读操作在接下来的读操作之前完成。问题就解决了。

请注意这影响了读操作,而不是写。内存屏障强制读或写操作顺序限制不是单向的。强制读和写操作顺序限制的内存屏障是双向的, 类似于双向开的栅栏。使用ld.acq就是单向内存屏障的例子。

一致性具有两面性。如果一个读线程在两次读操作之间插入了内存屏障而另外一个线程没有在两次写操作之间添加内存屏障又有什么用呢?线程为了协调,必须同时 遵守这个协议,就像网络中的节点或者团队中的成员。如果某个线程破坏了这个约定,那么其他所有线程的努力都白费。Dekker算法的最后两行代码的汇编指 令应该插入一个内存屏障,两次volatile写之间。

$ java -XX:+UnlockDiagnosticVMOptions -XX:PrintAssemblyOptions=hsdis-print-bytes -XX:CompileCommand=print,WriterReader.write WriterReader
 1  0x2000000001de81c0:      adds r37=592,r36;;  ;...0b284149 0421
 2  0x2000000001de81c6:      st4.rel [r37]=r39  ;...00389560 2380
 3  0x2000000001de81cc:      adds r36=596,r36;;  ;...84112544
 4  0x2000000001de81d0:      st1.rel [r36]=r0  ;...09000048 a011
 5  0x2000000001de81d6:      mf            ;...00000044 0000
 6  0x2000000001de81dc:      nop.i 0x0;;   ;...00040000
 7  0x2000000001de81e0:      mov r12=r33   ;...00600042 0021
 8  0x2000000001de81e6:      mov.ret b0=r35,0x2000000001de81e0
 9  0x2000000001de81ec:      mov.i ar.pfs=r34  ;...00aa0220
10  0x2000000001de81f0:      mov r6=r32    ;...09300040 0021

这里我们可以看到在第四行第二次写操作被注解了一个显式内存屏障。通过使用st.rel,即“存储释放”(store release),编译器确保第一次写操作在第二次写操作之前完成。这就完成了两边的约定,因为第一次写操作在第二次写操作之前发生。

st.rel屏障是单向的——就像ld.acq一样。但是在第五行编译器设置了一个双向内存屏障。mf指令,或者称为“内存栅栏”,是Itanium 2指令集中的完整栅栏。笔者认为是多余的。

内存屏障是特定于硬件的

本文不想针对所有内存屏障做一综述。这将是一件不朽的功绩。但是,重要的是认识到这些指令在不同的硬件体系中迥异。下面的指令是连续写操作在多处理 Intel Xeon硬件上编译的结果。本文后面的所有汇编指令除非特殊声明否则都出自于Intel Xeon。

1  0x03f8340c: push   %ebp               ;...55
 2  0x03f8340d: sub    $0x8,%esp          ;...81ec0800 0000
 3  0x03f83413: mov    $0x14c,%edi        ;...bf4c0100 00
 4  0x03f83418: movb   $0x1,-0x505a72f0(%edi)  ;...c687108d a5af01
 5  0x03f8341f: mfence                    ;...0faef0
 6  0x03f83422: mov    $0x148,%ebp        ;...bd480100 00
 7  0x03f83427: mov    $0x14d,%edx        ;...ba4d0100 00
 8  0x03f8342c: movsbl -0x505a72f0(%edx),%ebx  ;...0fbe9a10 8da5af
 9  0x03f83433: test   %ebx,%ebx          ;...85db
10  0x03f83435: jne    0x03f83460         ;...7529
11  0x03f83437: movl   $0x1,-0x505a72f0(%ebp)  ;...c785108d a5af01
12  0x03f83441: movb   $0x0,-0x505a72f0(%edi)  ;...c687108d a5af00
13  0x03f83448: mfence                    ;...0faef0
14  0x03f8344b: add    $0x8,%esp          ;...83c408
15  0x03f8344e: pop    %ebp               ;...5d

我们可以看到x86 Xeon在第11、12行执行两次volatile写操作。第二次写操作后面紧跟着mfence操作——显式的双向内存屏障。

下面的连续写操作基于SPARC。

 1 0xfb8ecc84: ldub  [ %l1 + 0x155 ], %l3  ;...e60c6155
 2 0xfb8ecc88: cmp  %l3, 0               ;...80a4e000
 3 0xfb8ecc8c: bne,pn   %icc, 0xfb8eccb0  ;...12400009
 4 0xfb8ecc90: nop                       ;...01000000
 5 0xfb8ecc94: st  %l0, [ %l1 + 0x150 ]  ;...e0246150
 6 0xfb8ecc98: clrb  [ %l1 + 0x154 ]     ;...c02c6154
 7 0xfb8ecc9c: membar  #StoreLoad        ;...8143e002
 8 0xfb8ecca0: sethi  %hi(0xff3fc000), %l0  ;...213fcff0
 9 0xfb8ecca4: ld  [ %l0 ], %g0          ;...c0042000
10 0xfb8ecca8: ret                       ;...81c7e008
11 0xfb8eccac: restore                   ;...81e80000

我们看到在第五、六行存在两次volatile写操作。第二次写操作后面是一个membar指令——显式的双向内存屏障。

x86和SPARC的指令流与Itanium的指令流存在一个重要区别。JVM在x86和SPARC上通过内存屏障跟踪连续写操作,但是在两次写操作之间 没有放置内存屏障。另一方面,Itanium的指令流在两次写操作之间存在内存屏障。为何JVM在不同的硬件架构之间表现不一?因为硬件架构都有自己的内 存模型,每一个内存模型有一套一致性保障。某些内存模型,如x86和SPARC等,拥有强大的一致性保障。另一些内存模型,如Itanium、 PowerPC和Alpha,是一种弱保障。例如,x86和SPARC不会重新排序连续写操作——也就没有必要放置内存屏障。Itanium、 PowerPC和Alpha将重新排序连续写操作——因此JVM必须在两者之间放置内存屏障。JVM使用内存屏障减少Java内存模型和硬件内存模型之间 的距离。

隐式内存屏障

显式屏障指令不是序列化内存操作的唯一方式。让我们再看一看Counter类这个例子。

 class Counter{

        static int counter = 0;

        public static void main(String[] _){
            for(int i = 0; i < 100000; i++)
                inc();
        }

        static synchronized void inc(){ counter += 1; }

    }
		

Counter类执行了一个典型的读-修改-写的操作。静态counter字段不是volatile的,因为所有三个操作必须要原子可见的。因此,inc 方法是synchronized修饰的。我们可以采用下面的命令编译Counter类并查看生成的汇编指令。Java内存模型确保了 synchronized区域的退出和volatile内存操作都是相同的可见性,因此我们应该预料到会有另一个内存屏障。

$ java -XX:+UnlockDiagnosticVMOptions -XX:PrintAssemblyOptions=hsdis-print-bytes -XX:-UseBiasedLocking -XX:CompileCommand=print,Counter.inc Counter
 1  0x04d5eda7: push   %ebp               ;...55
 2  0x04d5eda8: mov    %esp,%ebp          ;...8bec
 3  0x04d5edaa: sub    $0x28,%esp         ;...83ec28
 4  0x04d5edad: mov    $0x95ba5408,%esi   ;...be0854ba 95
 5  0x04d5edb2: lea    0x10(%esp),%edi    ;...8d7c2410
 6  0x04d5edb6: mov    %esi,0x4(%edi)     ;...897704
 7  0x04d5edb9: mov    (%esi),%eax        ;...8b06
 8  0x04d5edbb: or     $0x1,%eax          ;...83c801
 9  0x04d5edbe: mov    %eax,(%edi)        ;...8907
10  0x04d5edc0: lock cmpxchg %edi,(%esi)  ;...f00fb13e
11  0x04d5edc4: je     0x04d5edda         ;...0f841000 0000
12  0x04d5edca: sub    %esp,%eax          ;...2bc4
13  0x04d5edcc: and    $0xfffff003,%eax   ;...81e003f0 ffff
14  0x04d5edd2: mov    %eax,(%edi)        ;...8907
15  0x04d5edd4: jne    0x04d5ee11         ;...0f853700 0000
16  0x04d5edda: mov    $0x95ba52b8,%eax   ;...b8b852ba 95
17  0x04d5eddf: mov    0x148(%eax),%esi   ;...8bb04801 0000
18  0x04d5ede5: inc    %esi               ;...46
19  0x04d5ede6: mov    %esi,0x148(%eax)   ;...89b04801 0000
20  0x04d5edec: lea    0x10(%esp),%eax    ;...8d442410
21  0x04d5edf0: mov    (%eax),%esi        ;...8b30
22  0x04d5edf2: test   %esi,%esi          ;...85f6
23  0x04d5edf4: je     0x04d5ee07         ;...0f840d00 0000
24  0x04d5edfa: mov    0x4(%eax),%edi     ;...8b7804
25  0x04d5edfd: lock cmpxchg %esi,(%edi)  ;...f00fb137
26  0x04d5ee01: jne    0x04d5ee1f         ;...0f851800 0000
27  0x04d5ee07: mov    %ebp,%esp          ;...8be5
28  0x04d5ee09: pop    %ebp               ;...5d
		

不出意外,synchronized生成的指令数量比volatile多。第18行做了一次增操作,但是JVM没有显式插入内存屏障。相反,JVM通过在 第10行和第25行cmpxchg的lock前缀一石二鸟。cmpxchg的语义超越了本文的范畴。lock cmpxchg不仅原子性执行写操作,也会刷新等待的读写操作。写操作现在将在所有后续内存操作之前完成。如果我们通过 java.util.concurrent.atomic.AtomicInteger 重构和运行Counter,将看到同样的手段。

    import java.util.concurrent.atomic.AtomicInteger;

    class Counter{

        static AtomicInteger counter = new AtomicInteger(0);

        public static void main(String[] args){
            for(int i = 0; i < 1000000; i++)
                counter.incrementAndGet();
        }

    }
$ java -XX:+UnlockDiagnosticVMOptions -XX:PrintAssemblyOptions=hsdis-print-bytes -XX:CompileCommand=print,*AtomicInteger.incrementAndGet Counter
 1  0x024451f7: push   %ebp               ;...55
 2  0x024451f8: mov    %esp,%ebp          ;...8bec
 3  0x024451fa: sub    $0x38,%esp         ;...83ec38
 4  0x024451fd: jmp    0x0244520a         ;...e9080000 00
 5  0x02445202: xchg   %ax,%ax            ;...6690
 6  0x02445204: test   %eax,0xb771e100    ;...850500e1 71b7
 7  0x0244520a: mov    0x8(%ecx),%eax     ;...8b4108
 8  0x0244520d: mov    %eax,%esi          ;...8bf0
 9  0x0244520f: inc    %esi               ;...46
10  0x02445210: mov    $0x9a3f03d0,%edi   ;...bfd0033f 9a
11  0x02445215: mov    0x160(%edi),%edi   ;...8bbf6001 0000
12  0x0244521b: mov    %ecx,%edi          ;...8bf9
13  0x0244521d: add    $0x8,%edi          ;...83c708
14  0x02445220: lock cmpxchg %esi,(%edi)  ;...f00fb137
15  0x02445224: mov    $0x1,%eax          ;...b8010000 00
16  0x02445229: je     0x02445234         ;...0f840500 0000
17  0x0244522f: mov    $0x0,%eax          ;...b8000000 00
18  0x02445234: cmp    $0x0,%eax          ;...83f800
19  0x02445237: je     0x02445204         ;...74cb
20  0x02445239: mov    %esi,%eax          ;...8bc6
21  0x0244523b: mov    %ebp,%esp          ;...8be5
22  0x0244523d: pop    %ebp               ;...5d

我们又一次在第14行看到了带有lock前缀的写操作。这确保了变量的新值(写操作)会在其他所有后续内存操作之前完成。

内存屏障能够避免

JVM非常擅于消除不必要的内存屏障。通常JVM很幸运,因为硬件内存模型的一致性保障强于或者等于Java内存模型。在这种情况下,JVM只是简单地插 入一个no op语句,而不是真实的内存屏障。例如,x86和SPARC内存模型的一致性保障足够强壮以消除读volatile变量时所需的内存屏障。还记得在 Itanium上两次读操作之间的显式单向内存屏障吗?x86上的Dekker算法中连续volatile读操作的汇编指令之间没有任何内存屏障。

x86平台上共享内存的连续读操作。

 1  0x03f83422: mov    $0x148,%ebp        ;...bd480100 00
 2  0x03f83427: mov    $0x14d,%edx        ;...ba4d0100 00
 3  0x03f8342c: movsbl -0x505a72f0(%edx),%ebx  ;...0fbe9a10 8da5af
 4  0x03f83433: test   %ebx,%ebx          ;...85db
 5  0x03f83435: jne    0x03f83460         ;...7529
 6  0x03f83437: movl   $0x1,-0x505a72f0(%ebp)  ;...c785108d a5af01
 7  0x03f83441: movb   $0x0,-0x505a72f0(%edi)  ;...c687108d a5af00
 8  0x03f83448: mfence                    ;...0faef0
 9  0x03f8344b: add    $0x8,%esp          ;...83c408
10  0x03f8344e: pop    %ebp               ;...5d
11  0x03f8344f: test   %eax,0xb78ec000    ;...850500c0 8eb7
12  0x03f83455: ret                       ;...c3
13  0x03f83456: nopw   0x0(%eax,%eax,1)   ;...66660f1f 840000
14  0x03f83460: mov    -0x505a72f0(%ebp),%ebx  ;...8b9d108d a5af
15  0x03f83466: test   %edi,0xb78ec000    ;...853d00c0 8eb7

第三行和第十四行存在volatile读操作,而且都没有伴随内存屏障。也就是说,x86和SPARC上的volatile读操作的性能下降对于代码的优 化影响很小——指令本身和常规读操作一样。

单向内存屏障本质上比双向屏障性能要好一些。JVM在确保单向屏障即可的情况下会避免使用双向屏障。本文的第一个例子展示了这点。Itanium平台上的 连续两次读操作被插入单向内存屏障。如果读操作插入显式双向内存屏障,程序仍然正确,但是延迟比较长。

动态编译

静态编译器在构建阶段决定的一切事情,在动态编译器那里都可以在运行时决定,甚至更多。更多信息意味着存在更多机会可以优化。例如,让我们看看JVM在单 处理器运行时如何对待内存屏障。以下指令流来自于通过Dekker算法实现两次连续volatile写操作的运行时编译。程序运行于 x86硬件上的单处理器模式中的VMWare工作站镜像。

 1  0x017b474c: push   %ebp               ;...55
 2  0x017b474d: sub    $0x8,%esp          ;...81ec0800 0000
 3  0x017b4753: mov    $0x14c,%edi        ;...bf4c0100 00
 4  0x017b4758: movb   $0x1,-0x507572f0(%edi)  ;...c687108d 8aaf01
 5  0x017b475f: mov    $0x148,%ebp        ;...bd480100 00
 6  0x017b4764: mov    $0x14d,%edx        ;...ba4d0100 00
 7  0x017b4769: movsbl -0x507572f0(%edx),%ebx  ;...0fbe9a10 8d8aaf
 8  0x017b4770: test   %ebx,%ebx          ;...85db
 9  0x017b4772: jne    0x017b4790         ;...751c
10  0x017b4774: movl   $0x1,-0x507572f0(%ebp)  ;...c785108d 8aaf01
11  0x017b477e: movb   $0x0,-0x507572f0(%edi)  ;...c687108d 8aaf00
12  0x017b4785: add    $0x8,%esp          ;...83c408
13  0x017b4788: pop    %ebp               ;...5d

在单处理器系统上,JVM为所有内存屏障插入了一个no op指令,因为内存操作已经序列化了。每一个写操作(第10、11行)后面都跟着一个屏障。JVM针对原子条件式做了类似的优化。下面的指令流来自于同一 个VMWare镜像的AtomicInteger.incrementAndGet动态编译结果。

1  0x036880f7: push   %ebp               ;...55
 2  0x036880f8: mov    %esp,%ebp          ;...8bec
 3  0x036880fa: sub    $0x38,%esp         ;...83ec38
 4  0x036880fd: jmp    0x0368810a         ;...e9080000 00
 5  0x03688102: xchg   %ax,%ax            ;...6690
 6  0x03688104: test   %eax,0xb78b8100    ;...85050081 8bb7
 7  0x0368810a: mov    0x8(%ecx),%eax     ;...8b4108
 8  0x0368810d: mov    %eax,%esi          ;...8bf0
 9  0x0368810f: inc    %esi               ;...46
10  0x03688110: mov    $0x9a3f03d0,%edi   ;...bfd0033f 9a
11  0x03688115: mov    0x160(%edi),%edi   ;...8bbf6001 0000
12  0x0368811b: mov    %ecx,%edi          ;...8bf9
13  0x0368811d: add    $0x8,%edi          ;...83c708
14  0x03688120: cmpxchg %esi,(%edi)       ;...0fb137
15  0x03688123: mov    $0x1,%eax          ;...b8010000 00
16  0x03688128: je     0x03688133         ;...0f840500 0000
17  0x0368812e: mov    $0x0,%eax          ;...b8000000 00
18  0x03688133: cmp    $0x0,%eax          ;...83f800
19  0x03688136: je     0x03688104         ;...74cc
20  0x03688138: mov    %esi,%eax          ;...8bc6
21  0x0368813a: mov    %ebp,%esp          ;...8be5
22  0x0368813c: pop    %ebp               ;...5d

注意第14行的cmpxchg指令。之前我们看到编译器通过lock前缀把该指令提供给处理器。由于缺少SMP,JVM决定避免这种成本——与静态编译有些不同。

结束语

内存屏障是多线程编程的必要装备。它们形式多样,某些是显式的,某些是隐式的。某些是双向的,某些是单向的。JVM利用这些形式在所有平台中有效地支持 Java内存模型。我希望本文能够帮助经验丰富的JVM开发人员了解一些代码在底层如何运行的知识。

参考书目

关于作者

Dennis ByrneDRW Trading(一家自营证券投资公司和流通量供应商)的一名高级软件 工程师。他是一名作家、演说家和开源社区的活跃成员。

查看英文原文:Memory Barriers and JVM Concurrency

 

 

 

========================================

聊聊高并发(三十五)Java内存模型那些事(三)理解内存屏障

前言

内存屏障的概念很好理解,不同硬件实现内存屏障的方式不同,Java内存模型屏蔽了这种底层硬件平台的差异,由JVM来为不同的平台生成相应的机器码。

有些材料里说Java实现volatile的时候使用了类似mfence等内存屏障,但是我经过测试发现在X86平台上volatile是用Lock前缀来实现的,测试的是JDK6和7。

 

 

Java内存模型利用了硬件层提供的能力指定了一系列的语法和规则,让Java开发者可以隔绝这种底层的实现专注于并发逻辑的开发。这篇我们来看看硬件层是如何提供这些实现一致性需求的能力的。

 

硬件层提供了一系列的内存屏障 memory barrier / memory fence(Intel的提法)来提供一致性的能力。拿X86平台来说,有几种主要的内存屏障

1. lfence,是一种Load Barrier 读屏障

2. sfence, 是一种Store Barrier 写屏障

3. mfence, 是一种全能型的屏障,具备ifence和sfence的能力

4. Lock前缀,Lock不是一种内存屏障,但是它能完成类似内存屏障的功能。Lock会对CPU总线和高速缓存加锁,可以理解为CPU指令级的一种锁。它后面可以跟ADD, ADC, AND, BTC, BTR, BTS, CMPXCHG, CMPXCH8B, DEC, INC, NEG, NOT, OR, SBB, SUB, XOR, XADD, and XCHG等指令。

LOCK指令前缀只能用于以下指令,并且要求指令目的操作数为内存操作数,如果源操作数为内存操作数,就会产生undefined opcode异常:ADD, ADC, AND, BTC, BTR, BTS, CMPXCHG, CMPXCH8B,CMPXCHG16B, DEC, INC, NEG, NOT, OR, SBB, SUB, XOR, XADD, XCHG。(摘自here

内存屏障有两个能力:

1. 阻止屏障两边的指令重排序

2. 强制把写缓冲区/高速缓存中的脏数据等写回主内存,让缓存中相应的数据失效

======================

下面内容将不再探讨内存屏障在不同硬件平台的实现,而把精力重点放在lock前缀指令和缓存一致性协议的配合,

看它们怎么分别作用于volatile变量的写和读上,从而保证 所有线程看到volatile变量的值是一致的(volatile变量的可见性)。

 

======================

Volatile的官方定义

Java语言规范第三版中对volatile的定义如下: java编程语言允许线程访问共享变量,为了确保共享变量能被准确和一致的更新,线程应该确保通过排他锁单独获得这个变量。Java语言提供了volatile,在某些情况下比锁更加方便。如果一个字段被声明成volatile,java线程内存模型确保所有线程看到这个变量的值是一致的。

为什么要使用Volatile

Volatile变量修饰符如果使用恰当的话,它比synchronized的使用和执行成本会更低,因为它不会引起线程上下文的切换和调度。

Volatile的实现原理

那么Volatile是如何来保证可见性的呢?在x86处理器下通过工具获取JIT编译器生成的汇编指令来看看对Volatile进行写操作CPU会做什么事情。

Java代码:

instance = new Singleton();//instance是volatile变量

汇编代码:

0x01a3de1d: movb $0x0,0x1104800(%esi);

0x01a3de24: lock addl $0x0,(%esp);

ps:对于这段汇编代码,我的理解是,第一行汇编代码是instance赋值,将值写入缓存行$0x0。第二行汇编代码用lock前缀指令标注addl指令,将缓存行数据$0x0写入到内存。有懂得汇编语言的大神,恳请评论告诉我这个理解是否正确。lock前缀指令会锁定addl指令的目的操作数,即缓存行数据$0x0,并将其它cpu的对应缓存行无效。禁止addl指令与后面的读写指令重排序。

intel手册对lock前缀的说明如下:

确保
后续指令执行的原子性。
在Pentium及之前的处理器中,带有lock前缀的指令在执行期间会锁住总线,使得其它处理器暂时无法通过总线访问内存,很显然,这个开销很大。在新的处理器中,Intel使用缓存锁定来保证指令执行的原子性,缓存锁定将大大降低lock前缀指令的执行开销。
禁止该指令与前面和后面的读写指令重排序
把写缓冲区的所有数据刷新到内存中。
(摘自here
 

(movb,数据传送指令,传送语法是从右向左,eg.从%esi传送到$0x0

Two processors, running in parallel, execute the following machine code:

图片摘自:Memory Reordering Caught in the Act

有volatile变量修饰的共享变量进行写操作的时候会多第二行汇编代码,通过查IA-32架构软件开发者手册可知,lock前缀的指令在多核处理器下会引发了两件事情。

  • 将当前处理器缓存行的数据会写回到系统内存。
  • 这个写回内存的操作会引起在其他CPU里缓存了该内存地址的数据无效。

处理器为了提高处理速度,不直接和内存进行通讯,而是先将系统内存的数据读到内部缓存(L1,L2或其他)后再进行操作,但操作完之后不知道何时会写到内存,如果对声明了Volatile变量进行写操作,JVM就会向处理器发送一条Lock前缀的指令,将这个变量所在缓存行的数据写回到系统内存。但是就算写回到内存,如果其他处理器缓存的值还是旧的,再执行计算操作就会有问题,所以在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器要对这个数据进行修改操作的时候,会强制重新从系统内存里把数据读到处理器缓存里。

这两件事情在IA-32软件开发者架构手册的第三册的多处理器管理章节(第八章)中有详细阐述。

Lock前缀指令会引起处理器缓存回写到内存。Lock前缀指令导致在执行指令期间,声言处理器的 LOCK# 信号。在多处理器环境中,LOCK# 信号确保在声言该信号期间,处理器可以独占使用任何共享内存。(因为它会锁住总线,导致其他CPU不能访问总线,不能访问总线就意味着不能访问系统内存),但是在最近的处理器里,LOCK#信号一般不锁总线,而是锁缓存,毕竟锁总线开销比较大。在8.1.4章节有详细说明锁定操作对处理器缓存的影响,对于Intel486和Pentium处理器,在锁操作时,总是在总线上声言LOCK#信号。但在P6和最近的处理器中,如果访问的内存区域已经缓存在处理器内部,则不会声言LOCK#信号。相反地,它会锁定这块内存区域的缓存并回写到内存,并使用缓存一致性机制来确保修改的原子性,此操作被称为“缓存锁定”,缓存一致性机制会阻止同时修改被两个以上处理器缓存的内存区域数据

一个处理器的缓存回写到内存会导致其他处理器的缓存无效。IA-32处理器和Intel 64处理器使用MESI(修改,独占,共享,无效)控制协议去维护内部缓存和其他处理器缓存的一致性。在多核处理器系统中进行操作的时候,IA-32 和Intel 64处理器能嗅探其他处理器访问系统内存和它们的内部缓存。它们使用嗅探技术保证它的内部缓存,系统内存和其他处理器的缓存的数据在总线上保持一致。例如在Pentium和P6 family处理器中,如果通过嗅探一个处理器来检测其他处理器打算写内存地址,而这个地址当前处理共享状态,那么正在嗅探的处理器将无效它的缓存行,在下次访问相同内存地址时,强制执行缓存行填充。

========================

一文解决内存屏障

内存屏障是硬件之上、操作系统或JVM之下,对并发作出的最后一层支持。再向下是是硬件提供的支持;向上是操作系统或JVM对内存屏障作出的各种封装。内存屏障是一种标准,各厂商可能采用不同的实现。

本文仅为了帮助理解JVM提供的并发机制。首先,从volatile的语义引出可见性与重排序问题;接下来,阐述问题的产生原理,了解为什么需要内存屏障;然后,浅谈内存屏障的标准、厂商对内存屏障的支持,并以volatile为例讨论内存屏障如何解决这些问题;最后,补充介绍JVM在内存屏障之上作出的几个封装。为了帮助理解,会简要讨论硬件架构层面的一些基本原理(特别是CPU架构),但不会深入实现机制。

内存屏障的实现涉及大量硬件架构层面的知识,又需要操作系统或JVM的配合才能发挥威力,单纯从任何一个层面都无法理解。本文整合了这三个层面的大量知识,篇幅较长,希望能在一篇文章内,把内存屏障的基本问题讲述清楚。

如有疏漏,还望指正!

volatile变量规则

一个用于引出内存屏障的好例子是volatile变量规则

volatile关键字可参考猴子刚开博客时的文章volatile关键字的作用、原理。volatile变量规则描述了volatile变量的偏序语义;这里从volatile变量规则的角度来讲解,顺便做个复习。

定义

volatile变量规则:对volatile变量的写入操作必须在对该变量的读操作之前执行

volatile变量规则只是一种标准,要求JVM实现保证volatile变量的偏序语义。结合程序顺序规则、传递性,该偏序语义通常表现为两个作用:

  • 保持可见性
  • 禁用重排序(读操作禁止重排序之后的操作,写操作禁止重排序之前的操作)

补充:

  • 程序顺序规则:如果程序中操作A在操作B之前,那么在线程中操作A将在操作B之前执行。
  • 传递性:如果操作A在操作B之前执行,并且操作B在操作C之前执行,那么操作A必须在操作C之前执行。

后文,如果仅涉及可见性,则指明“可见性”;如果二者均涉及,则以“偏序”代称。重排序一定会带来可见性问题,因此,不会出现单独讨论重排序的场景。

正确姿势

之前的文章多次涉及volatile变量规则的用法。

简单的仅利用volatile变量规则对volatile变量本身的可见性保证:

复杂的利用volatile变量规则(结合了程序顺序规则、传递性)保证变量本身及周围其他变量的偏序:

可见性与重排序

前文多次提到可见性与重排序的问题,内存屏障的存在就是为了解决这些问题。到底什么是可见性?什么是重排序?为什么会有这些问题?

可见性

定义

可见性的定义常见于各种并发场景中,以多线程为例:当一个线程修改了线程共享变量的值,其它线程能够立即得知这个修改。

从性能角度考虑,没有必要在修改后就立即同步修改的值——如果多次修改后才使用,那么只需要最后一次同步即可,在这之前的同步都是性能浪费。因此,实际的可见性定义要弱一些,只需要保证:当一个线程修改了线程共享变量的值,其它线程在使用前,能够得到最新的修改值

可见性可以认为是最弱的“一致性”(弱一致),只保证用户见到的数据是一致的,但不保证任意时刻,存储的数据都是一致的(强一致)。下文会讨论“缓存可见性”问题,部分文章也会称为“缓存一致性”问题。

问题来源

一个最简单的可见性问题来自计算机内部的缓存架构:

 

image.png

 

缓存大大缩小了高速CPU与低速内存之间的差距。以三层缓存架构为例:

  • L1 Cache最接近CPU, 容量最小(如32K、64K等)、速度最高,每个核上都有一个L1 Cache。
  • L2 Cache容量更大(如256K)、速度更低, 一般情况下,每个核上都有一个独立的L2 Cache。
  • L3 Cache最接近内存,容量最大(如12MB),速度最低,在同一个CPU插槽之间的核共享一个L3 Cache。

准确地说,每个核上有两个L1 Cache, 一个存数据 L1d Cache, 一个存指令 L1i Cache。

单核时代的一切都是那么完美。然而,多核时代出现了可见性问题。一个badcase如下:

  1. Core0与Core1命中了内存中的同一个地址,那么各自的L1 Cache会缓存同一份数据的副本。
  2. 最开始,Core0与Core1都在友善的读取这份数据。
  3. 突然,Core0要使坏了,它修改了这份数据,使得两份缓存中的数据不同了,更确切的说,Core1 L1 Cache中的数据失效了。

单核时代只有Core0,Core0修改Core0读,没什么问题;但是,现在_Core0修改后,Core1并不知道数据已经失效,继续傻傻的使用_,轻则数据计算错误,重则导致死循环、程序崩溃等。

实际的可见性问题还要扩展到两个方向:

  • 除三级缓存外,各厂商实现的硬件架构中还存在多种多样的缓存,都存在类似的可见性问题。例如,寄存器就相当于CPU与L1 Cache之间的缓存。
  • 各种高级语言(包括Java)的多线程内存模型中,在线程栈内自己维护一份缓存是常见的优化措施,但显然在CPU级别的缓存可见性问题面前,一切都失效了

以上只是最简单的可见性问题,不涉及重排序等。

重排序也会导致可见性问题;同时,缓存上的可见性也会引起一些看似重排序导致的问题。

重排序

定义

重排序并没有严格的定义。整体上可以分为两种:

  • 真·重排序:编译器、底层硬件(CPU等)出于“优化”的目的,按照某种规则将指令重新排序(尽管有时候看起来像乱序)。
  • 伪·重排序:由于缓存同步顺序等问题,看起来指令被重排序了。

重排序也是单核时代非常优秀的优化手段,有足够多的措施保证其在单核下的正确性。在多核时代,如果工作线程之间不共享数据或仅共享不可变数据,重排序也是性能优化的利器。然而,如果工作线程之间共享了可变数据,由于两种重排序的结果都不是固定的,会导致工作线程似乎表现出了随机行为。

第一次接触重排序的概念一定很迷糊,耐心,耐心。

问题来源

重排序问题无时无刻不在发生,源自三种场景:

  1. 编译器编译时的优化
  2. 处理器执行时的乱序优化
  3. 缓存同步顺序(导致可见性问题)

场景1、2属于真·重排序;场景3属于伪·重排序。场景3也属于可见性问题,为保持连贯性,我们先讨论场景3。

可见性导致的伪·重排序

缓存同步顺序本质上是可见性问题。

假设程序顺序(program order)中先更新变量v1、再更新变量v2,不考虑真·重排序:

  1. Core0先更新缓存中的v1,再更新缓存中的v2(位于两个缓存行,这样淘汰缓存行时不会一起写回内存)。
  2. Core0读取v1(假设使用LRU协议淘汰缓存)。
  3. Core0的缓存满,将最远使用的v2写回内存。
  4. Core1的缓存中本来存有v1,现在将v2加载入缓存。

重排序是针对程序顺序而言的,如果指令执行顺序与程序顺序不同,就说明这段指令被重排序了。

此时,尽管“更新v1”的事件早于“更新v2”发生,但Core1只看到了v2的最新值,却看不到v1的最新值。这属于可见性导致的伪·重排序:虽然没有实际上没有重排序,但看起来发生了重排序

可以看到,缓存可见性不仅仅导致可见性问题,还会导致伪·重排序。因此,只要解决了缓存上的可见性问题,也就解决了伪·重排序

MESI协议

回到可见性问题中的例子和可见性的定义。要解决这个问题很简单,套用可见性的定义,只需要:在Core0修改了数据v后,让Core1在使用v前,能得到v最新的修改值

这个要求很弱,既可以在每次修改v后,都同步修改值到其他缓存了v的Cache中;又可以只同步使用前的最后一次修改值。后者性能上更优,如何实现呢:

  1. Core0修改v后,发送一个信号,将Core1缓存的v标记为失效,并将修改值写回内存。
  2. Core0可能会多次修改v,每次修改都只发送一个信号(发信号时会锁住缓存间的总线),Core1缓存的v保持着失效标记。
  3. Core1使用v前,发现缓存中的v已经失效了,得知v已经被修改了,于是重新从其他缓存或内存中加载v。

以上即是MESI(Modified Exclusive Shared Or Invalid,缓存的四种状态)协议的基本原理,不算严谨,但对于理解缓存可见性(更常见的称呼是“缓存一致性”)已经足够。

MESI协议解决了CPU缓存层面的可见性问题。

以下是MESI协议的缓存状态机,简单看看即可:

 

image.png

 

状态:

  • M(修改, Modified): 本地处理器已经修改缓存行, 即是脏行, 它的内容与内存中的内容不一样. 并且此cache只有本地一个拷贝(专有)。
  • E(专有, Exclusive): 缓存行内容和内存中的一样, 而且其它处理器都没有这行数据。
  • S(共享, Shared): 缓存行内容和内存中的一样, 有可能其它处理器也存在此缓存行的拷贝。
  • I(无效, Invalid): 缓存行失效, 不能使用。

剩余问题

既然有了MESI协议,是不是就不需要volatile的可见性语义了?当然不是,还有三个问题:

  • 并不是所有的硬件架构都提供了相同的一致性保证,JVM需要volatile统一语义(就算是MESI,也只解决CPU缓存层面的问题,没有涉及其他层面)。
  • 可见性问题不仅仅局限于CPU缓存内,JVM自己维护的内存模型中也有可见性问题。使用volatile做标记,可以解决JVM层面的可见性问题。
  • 如果不考虑真·重排序,MESI确实解决了CPU缓存层面的可见性问题;然而,真·重排序也会导致可见性问题。

暂时第一个问题称为“内存可见性”问题,内存屏障解决了该问题。后文讨论。

编译器编译时的优化

JVM自己维护的内存模型中也有可见性问题,使用volatile做标记,取消volatile变量的缓存,就解决了JVM层面的可见性问题。编译器产生的重排序也采用了同样的思路。

编译器为什么要重排序(re-order)呢?和处理器乱序执行的目的是一样的:与其等待阻塞指令(如等待缓存刷入)完成,不如先去执行其他指令。与处理器乱序执行相比,编译器重排序能够完成更大范围、效果更好的乱序优化。

由于同处理器乱序执行的目的相同,原理相似,这里不讨论编译器重排序的实现原理。

幸运的是,既然是编译器层面的重排序,自然可以由编译器控制。使用volatile做标记,就可以禁用编译器层面的重排序。

处理器执行时的乱序优化

处理器层面的乱序优化节省了大量等待时间,提高了处理器的性能。

所谓“乱序”只是被叫做“乱序”,实际上也遵循着一定规则:只要两个指令之间不存在数据依赖,就可以对这两个指令乱序。不必关心数据依赖的精确定义,可以理解为:只要不影响程序单线程、顺序执行的结果,就可以对两个指令重排序

不进行乱序优化时,处理器的指令执行过程如下:

  1. 指令获取。
  2. 如果输入的运算对象是可以获取的(比如已经存在于寄存器中),这条指令会被发送到合适的功能单元。如果一个或者更多的运算对象在当前的时钟周期中是不可获取的(通常需要从主内存获取),处理器会开始等待直到它们是可以获取的。
  3. 指令在合适的功能单元中被执行。
  4. 功能单元将运算结果写回寄存器。

乱序优化下的执行过程如下:

  1. 指令获取。
  2. 指令被发送到一个指令序列(也称执行缓冲区或者保留站)中。
  3. 指令将在序列中等待,直到它的数据运算对象是可以获取的。然后,指令被允许在先进入的、旧的指令之前离开序列缓冲区。(此处表现为乱序)
  4. 指令被分配给一个合适的功能单元并由之执行。
  5. 结果被放到一个序列中。
  6. 仅当所有在该指令之前的指令都将他们的结果写入寄存器后,这条指令的结果才会被写入寄存器中。(重整乱序结果)

当然,为了实现乱序优化,还需要很多技术的支持,如寄存器重命名分枝预测等,但大致了解到这里就足够。后文的注释中会据此给出内存屏障的实现方案。

乱序优化在单核时代不影响正确性;但多核时代的多线程能够在不同的核上实现真正的并行,一旦线程间共享数据,就出现问题了。看一段很经典的代码:

public class OutofOrderExecution {
    private static int x = 0, y = 0;
    private static int a = 0, b = 0;
    
    public static void main(String[] args)
        throws InterruptedException {
        Thread t1 = new Thread(new Runnable() {
            public void run() {
                a = 1;
                x = b;
            }
        });
        Thread t2 = new Thread(new Runnable() {
            public void run() {
                b = 1;
                y = a;
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(“(” + x + “,” + y + “)”);
    }
}

不考虑编译器重排序和缓存可见性问题,上面的代码可能会输出什么呢?

最容易想到的结果是(0,1)(1,0)(1,1)。因为可能先后执行线程t1、t2,也可能反之,还可能t1、t2交替执行。

然而,这段代码的执行结果也可能是(0,0),看起来违反常理。这是处理器乱序执行的结果:线程t1内部的两行代码之间不存在数据依赖,因此,可以将x = b乱序到a = 1前;同时,线程t2中的y = a早于线程t1中的a = 1执行。一个可能的执行序列如下:

  1. t1: x = b
  2. t2: b = 1
  3. t2: y = a
  4. t1: a = 1

这里将代码等同于指令,不严谨,但不妨碍理解。

看起来,似乎将上述重排序(或乱序)导致的问题称为“可见性”问题也未尝不可。然而,这种重排序的危害要远远大于单纯的可见性,因为并不是所有的指令都是简单的读或者写——面试中单例模式有几种写法?volatile关键字的作用、原理中都提到了部分初始化的例子,这种不安全发布就是由于重排序导致的。因此,将重排序归为“可见性”问题并不合适,只能说重排序会导致可见性问题

也就是说,单纯解决内存可见性问题是不够的,还需要专门解决处理器重排序的问题

当然,某些处理器不会对指令乱序,或能够基于多核间的数据依赖乱序。这时,volatile仅用于统一重排序方面的语义。

内存屏障

内存屏障(Memory Barrier)与内存栅栏(Memory Fence)是同一个概念,不同的叫法。

通过volatile标记,可以解决编译器层面的可见性与重排序问题。而内存屏障则解决了硬件层面的可见性与重排序问题

猴子暂时没有验证下述分析,仅从逻辑和系统设计考量上进行了判断、取舍。以后会补上实验。

标准

先简单了解两个指令:

  • Store:将处理器缓存的数据刷新到内存中。
  • Load:将内存存储的数据拷贝到处理器的缓存中。
屏障类型指令示例说明
LoadLoad BarriersLoad1;LoadLoad;Load2该屏障确保Load1数据的装载先于Load2及其后所有装载指令的的操作
StoreStore BarriersStore1;StoreStore;Store2该屏障确保Store1立刻刷新数据到内存(使其对其他处理器可见)的操作先于Store2及其后所有存储指令的操作
LoadStore BarriersLoad1;LoadStore;Store2确保Load1的数据装载先于Store2及其后所有的存储指令刷新数据到内存的操作
StoreLoad BarriersStore1;StoreLoad;Load2该屏障确保Store1立刻刷新数据到内存的操作先于Load2及其后所有装载装载指令的操作。它会使该屏障之前的所有内存访问指令(存储指令和访问指令)完成之后,才执行该屏障之后的内存访问指令

StoreLoad Barriers同时具备其他三个屏障的效果,因此也称之为全能屏障(mfence),是目前大多数处理器所支持的;但是相对其他屏障,该屏障的开销相对昂贵。

然而,除了mfence,不同的CPU架构对内存屏障的实现方式与实现程度非常不一样。相对来说,Intel CPU的强内存模型比DEC Alpha的弱复杂内存模型(缓存不仅分层了,还分区了)更简单。x86架构是在多线程编程中最常见的,下面讨论x86架构中内存屏障的实现。

查阅资料时,你会发现每篇讲内存屏障的文章讲的都不同。不过,重要的是理解基本原理,需要的时候再继续深究即可。

不过不管是那种方案,内存屏障的实现都要针对乱序执行的过程来设计。前文的注释中讲解了乱序执行的基本原理:核心是一个序列缓冲区,只要指令的数据运算对象是可以获取的,指令就被允许在先进入的、旧的指令之前离开序列缓冲区,开始执行。对于内存可见性的语义,内存屏障可以通过使用类似MESI协议的思路实现。对于重排序语义的实现机制,猴子没有继续研究,一种可行的思路是:

  • 当CPU收到屏障指令时,不将屏障指令放入序列缓冲区,而将屏障指令及后续所有指令放入一个FIFO队列中(指令是按批发送的,不然没有乱序的必要)
  • 允许乱序执行完序列缓冲区中的所有指令
  • 从FIFO队列中取出屏障指令,执行(并刷新缓存等,实现内存可见性的语义)
  • 将FIFO队列中的剩余指令放入序列缓冲区
  • 恢复正常的乱序执行

对于x86架构中的sfence屏障指令而言,则保证sfence之前的store执行完,再执行sfence,最后执行sfence之后的store;除了禁用sfence前后store乱序带来的新的数据依赖外,不影响load命令的乱序。详细见后。

x86架构的内存屏障

x86架构并没有实现全部的内存屏障。

Store Barrier

sfence指令实现了Store Barrier,相当于StoreStore Barriers。

强制所有在sfence指令之前的store指令,都在该sfence指令执行之前被执行,发送缓存失效信号,并把store buffer中的数据刷出到CPU的L1 Cache中;所有在sfence指令之后的store指令,都在该sfence指令执行之后被执行。即,禁止对sfence指令前后store指令的重排序跨越sfence指令,使所有Store Barrier之前发生的内存更新都是可见的

这里的“可见”,指修改值可见(内存可见性)且操作结果可见(禁用重排序)。下同。

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

Load Barrier

lfence指令实现了Load Barrier,相当于LoadLoad Barriers。

强制所有在lfence指令之后的load指令,都在该lfence指令执行之后被执行,并且一直等到load buffer被该CPU读完才能执行之后的load指令(发现缓存失效后发起的刷入)。即,禁止对lfence指令前后load指令的重排序跨越lfence指令,配合Store Barrier,使所有Store Barrier之前发生的内存更新,对Load Barrier之后的load操作都是可见的

Full Barrier

mfence指令实现了Full Barrier,相当于StoreLoad Barriers。

mfence指令综合了sfence指令与lfence指令的作用,强制所有在mfence指令之前的store/load指令,都在该mfence指令执行之前被执行;所有在mfence指令之后的store/load指令,都在该mfence指令执行之后被执行。即,禁止对mfence指令前后store/load指令的重排序跨越mfence指令,使所有Full Barrier之前发生的操作,对所有Full Barrier之后的操作都是可见的。

volatile如何解决内存可见性与处理器重排序问题

在编译器层面,仅将volatile作为标记使用,取消编译层面的缓存和重排序。

如果硬件架构本身已经保证了内存可见性(如单核处理器、一致性足够的内存模型等),那么volatile就是一个空标记,不会插入相关语义的内存屏障。

如果硬件架构本身不进行处理器重排序、有更强的重排序语义(能够分析多核间的数据依赖)、或在单核处理器上重排序,那么volatile就是一个空标记,不会插入相关语义的内存屏障。

如果不保证,JVM对volatile变量的处理如下:

  • 在写volatile变量v之后,插入一个sfence。这样,sfence之前的所有store(包括写v)不会被重排序到sfence之后,sfence之后的所有store不会被重排序到sfence之前,禁用跨sfence的store重排序;且sfence之前修改的值都会被写回缓存,并标记其他CPU中的缓存失效。
  • 在读volatile变量v之前,插入一个lfence。这样,lfence之后的load(包括读v)不会被重排序到lfence之前,lfence之前的load不会被重排序到lfence之后,禁用跨lfence的load重排序;且lfence之后,会首先刷新无效缓存,从而得到最新的修改值,与sfence配合保证内存可见性。

在另外一些平台上,JVM使用mfence代替sfence与lfence,实现更强的语义。

二者结合,共同实现了Happens-Before关系中的volatile变量规则。

JVM对内存屏障作出的其他封装

除volatile外,常见的JVM实现还基于内存屏障作了一些其他封装。借助于内存屏障,这些封装也得到了内存屏障在可见性与重排序上的语义

借助:piggyback。

在JVM中,借助通常指:将Happens-Before的程序顺序规则与其他某个顺序规则(通常是监视器锁规则、volatile变量规则)结合起来,从而对某个未被锁保护的变量的访问操作进行排序。

本文将借助的语义扩展到更大的范围,可以借助任何现有机制,以获得现有机制的某些属性。当然,并不是所有属性都能被借助,比如原子性。但基于前文对内存屏障的分析可知,可见性与重排序是可以被借助的。

下面仍基于x86架构讨论。

final关键字

如果一个实例的字段被声明为final,则JVM会在初始化final变量后插入一个sfence。

类的final字段在<clinit>()方法中初始化,其可见性由JVM的类加载过程保证。

final字段的初始化在<init>()方法中完成。sfence禁用了sfence前后对store的重排序,且保证final字段初始化之前(include)的内存更新都是可见的。

再谈部分初始化

上述良好性质被称为“初始化安全性”。它保证,对于被正确构造的对象,所有线程都能看到构造函数给对象的各个final字段设置的正确值,而不管采用何种方式来发布对象

这里将可见性从“final字段初始化之前(include)的内存更新”缩小到“final字段初始化”。猴子没找到确切的原因,手里暂时只有一个jdk也不方便验证。可能是因为,JVM没有要求虚拟机实现在生成<init>()方法时编排字段初始化指令的顺序

初始化安全性为解决部分初始化问题带来了新的思路:如果待发布对象的所有域都是final修饰的,那么可以防止对对象的初始引用被重排序到构造过程完成之前。于是,面试中单例模式有几种写法?中的饱汉变种三还可以扔掉volatile,改为借助final的sfence语义:

// 饱汉
// ThreadSafe
public class Singleton1_3 {
  private static Singleton1_3 singleton = null;
  
  public int f1 = 1;   // 触发部分初始化问题
  public int f2 = 2;

  private Singleton1_3() {
  }

  public static Singleton1_3 getInstance() {
    if (singleton == null) {
      synchronized (Singleton1_3.class) {
        // must be a complete instance
        if (singleton == null) {
          singleton = new Singleton1_3();
        }
      }
    }
    return singleton;
  }
}

注意,初始化安全性仅针对安全发布中的部分初始化问题,与其他安全发布问题、发布后的可见性问题无关。

CAS

在x86架构上,CAS被翻译为"lock cmpxchg..."。cmpxchg是CAS的汇编指令。在CPU架构中依靠lock信号保证可见性并禁止重排序。

lock前缀是一个特殊的信号,执行过程如下:

  • 对总线和缓存上锁。
  • 强制所有lock信号之前的指令,都在此之前被执行,并同步相关缓存。
  • 执行lock后的指令(如cmpxchg)。
  • 释放对总线和缓存上的锁。
  • 强制所有lock信号之后的指令,都在此之后被执行,并同步相关缓存。

因此,lock信号虽然不是内存屏障,但具有mfence的语义(当然,还有排他性的语义)。

与内存屏障相比,lock信号要额外对总线和缓存上锁,成本更高。

JVM的内置锁通过操作系统的管程实现。且不论管程的实现原理,由于管程是一种互斥资源,修改互斥资源至少需要一个CAS操作。因此,锁必然也使用了lock信号,具有mfence的语义。

锁的mfence语义实现了Happens-Before关系中的监视器锁规则。

CAS具有同样的mfence语义,也必然具有与锁相同的偏序关系。尽管JVM没有对此作出显式的要求。


参考:


本文链接:一文解决内存屏障 作者:猴子007
出处:monkeysayhi.github.io

 

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值