并发编程概述:内存屏障、Java volatile

并发编程三条特性:

  • 原子性
    原子性是指一个操作是不可中断的,要么全部执行成功要么全部执行失败。

  • 可见性
    可见性是指当一个线程修改了共享变量后,其他线程能够立即看见这个修改。

  • 有序性
    有序性是指程序指令按照预期的顺序执行而非乱序执行,乱序又分为编译器乱序和CPU执行乱序。

系统架构

首先展示一幅手绘图,概略描述单核和多核时的系统架构:

architecture

Register

寄存器。CPU指令执行,寄存器存放指令和数据,线程切换会进行寄存器保存和恢复操作。对线程而言,寄存器可以认为是私有的,即线程“工作内存”。多线程下,指令并发执行,操作各自工作内存,产生了原子性问题。

Cache

CPU缓存。CPU缓存提升了数据加载速度的同时,也带来了缓存一致性问题,该问题通过MESI协议来解决。但MESI协议下,一个CPU可能需要等待另一个CPU响应后才能继续执行,导致了阻塞,影响性能。所以增加了StoreBuffer和InvalidateQueue,也就是需要store时先放到StoreBuffer里,然后继续执行下一条指令,等到其他CPU响应返回后再处理对应store;收到invalidate通知时也不立即处理,而是先放到InvalidateQueue,并立即给予对方响应,然后等到合适时机再一起处理。这种优化提升了CPU执行能力,但也使得MESI协议的操作无法立即得到处理,出现异步,产生了可见性和有序性问题。

Memory

内存。

单核环境下,多线程并发存在原子性问题和编译器乱序问题,因为Cache使用同一份,不存在可见性和CPU乱序问题。

多核环境下,多线程并发存在原子性、可见性和有序性问题。

内存屏障

针对上述情况,编译屏障可以解决编译乱序问题,内存屏障可以解决可见性和CPU乱序问题。

编译屏障作用于编译期,阻止编译器因为优化等对代码进行重排序。

内存屏障作用于指令执行期,又分为读屏障和写屏障。

读屏障

针对InvalidateQueue,确保其被立即处理。

写屏障

针对StoreBuffer,确保收到invalidate ack后数据写入缓存。

在内存屏障实现上,一般在单核上,即为编译器屏障,在多核上,包括编译屏障和读写屏障。

Java volatile

Java volatile通过内存屏障,实现了可见性和有序性。

Java内存屏障

LoadLoad屏障:对于这样的指令序列load1; loadload; load2,load1一定在load2之前执行完,CPU不会对load1和load2进行重排序。

LoadStore屏障:对于这样的指令序列load1; loadstore; store1,load1一定在store1之前执行完,CPU不会对load1和store1进行重排序。

StoreStore屏障:对于这样的指令序列store1; storestore; store2,store1一定在store2之前执行完,store1的写入操作对其他CPU可见,CPU不会对store1和store2进行重排序。

StoreLoad屏障:对于这样的指令序列store1; storeload; load1,store1一定在load1之前执行完,store1的写入操作对其他CPU可见,CPU不会对store1和load1进行重排序。

顺序规则

1
2
3
4
5
6
7
8
9
10
11
12
(volatile)Load      (volatile)Store
| |
| |
LoadStore StoreStore
| | volatile Load
|___________________|_____StoreLoad_________________________
volatile Store | |
| |
LoadLoad LoadStore
| |
| |
(volatile)Load (volatile)Store

Store蕴含写语义,Load蕴含读语义,写与读期望构建一种 happens-before 关系,即一边写,一边读,读到了写的结果,就表示写对读可见了,且写与读之间有了先后关系。同时如果写之前的先后关系,读之后的有先后关系,那么它们之间也就有了先后关系,这就是 happens-before。

内存屏障确定了一种 happens-before 关系。

类似的写/读语义,还有Release/Acquire,Unlock/Lock。