多线程进行并发访问资源时需要进行锁同步,否则会出现两个线程之间的计算交叠造成逻辑错误。在java中常用的关键字syncronized就是用来进行加锁同步的。下面我们就来聊一聊syncronized的实现原理。
有过C++开发经验的同学都知道,在C++中进行锁同步通常会使用mutex(互斥量)。互斥量是操作系统提供给我们的一种能力,通过他可以实现资源的抢占与访问隔离。调用伪代码如下:
std::mutex mutex;
mutex.lock();
access_resource();
mutex.unlock;
上面是一个常见的加锁逻辑(这是一个悲观锁的逻辑)。在java当中,我们同样依赖mutex实现访问隔离。通过将资源(每个java对象)与一个对应的mutex关联起来,然后在对象访问前后分别调用上面的逻辑。但是在调用mutex会使得线程发生状态转换,导致线程在内核态和用户态之间进行切换从而导致性能问题,因此我们称mutex为一个重型锁。加锁时线程状态转换关系如下:
mutex调用之后线程会切换到BLOCKED状态(让渡出线程时间分片),当竞争锁成功线程又会被唤醒至RUNNABLE状态。这两者之间的转换对操作系统的开销比较大(线程会在内核态和用户态之间转移)。因此JVM在实现syncronized关键字时进行了针对性的性能优化。
优化思想是尽量采用乐观锁替代悲观锁,从而避免mutex的调用。这是基于一个假设,大部分情况下,线程的竞争是很弱的,且线程占用资源的时间分片会很短,也就是说锁会很快被释放。说到乐观锁,它的实现原理是CAS(Compare And Swap)思想。CAS是操作系统提供的一种原子能力。使用CAS实现乐观锁的伪代码如下:
//flag作为锁标志位关联访问资源
boolean flag = false;
if (CAS(flag, expect=false, true)) {
//加锁成功访问资源
accessResource();
//解锁
flag= false;
} else {
//不成功就重试
retry();
}
这样就避免了mutex的调用。但是乐观锁也有自身的缺点,就是当竞争激烈的时候,线程会不停的重试,不会让渡出线程资源,造成计算资源的浪费。所以在重度竞争场景还是使用mutex比较合理。因此我们会倾向于依次进行不加锁,乐观锁,mutex的逐级尝试。这就是syncronized的锁升级实现。
第一级偏向锁
偏向锁倾向于认为当前资源访问不存在竞争。在本线程占有资源之后不会有其他线程并发访问(如果有就需要进行锁升级)。偏向锁也是基于CAS乐观锁的实现。上面提到CAS实现乐观锁需要将资源(java对象)与一个flag锁标志位关联起来。syncronized利用了每个java对象头中的Markword空间。Markword是每个java对象头中一个4字节空间(32机器)。当偏向锁模式时,其各bit功能如下:
抢占线程ID(23bit) | Epoch(2bit) | 分代年龄(4bit) | 是否偏向锁(1bit) | 锁标志位(2bit) |
偏向锁运行过程如下:
-
初始化时抢占线程ID为空
-
线程A比较Markword占用线程ID字段,如果指向自己则直接访问资源。否则通过CAS(expect=null, set=self)操作将Markword中的线程ID指向自己,抢占成功,否则加锁失败升级锁。占锁成功后,在自己的线程栈帧中创建LockRecord指向Markword。LockRecord还记录本线程加锁次数(为可重入服务,后面会介绍)
-
线程访问对象资源
-
线程A解锁后,清空线程栈帧中的LockRecord,但是不清理Markword中的占用线程ID
从上面的过程可以看出当线程A占锁成功后,后面再次访问都是无同步访问(CAS都不需要,因为Markword中线程ID指向了自己)。但是当线程解锁后,如果有线程B访问,此时也会造成锁升级。因此偏向锁是一个基于无竞争假设的实现。
那么是不是线程B的CAS加锁一定会失败那,也不是,syncronized中设置了锁批量重偏向和批量撤销的机制(结合epoch实现,不再展开),当满足一定条件会重置抢占线程ID,所以线程B的并发访问有一定的概率可以通过CAS加锁成功,避免锁升级。
第二级轻量锁
前面提到当出现两个线程竞争时需要将偏向锁升级为轻量锁。轻量锁是一种基于CAS重试机制实现的轻量级乐观锁。实现原理与之前提到的CAS乐观锁一致。同步资源的抢占标志也是利用对象头中的Markword字段,轻量锁Markword字段如下:
指向线程栈Markword副本指针(30bit) | 锁标志位(2bit) |
升级为轻量锁时,抢占线程会将对象头中的Markword拷贝一份副本存放在自己的线程栈帧中。锁抢占时通过CAS操作将对象头中的Markword副本指针指向自己,如果设置成功则抢占成功。抢占示意如下:
解锁时通过CAS将对象头中的副本指针置空。如果线程竞争失败,会进行空转重试(自旋)。如果线程多次尝试失败,那说明当前竞争异常激烈,需要将锁升级为重型锁。
第三级重型锁
重型锁通过mutex互斥量实现。mutex被包装在Monitor对象中。资源对象通过对象头中Markword对象存放的Monitor指针与锁对象相关联。此时Markword结构如下:
指向Monitor对象指针(30bit) | 锁标志位(2bit) |
下面重点看下Monitor类。Monitor是一个由C++实现的类,主要类成员如下:
-
_owner指向持有Monitor对象的线程
-
_EntryList等待竞争处于BLOCKED状态的线程存放队列
-
_WaitSet调用了wait方法,而进入等待状态的线程存放队列
-
_recursions当前持锁线程重入次数
当线程通过mutex占锁成功后就将_owner指向占锁线程。抢占失败的线程会被操作系统挂起进入到_EntryList中等待资源释放后被唤醒。整个过程线程转移关系如下:
WaitSet是持有锁的线程,在代码中主动调用了wait()接口后被操作系统挂起的线程。当有其他线程调用了notify接口后,挂起线程会被唤醒,重新竞争锁资源继续运行。线程wait,notify通过条件变量实现(condition variable)。
_recursions成员是用来实现锁重入机制的。锁重入是指程序中实现嵌套加锁,例如:
在第二次加锁时,当前线程只要检查到_owner字段指向自己,就可以避免mutex调用,直接获取锁资源,同时将recursions字段加一。解锁时通过减一操作,当recursions归零时实现真正的解锁操作(前面的偏性锁和轻量锁中的LockRecord也有加锁次数字段实现响应的功能)。以上就是我对syncronized的实现过程的理解。主要思想就是乐观地看待竞争,尽量通过轻量级的同步操作解决问题,逐步升级应对措施。
纯属个人理解,欢迎交流。