基础知识
线程切换代价
Java的线程是映射到操作系统的原生线程之上的,如果阻塞或唤醒一个线程就需要操作系统介入,需要在用户态和内核态之间切换,该切换会消耗大量的系统资源,因为用户态和内核态均有各自专用的内存空间,专用的寄存器等,用户态切换至内核态需要传递很多变量、参数给内核,内核也需要保护好用户态切换时的一些寄存器值、变量等,以便内核态调用结束后切换回用户态继续工作。
JVM1.6之前,Synchronized会导致争不到锁的线程直接进入阻塞状态,所以说其是一个重量级的同步操作,被称为重量锁。
为了缓解上述的性能问题,JVM1.6开始,引入了偏向锁、轻量锁,其均属于乐观锁。
Mark Word
在JVM 1.6中,对象实例在堆内存中被分为3部分: 对象头、实例数据、对齐填充。
对象头的组成部分: Mark Word、指向类的指针、数组长度(可选,数组类型时才有),每个部分长度均为1个字宽,32位的JVM中,1字宽位32 bit,64位的JVM中,1字宽为64 bit。
锁升级功能主要依赖Mark Word中锁标志位和是否偏向锁标志位。
Synchronized同步锁的升级优化路径: 偏向锁->轻量级锁->重量级锁。
锁升级
偏向锁
偏向锁主要用来优化同一线程多次申请同一个锁的竞争,在某些情况下,大部分时间都是同一个线程竞争锁资源。
当线程1再次获取锁时,会比较当前线程ID与锁对象头Mark Word中的线程ID是否一致。
- 如果一致,直接获取锁,无需CAS来抢占锁;
- 如果不一致,需要查看锁对象头Mark Word中的线程是否存活:
- 若存活,查找线程1的栈帧信息,如果线程1还需要继续持有该锁对象,那么暂停线程1(Stop-The-World),撤销偏向锁,升级为轻量级锁;如果线程1不再使用锁对象,则将锁对象设置为无锁状态(也属于锁撤销),然后重新偏向线程2;
- 若不存活,则将锁对象设置为无锁状态(也属于锁撤销),然后重新偏向线程2。
可以看到,当持有锁的线程宕掉之后,其他请求锁的线程会检查持有锁的线程是否存活,若不存活则直接撤销锁,从而避免了死锁。
在高并发场景下,当大量线程同时竞争同一个锁资源时,偏向锁会被撤销,发生STW,加大性能开销。
JVM的默认配置为: -XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=4000,即默认开启偏向锁,并且延迟4秒生效,之所以延迟,是因为JVM刚启动时竞争比较激烈。
关闭偏向锁: -XX:-UseBiasedLocking,也可以直接设置为重量级锁: -XX:+UseHeavyMonitors。
轻量锁
轻量锁适应的场景是: 各线程交替执行同步块,大部分的锁在同步周期内不存在长时间的竞争。
轻量锁在虚拟机内部是通过BasicObjectLock对象实现的,该对象内部由一个BasicLock对象_lock和一个锁对象指针_obj组成,BasicObjectLock对象放置在Java栈帧中。
在BasicLock内部还维护着displace_header字段,用于备份锁对象头部的Mark Word。
// A BasicObjectLock associates a specific Java object with a BasicLock.
// It is currently embedded in an interpreter frame.
class BasicObjectLock VALUE_OBJ_CLASS_SPEC {
private:
BasicLock _lock; // the lock, must be double word aligned
oop _obj; // object holds the lock;
};
class BasicLock VALUE_OBJ_CLASS_SPEC {
private:
volatile markOop _displaced_header;
};
当需要判断一个线程是否持有该锁对象时,只需要简单判断锁对象头的指针是否在当前线程栈地址范围即可。
加锁
线程在执行同步块之前,JVM会先在当前线程的栈帧中创建用于存储锁记录的空间,即BasicObjectLock对象。
创建过程如下:
- 将锁对象头的Mark Word拷贝赋值给BasicObjectLock中BasicLock对象的_displaced_header字段;
- 然后线程尝试使用CAS将锁对象头的Mark Word替换为指向该BasicObjectLock对象的指针;
- 若成功,则当前线程获得锁。若失败,表示其他线程竞争锁,当前线程尝试使用自旋来获取锁。
可以看到,当前线程即时获取锁失败,也不会立即阻塞挂起,而是先尝试使用自旋来获取锁。如果竞争不是很激烈,可能几次自旋,当前线程就获取到锁了,从而避免了1次线程的上下文切换。
若当前线程自旋获取锁失败,锁就会膨胀成重量锁,当前线程阻塞挂起。
解锁
轻量锁解锁时,会使用原子的CAS操作将BasicObjectLock对象备份的_displaced_header替换回到锁对象头的Mark Word。若成功,则表明没有竞争发生,解锁成功;若失败,则表明当前锁存在竞争(此时锁已经膨胀为重量锁),释放锁并唤醒阻塞的线程。
自旋锁
当锁处于轻量锁状态,且被某线程持有时,其他线程尝试获取锁失败后,不会直接阻塞挂起,而是先自旋一定次数,避免正在持有锁的线程可能在很短的时间内释放锁资源。
从JVM 1.7开始,自旋锁默认启用,自旋次数不宜设置过大(避免长时间占用CPU),-XX:+UseSpinning -XX:PreBlockSpin=10,JVM 1.7之后,默认的自旋次数由JVM根据实际系统环境灵活设置。
在锁竞争不是很激烈且锁占用的时间非常短的场景下,自旋锁可以通过减少上下文切换来提高系统性能;在锁竞争激烈或者锁占用时间较长的场景下,自旋锁会导致大量的线程一直处于CAS重试状态,造成CPU空转。
在高并发场景下,可以通过关闭自旋锁来优化系统性能: -XX:-UseSpinning。
该线程自旋之后仍旧未获取锁,则其会将锁对象升级为重量锁。未抢到锁的线程都会进入Monitor,之后会被阻塞到WaitSet中。
这里有个问题,假设持有轻量锁的线程执行同步块的时候宕掉了,则不会有释放锁并唤醒阻塞线程的动作,此时会造成死锁吗?
答案是肯定不会的,因为其他线程请求锁时,会有1个线程自旋获取锁失败后将锁升级为重量锁,然后获取重量锁的线程在释放的时候会将WaitSet中阻塞的线程唤醒,也就是说即使持有轻量级锁的线程不唤醒阻塞线程,其他持有重量级锁的线程在释放锁的时候也会唤醒WaitSet中的阻塞线程的,可谓是双重保障,哈哈。
重量锁
当多个线程同时请求某个Monitor时,对象的Monitor会设置以下状态用来区分请求的线程:
- Contention List: 所有请求锁的线程被首先放置到该竞争队列;
- Entry List: Contention List中那些有资格成为候选人的线程会被转移到Entry List;
- Wait Set: 调用Wait方法等被阻塞的线程会被放置到Wait Set;
- OnDeck: 任何时刻仅能有1个线程竞争锁,该线程称为OnDeck;
- Owner: 获取锁的线程称为Owner;
- !Owner: 释放锁的线程。
EntryList与ContentionList逻辑上同属等待队列,ContentionList会被线程并发访问,为了降低对ContentionList队尾的争用,而建立EntryList。Owner线程在unlock时会从ContentionList中迁移线程到EntryList,并会指定EntryList中的某个线程(一般为Head)为Ready(OnDeck)线程。Owner线程并不是把锁传递给OnDeck线程,只是把竞争锁的权利交给OnDeck,OnDeck线程需要重新竞争锁。这样做虽然牺牲了一定的公平性,但极大的提高了整体吞吐量,在 Hotspot中把OnDeck的选择行为称之为“竞争切换”。
OnDeck线程获得锁后即变为Owner线程,无法获得锁则会依然留在EntryList中,考虑到公平性,在EntryList中的位置不发生变化(依然在队头)。如果Owner线程被wait方法阻塞,则转移到WaitSet队列;如果在某个时刻被notify/notifyAll唤醒,则再次转移到EntryList。
需要注意的是:当持有重量锁的线程在运行期间出错,会自动释放掉锁,从而避免死锁。
小结
锁升级的过程可以通过下面2张图来展示:
参考
- https://www.cnblogs.com/lykm02/p/4516777.html
- https://www.cnblogs.com/sevencutekk/archive/2019/09/21/11563367.html