浅析synchronized底层实现与锁相关


/   今日科技快讯   /

刚宣布转型更名的社区团购平台“蜜橙生活”(原名“同程生活”),于7月7日晚间再度发布公告称,“几年来因经营不善,虽经多方努力,但仍然无法摆脱经营困境。公司决定申请破产,现拟提出破产申请。”

/   作者简介   /

明天就是周六啦,提前祝大家周末愉快!

本篇文章来自Petterp的投稿,浅析synchronized底层实现,相信会对大家有所帮助!同时也感谢作者贡献的精彩文章!

Petterp的博客地址:

https://juejin.cn/user/3491704662136541/posts

/   引言   /

一切的最开始都是源自一系列为什么?

  • 为什么加了锁 synchronized 关键字,就可以实现同步?

  • synchronized 底层到底做了什么优化?

  • Java 中的各种锁及锁膨胀?

  • 用户态、内核态与上下文切换到底是什么鬼?

  • 什么叫自旋锁,它与 CAS 的关系?

  • 对象头是什么玩意,什么又是 MarkWord ?

/   概述   /

synchronizrd 是开发中解决同步问题中最常见,也是最简单的一种方法。

从最开始学习并发编程,我们都知道,只要加上这个 synchronizrd 关键字,就可以很大程度上轻松解决同步问题。

相应的,从原理上来讲,其也是比较重的一种操作,特别是 jdk1.5 时候,相比 JUC 中的 Lock 锁,一定程度上逊色不少。

但随着jdk1.6对 synchronized 的优化后,synchronizrd  并不会显得那么重,相比使用  Lock  而言,其的性能大多数情况下也可以接近 Lock 。

本文的主旨就是对 synchronized 的原理进行探秘,从而完成对各种锁的了解与学习。

synchronizrd 常用的作用有三个:

  • 原子性

即一个操作或者多个操作,要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行;

  • 可见性

可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值;

  • 有序性

防止编译器和处理对指令进行重排序,即也就是抑制指令重排序;

/   解析   /

synchronzied 在 jvm 里的实现都是基于进入和退出 Monitor 对象来实现方法同步和代码块同步,虽然具体实现细节不一样,但是都可以通过 成对 的 MonitorEnter 和 MonitorExit 指令来实现。具体如下图对比:

示例代码:

字节码对比:

同步方法 ,从上图字节码来看,方法的同步并没有通过指令 monitorenter 和 monitorexit 来实现,而是直接在方法中增加了 synchronzied 修饰。更底层实现上而言,其常量池中多了 ACC_SYNCHRONIZED  标识符,JVM 就是根据该标识符来实现方法的同步:当方法被调用时,调用指令将会检查方法的 ACC_SYNCHRONZED 访问标志是否被设置,如果设置了,执行线程将先获取 monitor ,获取成功之后才能执行方法体,方法执行完后再释放 monitor 。在方法执行期间,其他任何线程都无法再获得同一个 monitor 对象。

对于 同步代码块 而言,synchronzied 的底层实现中,MonitorEnter 指令插入在同步代码块的开始位置,当代码执行到该指令时,将会尝试获取该对象  Monitor  的所有权,即尝试获得该对象的锁,而 monitorExit 指令则插入在方法结束处和异常处,JVM 保证每个 MonitorEnter 必须有对应的 MonitorExit 。

在上面,说到了 synchronized 的在字节码上的实现,那对于虚拟机而言,synchronzied 锁的标志到底放哪了呢?说到这个问题,我们不得不提 对象头 这个概念。

/   对象头   /

什么是对象头,对象头干了什么?

如果看过垃圾回收机制,那么可能知道这个玩意。对象头就相当于一个名片,它包含了对象的一个基本信息,如下图所示:

注意:对象头中不一定包含数组长度,如果这个对象不是数组 ✋

整个对象头由两个部分组成,分别是 KlassPoint 与 Mark Word 。

KlassPoint

当我们 new 出一个类时,虚拟机如何得知它是哪个类呢, 这时候就是通过上述 KlassPoint ,其指向了类元数据 (mteaData) 的信息。

在计算机中,有各种 [元] 数据。比如文件有元数据,网页有元标签。元 这个说法来自希腊语,表示关于。所以 文件中的元数据,即为关于文件的数据,类的元数据即为类信息的一个原始标签。也即为描述这个类的信息

Mark Word

用于存储不同状态信息,是会随着时间点而改变。一般而言默认数据是存储对象的 HashCode 等信息,而我们本篇的主题 synchronzied 正是在其里存储。如下图所示

Markword的信息会随着时间不断改变,比如发生gc时,内部gc 标记为null。

而我们本篇的主题 synchronized 的 锁状态 也存在与 MarkWord 中,在对象运行变化的过程中,锁的状态存在4种变化状态,即 无锁状态 、偏向锁状态 、轻量级锁状态 、重量级锁状态 。它会随着竞争情况逐渐升级,锁可以升级但不能降级,主要目的是为了提高获得锁和释放锁的效率。

那为什么要存在几种????呢?或者还没看明白缘由?请接着下面继续看?????

/   上下文切换   /

在jdk1.6之后,synchronizrd 得到了优化,而添加各种锁的目的都是为了避免直接加锁而导致的上下文切换从而引发的耗时浪费。

如果不了解上下文切换,这样说可能听着有点懵,我们先从基础讲一下:

内容摘录自:Java性能之线程上下文切换究极解析(https://zhuanlan.zhihu.com/p/82848203)

所以,当我们某个资源使用 synchronizrd 进行加锁时:

  1. 当线程A获取了锁,线程B在获取时将会被阻塞,也即是 BLOCKED 状态,此时线程B暂停被操作系统 切出 ,操作系统会保存此时的上下文;

  2. 当线程A释放了锁,此时假设线程B获取到了锁,线程B 从 BLOCKED  进入  RUNNABLE 状态,即线程重新唤醒,此时线程将获取上次操作系统保存的上下文继续执行。

上述的过程中线程B执行了 两次 上下文切换,每一次上下文切换的过程为 3~5微秒 ,而cpu执行一条指令只需要 0.6ns ,所以如果加锁后只是执行几条普通指令,如某个变量的自增或者其他,那么上下文切换将对性能产生极大影响,所以在jdk1.6以后,synchronizrd 得到了优化,新增了几种锁,以及不同情况下的状态变化,以避免直接重量级锁产生的性能损耗。

/   常见的锁   /

自旋锁

如果持有锁的线程能在很短时间内释放锁资源,那么那些等待竞争锁的线程就不需要做内核态和用户态之间的切换进入阻塞挂起状态,它们只需要等一等(自旋),等持有锁的线程释放锁后即可立即获取锁,这样就避免用户线程和内核的切换的消耗。

但是线程自旋是需要消耗 CPU 的,说白了就是让 CPU 在做无用功,线程不能一直占用 CPU 自旋做无用功,所以需要设定一个自旋等待的最大时间。

如果持有锁的线程时间超过自旋等待的最大时间扔没有释放锁,就会导致其他争用锁的线程在最大等待时间内还是获取不到锁,这时争用线程会停止自旋进入阻塞状态。

  • 优缺点

自旋锁尽可能的减少线程的阻塞,这对于锁的竞争不激烈,且占用锁时间非常短的代码块来说性能能大幅度的提升,因为自旋的消耗会小于线程阻塞挂起操作的消耗。

但是如果锁的竞争激烈,或者持有锁的线程需要长时间占用锁执行代码块,这时候就不适合使用自旋锁,因为自旋锁在获取锁前一直都是占用cpu不断做尝试,线程自旋产生的消耗大于线程阻塞挂起操作的消耗。导致其他需要cpu的线程无法获取到cpu,从而造成了cpu的浪费。

  • 自旋锁时间阈值

自旋锁的目的是为了占着 CPU 的资源不释放,等到获取到锁立即进行处理。

如何选择自旋的执行时间呢?如果自旋执行时间太长,会有大量的线程处于自旋状态占用 CPU 资源,进而会影响整体系统的性能,因此自旋次数非常重要。

自适应自旋锁

JVM 对于自旋次数的选择, jdk 1.5 默认为 10次 ,在1.6引入了适应性自旋锁,适应性自旋锁意味着自旋的时间不再是固定的了,而是由前一次在同一个锁上的自旋时间以及锁的拥有者的状态来决定,基本上认为是一个线程上下文切换的时间是最佳的时间。

比如线程A 获取了一把锁后,当它释放这把锁之后,线程B成功获得了这把锁后,此时,线程A再次申请获取锁,由于此时线程B还没有释放锁,所以线程A只能自旋等待,但是虚拟机认为:由于线程A刚刚获得过这把锁,那么虚拟机会认为线程A这次自旋也是有可能会再次成功获得该把锁,所以会延长线程A的自旋次数。

对于一个锁,一个线程自旋之后,获取锁成功概率不大,那么以后这个线程要获取该锁时,是有可能直接忽略掉自旋过程,直接升级为重量级锁,以免空循环等待浪费资源。

偏向锁

  • 背景

实际开发中,大多数情况下不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入偏向锁,从而减少不必要的  CAS  操作。

  • 概括

偏向锁,顾名思义,它会偏向第一个访问锁的线程,如果在运行过程中,同步锁只有一个线程访问,不存在多线程争用的情况,则线程是不需要触发同步的,这种情况下,就会给线程加一个偏向锁,从而减少加锁/解锁 的一些 CAS  操作(比如等待队列中的 CAS 操作(CLH队列锁) ) 。

如果在运行过程中,遇到了其他线程抢占锁,则持有偏向锁的线程会挂起,JVM  会消除它身上的偏向锁,并将锁恢复到标准的轻量级锁。它通过消除资源无竞争情况下的同步原语,进一步提高了程序的运行性能。

  • 偏向锁升级过程

1. 访问 Mark Word 中偏向锁的标识是否被设置成  1  ,锁标志位 是否为 01 ,确认其为可偏向状态;

2. 如果是可偏向状态,则测试 线程ID 是否指向当前线程,如果是,进入步骤5,否则进入步骤3;

3. 如果 线程ID 并未指向当前线程,则通过 CAS  操作竞争锁。如果竞争成功,则将 Mark Word 中 线程ID 设置为当前线程ID ,然后执行 步骤5 ; 如果竞争失败,则执行步骤 4;

4. 如果 CAS 获取偏向锁失败,则表示有竞争。当到达全局安全点(safepoint)时,获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码。

5. 执行同步代码。

  • 偏向锁的释放

偏向锁的撤销在步骤4中已经提过。偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放偏向锁,线程不会主动去释放偏向锁。

偏向锁的撤销,需要等待 全局安全点(即在这个时间点上没有字节码正在执行),它会首先暂停拥有锁的线程,判断锁对象是否处于被锁定状态,撤销偏向锁后恢复到未锁定(标志位为 "01" ) 或轻量级锁(标志位为 "00" )的状态。

  • 适用场景

始终只有一个线程在执行代码块,在它没有执行完释放锁之前,没有其他线程去执行同步块,在锁无竞争的情况下使用,一旦有了竞争就升级为轻量级锁,升级为轻量级锁的时候需要撤销偏向锁,撤销偏量锁的时候会导致 stop the word(stw) 操作。

在有锁的竞争时,偏向锁会做很多额外操作,尤其是撤销偏向锁的时候会导致进入安全点,而安全点会导致 stw,导致性能下降。

  • Stop the word是什么?

指在执行垃圾收集算法时,Java应用程序的其他所有线程都被挂起(除了垃圾收集帮助器之外),是Java中一种全局暂停现象,类似于应用程序发生了停顿,没有任何响应。

轻量级锁

轻量级锁是由偏向锁升级来的,偏向锁运行在一个线程进入同步块的情况下,当第二个线程加入锁争用的时候,偏向锁就会升级为轻量级锁;轻量级锁的目的是减少无实际竞争情况下,使用重量级锁的性能消耗。比如系统调用引起的内核态与用户态切换,线程阻塞造成的线程切换等。

  • 轻量级锁的加锁过程

在代码进入同步块的时候,如果同步对象锁状态无锁状态且不允许进行偏向(锁标志位为"01"状态,是否为偏量锁为"0"),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录 (Lock Record) 的空间,用于存储锁对象目前的 Mark Word 的拷贝,官方称之为 Displaced Mark Word

拷贝成功后,虚拟机将使用 CAS 操作尝试将对象的 Mark Word 更新为指向 Lock Record 的指针,并将 Lock record 里的 owner 指针指向 object mark word 。如果更新成功,则执行上述偏向锁中的步骤4,否则执行步骤5。

如果这个更新操作成功,那么这个线程就拥有了该对象的锁,并且对象 MarkWord 的锁标志设置为 "00" ,即表示此对象处于轻量级锁锁定状态。

如果这个更新操作失败了,虚拟机首先会检查对象 Mark Word 是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行。否则说明多个线程竞争锁,当竞争线程尝试占用轻量级失败多次之后,轻量级锁就会膨胀为重量级锁,重量级线程指针指向竞争线程,竞争线程也会阻塞,等待轻量级线程释放锁后唤醒它。同时锁标志的状态值变为 "10" ,如图下 Mark Word 中存储的就是指向重量级锁(互斥量) 的指针,后面等待锁的线程也要进入阻塞状态。

重量级锁

轻量级锁不断自旋膨胀之后,就会升级为重量级锁。重量级锁时依赖对象内部的 monitor 锁来实现,而 moitor 又依赖系统的 MutexLock(互斥锁)  来实现,所以重量级锁也被称为 互斥锁 。

  • 为什么说重量级锁开销比较大?

当系统检查到锁时重量级锁时,会把正在等待获取锁的线程进行阻塞,被阻塞的线程不会消耗 cpu ,但是阻塞和唤醒线程,都需要操作系统来处理,这就需要从用户态转换到内核态,而从用户态到内核态的切换吗,需要通过系统调用来完成。

系统调用的过程中会发生 cpu 上下文切换,一次系统调用的过程,需要发生 两次 上下文切换。而这个过程很多时候比同步代码块所需时间还长。

不同锁之间的比较

/   总结   /

到了这里,我们知道了为什么 synchronized 关键字的底层实现以及锁的状态变化过程。说实话,这些对于一个Android 开发而言,可能很难有应用场景,但是于我个人而言,终于解释了曾经哪些隐晦的 为什么,以及一些边界概念。知道的越多,不知道的越多。

感谢

深度分析:锁升级过程和锁状态,看完这篇你就懂了!

https://segmentfault.com/a/1190000022904663

Java性能之线程上下文切换究极解析

https://zhuanlan.zhihu.com/p/82848203

享学课堂-synchronized-Mark

推荐阅读:

如何判断Android应用运行在鸿蒙系统上

从LiveData迁移到Kotlin Flow

我的故事登上了Android开发者的官网

欢迎关注我的公众号

学习技术或投稿

长按上图,识别图中二维码即可关注

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值