Java 提供了同步机制,解决并发编程的三个主要问题:
- 可见性。CPU缓存可以平衡 CPU 和内存的速度差,但如果线程对共享变量的修改未及时回写内存,其它线程就看不到该修改。
- 有序性。编译器、处理器为了提高缓存利用率,会重排指令顺序。
-
1 volatile
关键字 volatile 解决可见性、有序性问题。声明 volatile 可以确保所有线程读到的值一致,并禁止编译器指令重排。(无法禁止处理器重排序、内存重排序归,它们归 OS 管)
1.1 可见性
MESI 缓存一致性协议,监听共享总线上消息,修改缓存中数据的状态,从而保证数据的一致性。
volatile 使用 MESI,标记缓存中修改过的数据为 Invalidated,下次访问该数据会读取内存,从而实现可见性。1.2 有序性
Singleton {
static Singleton instance;
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
正常情况下,第7行编译后的指令:
new。分配一块内存M。
- invokespecial。在内存M上初始化对象。
- astore。将内存M的地址赋给 instance 变量。
如果发生指令重排,astore 可能会排在 invokespecial 之前。假如线程A执行完 astore 后切换,线程B 执行到第4行发现 instance 非空,于是返回一个未经初始化的 instance,产生有序性问题。
内存屏障
2 synchronized
关键字 synchronized 解决原子性问题。
总线锁(声言 LOCK#)、缓存行锁
2.1 原子性
雪花算法的自增序列,可以使用 synchronized、AtomicLong、LongAdder 实现同步自增。先测试3个场景:
- 单线程场景 synchronized >> LongAdder > AtomicLong
- 低并发场景 AtomicLong > LongAdder > synchronized
- 高并发场景 LongAdder > synchronized > AtomicLong
可以发现 synchronized 在单线程场景表现最佳,并发工具 LongAdder 在高并发场景最优。
2.2 锁升级
JDK6 对 synchronized 做了一系列优化:锁消除
JVM检测到一些同步的代码块,完全不存在数据竞争的场景,也就是不需要加锁,就会进行锁消除锁粗化
有很多操作都是对同一个对象进行加锁,就会把锁的同步范围扩展到整个操作序列之外锁升级
根据并发度,提升锁的强度:偏向锁、轻量锁、重量锁
2.2.1 偏向锁
只有一个线程执行同步代码,线程不会主动释放偏向锁。
- 线程A 访问 Mark Word,确认锁标志位 01,偏向锁标识位 1,线程ID是自己,执行同步代码
- 线程B 访问 Mark Word,线程ID不是自己,CAS修改线程ID失败,挂起线程A
- 到达 safepoint,如果:
3.1 线程A 已退出同步代码,修改线程ID 为 null
3.2 线程A 未退出同步代码,修改锁标志位 00,升级为轻量锁
结合单线程场景看,synchronized 可能会进行锁消除,即便加了偏向锁也不会主动释放,因此效率最高。
2.2.2 轻量锁
多个线程交替执行同步代码,线程尝试自旋获取锁,失败一定次数后(JVM控制,自适应)把锁升级为重量锁。
- 线程A 将 Mark Word 复制到本栈帧的锁记录,CAS 修改 Mark Word 指向自己的锁记录成功
- 线程B 将 Mark Word 复制到本栈帧的锁记录,CAS 修改 Mark Word 指向自己的锁记录失败,自旋重试
- 线程B 自旋一定次数后,修改锁标志位 10,升级为重量锁
轻量锁通过自旋的方式提升效率,因为 LWP 阻塞、唤醒需要上下文切换,代价很大。
2.2.3 重量锁
多个线程同时执行同步代码,阻塞取锁失败的线程。ObjectMonitor 有3个重要属性:EntryList、WaitSet、Owner。
- 线程A、线程B 进入同步代码块,进入 EntryList
- EntryList 中的线程通过 CAS 修改 Owner,成功者获取到锁,失败者 BLOCKED
- 线程A 调用 wait(),修改 Owner 为 null,同时进入 WaitSet 等待别的线程 notify 后重入 EntryList
-
3 Java 内存模型
JMM 工作内存抽象了 CPU cache,主内存抽象了 memory。为了实现同步,JMM 还制定了 happen-before 规则: 程序顺序规则。同线程中的代码要按顺序执行。
- monitor规则。解锁后才能加锁。
- volatile规则。对 volatile 变量的写操作优先于读操作。
- 线程start规则。调用线程 start() 之前的操作优先于线程中要执行的操作。
- 线程join规则。t1 调用 t2.join(),那么 t2 的操作优先于 t1 从 t2.join() 的成功返回。
传递规则。如果操作a优先于操作b,操作b优先于操作c,那么操作a优先于操作b。
4 死锁
死锁的形成须符合4个条件:
互斥条件。一个资源每次只能被一个进程使用。
- 请求与保持条件。一个进程因请求资源而阻塞时,对已获得的资源保持不放。
- 不剥夺条件。进程已获得的资源,在末使用完之前,不能强行剥夺。
- 循环等待条件。若干进程之间形成一种头尾相接的循环等待资源关系。
破化任意一个条件就可以避免死锁,最简单是破坏循环等待条件:将系统中所有的资源设置标志位、排序,规定所有的进程申请资源必须以一定的顺序加锁。