syncronized原理解析

​多线程进行并发访问资源时需要进行锁同步,否则会出现两个线程之间的计算交叠造成逻辑错误。在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的实现过程的理解。主要思想就是乐观地看待竞争,尽量通过轻量级的同步操作解决问题,逐步升级应对措施。

纯属个人理解,欢迎交流。

 

 

 

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值