漫谈基于JMM的并发编程

0. 前言

本文围绕“问题”展开,依次阐述“问题从何而来”,“问题是如何体现的“,”问题如何解决“,全文“问题“为主线进行串联.

1. 什么是JMM

JMM(Java Memory Model) 是对底层 CPU 内存模型的抽象,定义了JAVA程序在多线程环境下读写共享内存的顺序规则,规定了 volatile、原子操作、锁、final 等的内存语义,这些内存语义消除了底层CPU和编译优化带来的不确定性,是 JMM 对上层代码的程序语义保证,开发者只有了解和利用这些规则和内存语义才能正确地在 JMM 之上并发编程,否则会陷入种种怪异现象中.

2. 硬件与编译器带来的问题

高速缓存的好处是,CPU不必频繁的读写相对缓慢的内存,积聚多次写操作在缓冲区然后批量刷新到主存,减少CPU的等待和总线占用时间,但也带来了如下问题:

  • 可见性问题,由于内存太慢,CPU引入了一层缓存,而多个CPU核心间缓存是隔离的,于是带来了可见性性问题;

    其实所谓缓存可见性问题,也可以看作是多个CPU核心间的缓存一致性问题.

  • 指令重排问题,为了提升性能,在不改变单线程程序语义(as-if-serial)的前提下,编译器和处理器可以对没有数据依赖的指令进行重排序,指令是乱序执行(out-of-order execution)的.
  • 原子性问题,CPU每一次访问内存,发起一次总线事务,当多个CPU核心同时发起总线事务时,需要进行总线仲裁(Bus Arbitration),只有其中一个可以成功,其他的需要等待,这一机制保证了总线事务的原子性. 但是,取决CPU的位数,32位CPU一次只读写32个bit, 如果要读取64个bit,则需要分成两次, 这样就需要发起两次总线事务,也就不具备原子性了. 以java中的long/double为例, 假如CPU核心A在第一个总线事务内写了long/double变量的高32位,CPU核心B在第二个总线事务内读了这个long/double变量,那么这时B就读到了一个“写了一半“的数据.

2.1 可见性问题在代码中的体现

boolean flag=false
void write(){
	flag=true;
}
void read(){
	while(!flag){
	}
	System.out.println("flag has been set true");
}
复制代码

想象一下,线程A先执行write,之后线程B执行read,按照程序的编码顺序来看,如果线程A已经把flag已经被写为true了,那么线程B应当会读到这flag=true,进而打印输出语句.

但实际情况是,但是线程B一直没有打印输出语句,其读到的flag值一直都是false. 这是因为线程A对flag的写操作仍旧停留在本地缓存,而没有刷新到主存(前面说到这是为了性能的优化),所以对线程B并不可见.

2.2 重排问题在代码中的体现

如下代码中,操作1和操作2之间存在数据依赖,如果重排将改变单线程程序语义,所以编译器和CPU不会重排这两个操作.

int i =1; //1
int j=i; 	//2
复制代码

如下代码中,操作1和操作2之间不具有数据依赖,重排后不影响单线程程序语义,所以编译器和CPU可能会重排这两个操作.

int i=1;	//1
int j=2;	//2
复制代码

如下代码中,操作1与操作2之间没有数据依赖,因此可以重排;3和4也没有数据依赖也可以重排

int i=0;
boolean flag=false;
void write(){
	i=1; //1
	flag=true;	//2
}

void read(){
	if(flag){	//3
		int j = i;	//4
	}
}
复制代码

假如操作1和操作2发生重排,重排后的代码如下:

int i=0;
boolean flag=false;
void write(){
	flag=true;	//2
	i=1; //1
}

void read(){
	if(flag){	//3
		int j = i;	//4
	}
}
复制代码

这种重排虽然并不影响单线程环境下的程序语义,但是并发环境下,重排前后的语义已经完全发生了变化,想象一下线程A/B并发执行,A执行write,B执行read,那么一种可能的执行时序如下:

//A线程执行: 
flag=true;	//2
//B线程执行: 
if(flag){	//3
		int j = i;	//4
}
//A线程执行:
i=1; //1
复制代码

线程B会读到flag=true,但是i的值没有变,回过头去看看重排前的代码,你会发现这种现象很“诡异”,自己写的代码明明是先写i的值,后写flag,结果却发生flag值改变了,但i的还没变的现象.

这就是重排在并发环境下带来的问题,其让程序的执行顺序处于一种不确定的状态,上例中,编译器可能重排操作1和操作2,也可能重排操作3和操作4,也可能都不重排.

2.3 原子性问题在代码中的体现

除了体现在读写数据位数超过总线位数时(如double long读写),原子性还体现read-modify-write操作时. volatile仅能保证单次读写的原子性,并不能保证复合操作的原子性. 以如下代码为例:

class RMWExample{
	volatile int i;
	void volatileIncr(){
		i=i+1;
	}
}
复制代码

i=i+1如果编译成x86汇编,对应如下3条指令,虽然第3步的volatile写指令会加lock前缀保证原子性,但是前两个操作仍是在独立总线事务中执行的,所以这种read-modify-write类型的复合操无法用volatile保证原子性.

mov eax,1	//1
inc eax	//2
lock mov eax,[i的地址]	//3
复制代码

3. 如何解决问题

前面说明了可见性、有序性、原子性的问题,下面从JMM、操作系统、硬件3个层面说明如何解决这些问题.

3.1 JMM层面

硬件和编译器的优化提升了性能,但是其来带可见性和指令重排问题会让并发程序充满不确定性,为了在性能与并发不确定性之间作出权衡,JMM 规定了几种 happens-before规则 用于不同程度上消除不确定性(或者说偏序关系).

happens-beofre 一词源自论文《Time,Clocks and the Ordering of Events in a Distributed System》,用于描述分布式系统中事件之间的偏序关系(partial ordering). JMM中用happens-before描述两个操作之间的执行顺序,如果 A 操作 happens-before B 操作,那么 A 操作的执行结果对 B 操作可见,这也意味着 A 操作与 B 操作不能被重排.

JMM一共定义了7种 happens-before 关系:

  1. as-if-serial规则(也叫程序顺序规则),单个线程的每一个操作都happens-before后续的操作.

    翻译成人话: 单个线程的每一个操作都对后续操作可见,

  2. volatile规则,对一个volatile变量的写 happens-before于 后续的读

    翻译成人话: 如果一个CPU核心A写了volatile变量,那么之后其他核心读该变量时,就必须先把核心A的缓冲区刷新到内存,以保证其它核心可以读到最新值.

  3. 监视器锁规则,临界区内的操作可以按as-if-serial规则重排,但是不能重排到临界区外

    想想临界区内的操作如果重排到临界区外会发生什么? 这将意味着这个操作不具有互斥性,相当于锁了个寂寞. 因此不能重排出临界区是必须的保证

  4. 线程启动规则,一个线程调用Thread.start产生子线程时,父线程之前的操作 happens-before 子线程执行的任意操作.

    执行子线程代码之前,必须把父线程调用Thread.start之前的所有操作刷新到主存.

  5. 程序结束规则,线程中的任意操作都必须在其他线程检测到其已经结束之前执行.

    一个线程检测另一个线程结束的方式可以是: Thread.join返回或者Thread.isAlive返回false

  6. 传递性,A happens-before B,B happens-before C,那么A happens-before C.
  7. final,对象的final域初始化操作 happens-before 对象引用被访问.

    人话: 对象final域的初始化不能被重排到对象构造之外,这保证了线程拿到对象引用之前,final域已经被初始化.

单线程单线程的 happens-before 规则是编译器和CPU默认就遵守的,而多线程的 happens-before 关系则需要使用内存屏障. JMM抽象了4种内存屏障:

  • StoreStore,前面的写对后面的写可见,不可以写写重排
  • StoreLoad,前面的写对后面的读可见,不可以写读重排
  • LoadLoad,前面的读在后面的读可见,不可以读读重排
  • LoadStore,前面的读在后面的写之前,不可以读写重排

需注意,以上内存屏障只是JMM的抽象,编译时,按CPU架构的不同插入的屏障指令也不同.

内存屏障插入在两个操作之间,保证前一个操作与后一个操作的执行顺序,确定了多线程间读写操作的 happens-before 关系.

3.2 操作系统层面

内存屏障实现了多线程间的操作有序性(防重排)以及可见性,而在原子性上,CPU只能保证单条指令的原子性,如果需要保证多条指令的原子性,则需要依靠OS提供的同步支持.

linux内核提供了 futex 作为实现同步原语(synchronization primitive)的基础,高级语言的管程(Monitor)均基于这一特性(或类似的特性)实现等待唤醒. java 中的内置锁和 ReentrantLock 保证了临界区的互斥性,基于此可以实现多条指令的原子性.

3.3 硬件层面

以intel x86架构为例,JIT在编译字节码为汇编时,会给汇编复合指令加lock前缀,保证:

  1. 复合指令的原子性,通过锁总线或者cache locking手段实现

    cache locking无需锁住总线,只会阻塞其他cpu核对相关内存的缓存块的访问

  2. 防重排,CPU不会将“加了lock前缀的指令”与“前面或者后面的指令重排”
  3. 可见性,把缓冲区中的所有数据刷新到内存中(这也意味着volatile写除自身具有可见性外,也会让前面的普通写也具有可见性)

4. 分析 happens-before 规则

在 JVM 这个虚拟抽象的世界里,JMM 屏蔽了底层硬件的差异,抽象出各种 happens-before 规则,这些规则是 JMM 的灵魂,可以看做是 JVM 层面的“缓存一致性协议”.

4.1 as-if-serial规则

as-if-serial规则是最基本的内存语义保证,保证单线程环境下程序语义不变,也就是说只要操作不具有依赖性,编译器和CPU就可以重排.

4.2 volatile规则

如何保证可见性?

当写一个volatile变量时,会锁总线,然后直接写主存,并且将线程本地内存中的所有共享变量也一同刷新到主存,将其他线程对应的本地内存设置为失效. 当读一个volatile变量时,由于缓存失效,线程就会去主存读.

如何保证原子性?

当写一个volatile变量时,会锁总线,避免其他核心读写内存. 如果是在 32-bit CPU 上实现 64-bit 数据的读写原子性,由于读或写要分两条指令进行,需要通过锁来实现临界区.

根据本人查阅的资料显示,有的32位CPU也有64位寄存器,也可以通过一条指令实现读写原子性,不一定需要os以上层面的锁. 不过这块没有深究,暂且退一步来看,在功能上高级语言层面的锁也可以保证原子性,当然缺点是有系统调用,阻塞线程,切换上下文的开销.

如何防重排?

  • 在volatile写操作之前加入StoreStore,保证普通写不能与volatile写重排;之后加入StoreLoad屏障,保证后面的volatile读写不能和当前volatile写重排.

  • 在volatile读操作之后加入LoadLoad和LoadStore屏障,分别保证后面的普通读和写操作不能与volatile读重排.

前文的 重排问题在代码中的体现 的最后一个例子中,如果给flag加上volatile,则可以保证1和2,以及3和4不被重排:

int i=0;
volatile boolean flag=false;
void write(){
	//StoreStore 上面的普通写不能与volatile写(操作2)重排
	flag=true;	//2
	//StoreLoad	后面的volatile读写不能和volatile写(操作2)重排
	i=1; //1
}

void read(){
	if(flag){	//3
	//LoadLoad	后面的普通读操作不能与volatile读(操作3)重排
	//LoadStore	后面的普通写操作不能与volatile读(操作3)重排
		int j = i;	//4
	}
}
复制代码

以上只是概念上的解释,仅用作帮助理解volatile,事实上“一个读写操作”和“一行代码语句或表达式”是两码事.

4.3 锁规则

概念上讲,锁是一种高级抽象,它使用了硬件层面的原子指令来实现state的变更,和OS层面的条件变量(condvar,如linux中的futex)来实现线程等待与唤醒.

AQS

值得注意的是,等待唤醒和现场

Java 中每一个线程对应 JVM 层面均对应一个 OS 层面的 condvar,调用 LockSupport.park 和 LockSupport.unpark 基于底层的 condvar 置来实现线程的等待和唤醒.

AQS 中独立维护了 state 和等待队列,当需要让当前线程阻塞时,会将线程加入等待队列,并调用 LockSupport.park(Object blocker) 阻塞当前线程. 而当要唤醒一个线程时,会从 AQS 队列(或 ConditionObject 队列)中找到要被唤醒的线程,调用LockSuport.unpark(Thread thread) 让 JVM 将这个线程唤醒.

ReentrantLock 为例,调用 lock 时会调用 AQS 对象的 acquire ,如果 tryAcquire 失败的话,那么就会调用 addWaiter 将线程加入 AQS 队列,并且调用 LockSupport.park() 进入 JVM 层面,随后线程对象被加入 condvar 的等待队列,至此一个获取锁失败的线程就这样被阻塞了.

而当线程调用 unlock 时,会调用 release() 然后在 unparkSuccessor 里找出 AQS 队列里要被唤醒的线程(也就是之前获取锁失败而阻塞的线程),最后调用 LockSuport.unpark(Thread thread) ,由 JVM 从 condvar 队列中唤醒线程.

ReentrantLock#newCondition() 返回的 ConditionObjectawaitsignal 原理也上述过程类似.

AQS 的实现有一点反直觉的是,condvar 和 线程一一对应,而不是和 AQS,同步器只负责维护 state 和链表,当需要 blocking/unblocking 一个线程时,通过该线程关联的 condvar 来实现.

内置锁

按照 ReentrantLock 类的说明,内置锁与其在行为和语义上是一致的,就不多介绍了.

A reentrant mutual exclusion Lock with the same basic behavior and semantics as the implicit monitor lock accessed using synchronized methods and statements, but with extended capabilities.

锁是如何保证3要素的?

  • 原子性,通过阻塞线程来实现临界区只有一个线程访问
  • 可见性和有序性,站在 JMM 的抽象层面来看,临界区会加入屏障,保证临界区内的操作不会重排到外面. 从 CPU 层面看, lock 和 unlock 操作都会产生 CAS 操作(因为要更新条件变量),而这会用到 lock 指令,相当于屏障的作用,因此临界区内的指令不会重排到外面,并且对后续操作可见.

4.4 Thread#start()规则

这个规则保证的是主线程在创建子线程之前的操作都对子线程的操作可见,可以看作是在子线程的所有操作之前加入 StoreLoad 屏障,保证主线程的写已经 cache flush.

//主线程的操作
int i = 1;
new Thread(()->{
	//子线程的操作
	//这里插入一条 StoreLoad
	int j = i;
}).start()
复制代码

4.5 Thread#join()规则

确保子线程的修改在被其他线程观测到结束前已经 cache flush,可通过在子线程的指令序列末尾加入一条 StoreLoad 屏障来实现.

int [] arr = {1};
Thread t = new Thread(()->{
	arr[0]=2
	//这里插入一条 StoreLoad
});
t.join();
int r = arr[0]; 
复制代码

4.6 原子类规则

硬件层面

从X86汇编的层面看,原子操作是通过给复合指令加lock前缀来保证的,有些复合指令,比如 cmpxchg,本身不具有原子性,执行时会产生多个总线事务(意味着会经历多次总线的占有和释放). 如果加上lock前缀,多个总线事务就会合并为一个,意味着执行期间会让CPU核心一直锁住总线(或者cache),这也就避免了其他CPU核心在期间修改共享数据. CAS(Compare An Swap)就是通过lock cmpxchg实现的.

JAVA层面

java.util.concurrent.atomic 包中的所有原子类,都是基于native方法Unsafe.compareAndSwapInt(obj,offset,expect,update),底层基于CPU提供的原子指令.

offset参数是通过Usafe#xxOffset()得到的,是如果要改的是对象字段,那么就是数据在对象堆内偏移,如果要改的是数组元素,那么offset就是在数组堆内的偏移

使用CAS可以用于解决以上问题,概念上java实现代码如下:

class RMWExample{
	int i;
	void volatileIncr(){
		//对i字段进行原子更新
		Unsafe.compareAndSwapInt(i,iOffset,1,2);
	}
}
复制代码

iOffseti字段的在对象堆内的偏移,通过 Unsafe.getUnsafe().objectFieldOffset()可以得到.

以上代码编译成X86汇编后如下:

mov edx,i的地址		//1.将i的内存地址读到edx寄存器
mov ecx,2	//2.将要更新的值放入ecx寄存器
mov eax,1	//3.将要比较的值放入eax寄存器
lock cmpxchg dword ptr [edx],ecx	//4.通过i的地址读到内存中的i最新值,与eax寄存器的值(也就是expectValue)相比较,如果一致就写入ecx的值(也就是updateValue). 
复制代码

第4步中cmpxhg是一个if then do(或者说read-modify-write)的复合操作,本身不具有原子性,但是加了lock前缀以后,硬件层面会锁住总线(或者cache locking)来阻塞其他CPU核心对数据的访问.

ABA问题

CAS存在一个问题是,仅仅比较值是否是旧的,而不检查是否在上次访问之后被改过,比如当前线程一开始读到是A,此后其他线程写B后又写为A,那么当前线程在CAS时可以成功(因为值最终还是A).这就是ABA问题,这种情况在有些场景下是不可容忍的. 为了解决ABA问题,可以每次写值时都写一个唯一的版本号作为区分,这样ABA中的前一个A和后一个A虽然值一样但版本就不同.java中提供了AtomicStampedReference作为实现.

4.7 final规则

从程序语义上来看,final变量在构造对象时赋值,之后每次读到的值都应该是一样的. 但是在 JSR-133 以前却可能发生final变量的值被观测到变化的现象.

public class FinalExample{
	final int finalVar;
	public FinalExample(){
		finalVar=1;
	}
}
复制代码
//线程A初始化对象
FinalExample finalExample = new FinalExample();
//将 finalExample 引用传给线程池的线程去访问(省略具体代码)
复制代码

以上例子中,线程A初始化FinalExample对象,并把引用传给线程B.

在旧的内存模型中, final 变量并没有特别的内存语义,编译器和CPU只要遵守 as-if-serial语义,就可以随意重排. 以上代码重排前后的操作序列如下:

JMM-final-0.png

如上图所示,写final域 被重排到了 读对象引用 之后. 在并发环境下,线程A把对象引用传给线程B,然后线程B 先后读两次 final 变量, 这个过程可能产生如下执行序列:

JMM-final-1.png

线程 B 第一次读 final 域读到了初始化前的默认值,线程A 写 final 域之后,线程B第二次读 final 域又读到初始化后的值. 在这个过程中,站在线程 B 的视角,final 域,这个本不可变的字段却被读出两次不一样的值,这违背了 final 关键字的程序语义.

JSR133 增强了 final 域的内存语义,编译器在写 final 域操作之后,到构造函数返回之前,会插入一条 StoreStore 屏障,这保证了 final 域的写会在对象引用被写入某个变量前,CPU 会将 cache flush到主存.

JMM-final-2.png

FinalExample finalExample = new FinalExample(); 可分为两步: 1.执行构造初始化对象堆 2.然后将堆地址写入变量, 构造返回前的 StoreStore 可以确保在第二步之前 final 变量已经 cache flush.

参考文献

  • 《JAVA并发编程的艺术》
  • 《JAVA并发编程实践》
  • 《现代操作系统——原理与实现》
  • AQS源码

猜你喜欢

转载自juejin.im/post/7033295281710071822