并发编程之可见性、有序性和原子性的理解
并发处理的广泛应用使得Amdahl定律代替摩尔定律成为计算机性能发展源动力的根本原因,也是人类“压榨”计算机运算能力的最有力武器。
在学习并发编程,很多概念需要理解透彻,理解清楚。
其中有三个基本的关注点:
- 安全性,也就是正确性,指的是程序灾并发情况下执行的结果和预期一致
- 活跃性,比如死锁,活锁
- 性能,减少上下文切换,家少内核调用,较少一致性流量等等
安全性问题是首要解决的问题,保证程序的线程安全实际上就是对多线程的同步,而多线程的同步本质上就是多线程通信的问题。在操作系统里面定义了集中进程通信的方式:
- 管道 pipeline
- 信号 signal
- 消息队列 message queue
- 共享内存 shared memory
- 信号量 semaphore
- Socket
Java里面面进行多线程通信的主要方式就是共享内存的方式,共享内存主要的关注点有两个:可见性和有序性。加上复合操作的原子性,我们可以认为Java的线程安全问题主要关注点有3个:
- 可见性
- 有序性
- 原子性
Java内存模型(JMM)解决了可见性和有序性的问题,而锁解决了原子性的问题。
可见性
可见性指的是一个线程对变量的写操作对其它线程后续的读操作可见。
由于现在CPU都有多级缓存,CPU的操作都是基于告诉缓存的,而线程通信是基于内存的,这中间有一个Gap,可见性的关键还是在对变量的写操作之后能够在某个时间点显式地写回到主内存,这样其它线程就能从主内存中看到最新的写的值。
volatile、synchronized(隐式锁)、显式锁和原子变量这些同步手段都可以保证可见性。
可见性底层的实现就是通过加载内存屏障实现的:
- 写变量后加写屏障,保证CPU写缓冲区的值强制刷新回内存
- 读变量之前加读屏障,使缓存失效,从而强制从主内存读取变量最新值
写volatile变量 = 进入锁
读volatile变量 = 释放锁
有序性
有序性指的是数据不相关变量在并发的情况下,实际执行的结果和单线程的执行结果和单线程的执行结果是一样的,不会因为重排序的问题导致结果不可预知。volatile,final,synchronized和显式锁都可以保证有序性。
有序性的语意有几层:
- 最常见的就是保证多线程执行的串行顺序
- 防止重排序引起的问题
- 程序执行的先后顺序,比如JMM定义的一些Happens-before规则
重排序的问题是一个单独的主题,常见的重排序有三个层面:
- 编译级别的重排序,,比如编译的优化
- 指令级重排序,比如CPU执行执行的重排序
- 内存系统的重排序,比如缓存和读写缓冲去导致的重排序
原子性
原子性是指某个(些)操作在语意上是原子性。比如读操作,写操作,CAS(compare and set)操作在机器指令级别是原子的,又比如一些复合操作在语义上也也是原子的,如先检查后操作if(xxx == null){}
有个专有名词竞态条件来描述原子性的问题。
竞态条件(racing condition)是指某个操作由于不同的执行时序而出现的不同的结果,比如先检查后操作。volatile变量只保证了可见性,不保证原子性,比如a++这种情况在编译后实际上是多条语句,比如先读a的值,再加1操作,执行了3个原子操作,如果在并发情况下,另一个线程有可能读到了中间状态,从而导致程序语意上的不正确,所以a++实际上是一个复合操作。
加锁可以保证复合语句的原子性,synchronized可以保证多条语句在synchronized块中语意上是原子的,显式锁保证临界区的原子性。
原子变量也封装了对变量的原子操作。
非堵塞容器也提供了原子操作的接口,比如putIfAbsent。