Java 并发编程
Java并发机制底层原理
Java的并发机制依赖于 JVM 的实现和 CPU 的指令
前置知识
CPU的术语定义
术语 | 描述 |
---|---|
内存屏障(memory barriers) | 是一组处理器指令,用于实现对内存操作的顺序限制 |
缓冲行(cache line) | CPU高速缓存中可以分配的最小存储单位,处理器填写缓存行时会加载整个缓存行,现代CPU需要执行几百次CPU指令 |
原子操作(atomic operations) | 不可中断的一个或一系列操作 |
缓存行填充(cache line fill) | 当处理器识别到从内存读取到的操作数是可缓存的,处理器读取整个高速缓存行到适当的缓存(L1、L2、L3或所有) |
缓存命中(cache hit) | 如果进行高速缓存行填充操作的内存位置仍然是下次处理器访问的地址时,处理器从缓存中读取操作数,而不是从内存中读取 |
写命中(write hit) | 当处理器将操作数写回到一个内存缓存的区域时,它首先会检查这个缓存的内存地址是否在缓存行中,如果存在一个有效的缓存行,则处理器将这个操作数写回到缓存,而不是写回到内存,这个操作被称为写命中 |
写缺失(write misses the cache) | 一个有效的缓存行被写到不存在的内存区域 |
MESI(缓存一致性协议)
处理器有一套完整的协议来保证Cache一致性,比较经典的是MESI协议,在MESI协议中,每个缓存行有4个状态,可以用两个bit表示:
状态 | 描述 |
---|---|
M(Modified) | 数据有效,但被修改,与内存中不一致,数据仅存在本缓存中 |
E(Exclusive) | 数据有效,与内存中一致,数据仅存在本缓存中 |
S(Shared) | 数据有效,与内存中一致,数据存在多个缓存中 |
I(Invalid) | 数据无效 |
MESI协议状态迁移图如下:
存储器层次结构
volatile可见性原理
volatile 在并发编程中保证了共享变量的可见性,当一个线程修改共享变量的值时,volatile可以保证另一个线程可以读到修改后的值,volatile比synchronized更轻量,因为volatile不会引起上下文切换。
volatile修饰的共享变量在修改时会进行两个操作:
- 将当前处理器缓存行的数据写回系统内存
- 写回内存的操作会使其它处理器缓存的该缓存变量失效
对照MESI协议,可以了解此过程中各处理器的状态变化:修改了共享变量并写入内存的CPU的状态变化为 S -> M -> E
,其它CPU的状态变化为 S -> I
;其它CPU从内存中读取该变量时又会使得状态变化为 I -> S
,而刚刚修改了该变量的CPU的状态变化为 E -> S
。
Synchronized原理
Java对象头
Java对象在内存中的布局可以分为3个区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。Synchronized用的锁存储在Header里,Header的结构如下图所示:
其中Mark Word存储了锁相关的信息,其数据会随着锁标志位的变化而变化:
锁的升级与对比
Java SE在1.6版本为了减少获得锁和释放锁带来的性能消耗,引入了 偏向锁 和 轻量级锁,故锁共有4种状态,级别从低到高分别是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态。
Java内存模型
Java内存模型是个很复杂的规范,本质上可以理解为Java内存模型规范了JVM如何提供按需禁用缓存和编译优化的方法。具体的说,这包括了
volatile
、synchronized
、final
三个关键字以及六项Happens-Before
规则。
并发编程Bug的源头
随着计算机的快速发展,CPU、内存、I/O设备都在不断迭代更新,但在这个快速发展的过程中,有一个核心矛盾始终存在,就是这三者的速度差异。CPU和内存的速度差异巨大,而内存和I/O设备的速度差异更大,而程序整体的性能往往取决于最慢的操作——读写I/O设备,即是单纯的提高CPU性能是无效的。
为了合理利用CPU的高性能,平衡三者的速度差异,计算机做出了一些设计和调整:
- CPU增加了缓存,以均衡与内存的速度差异
- 操作系统增加了进程、线程,分时复用CPU,进而均衡CPU与I/O设备的速度差异
- 编译程序优化指令执行次序,使得缓存能够更加合理的利用
除了带给我们收益之外,这也带来了很多并发编程的问题:
缓存导致的可见性问题
一个线程对共享变量的修改,另一个线程可以立刻看到,我们称之为可见性。在多核时代,每颗CPU都有自己的缓存,这时缓存与内存的数据一致性就出现问题了,当多个线程在不同CPU上执行时,这些线程操作的是不同的CPU缓存,当运行在CPU-1上的线程A修改了共享变量的值时,操作的是CPU-1上的缓存,而此时在CPU-2上运行的线程B显然无法感知线程A对共享变量的修改,即线程A对共享变量的操作对于线程B而言不具有可见性。
线程切换带来的原子性问题
现代操作系统基于线程进行CPU调度,操作系统允许一个线程运行一个时间片,时间片结束后会重新选择一个线程来执行,线程的切换可以发生在任何一条CPU指令执行完,而我们在代码里的一行简单操作往往需要多条CPU指令才能完成,例如 count += 1
,至少需要3条指令,我们称这样的操作不是原子性的:
- 把变量
count
从内存加载到寄存器 - 在寄存器执行+1操作
- 将结果写入内存(或CPU缓存)
假设线程A在执行完指令1后进行了线程切换,线程A和线程B按照下列图序列执行,会发现两个线程都执行了 count += 1
的操作,但结果却只加了一次:
编译优化带来的有序性问题
有序性指程序按照代码先后顺序执行,但编译器为了优化性能,有时候会改变程序语句中的顺序,可能导致意想不到的bug,比较经典的例子就是Java利用双重检查创建单例对象:
public class Singleton {
static Singleton instance;
static Singleton getInstance() {
if (instance == null) {
synchronized(Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
假设线程A和线程B同时调用 getInstance()
方法,它们会发现 instance == null
,于是尝试加锁,假设线程A竞争到了锁,判断此时 instance == null
,会初始化 instance
,之后释放锁,此时如果线程B竞争到了锁,再次判断 instance == null
时会发现已经不为空了,因此线程B不会重复创建 instance
。
看似逻辑清晰的单例创建流程,却会因为编译重排导致有bug出现,问题就在new操作上,我们认为的流程应该是:
- 分配一块内存M
- 在内存M上初始化
Singleton
实例 - 将M的地址赋给
instance
变量
但实际上经过编译器优化后,执行顺序变为如下:
- 分配一块内存M
- 将M的地址赋给
instance
变量 - 在内存M上初始化
Singleton
实例
这会导致一个问题,我们假设线程A调用了 getInstance()
方法,获取到锁之后创建单例对象,当执行到指令2时,也即把M的地址赋给了 instance
之后发生了线程切换,线程B刚执行 getInstance()
,这时B在第一次进行 instance
是否为空的判断时发现 instance != null
,会直接返回 instance
,但此时的单例对象还没有进行指令3的初始化,如果这个时候我们访问单例的成员变量,就可能触发空指针异常。