synchronized 底层原理(嚼碎了喂版)
先说一下心得吧,我们知道硬软不分家,在学习底层原理的时候我们不需要死扣到底,没必要把硬件方面全吃透,点到为止,学到能够帮助理解代码即可,我们的目标是写出高性能的代码,而不是创造出硬软一体化高性能套件。不要一学底层就一股子牛劲死磕,至少我们现在不应该这样,莫要本末倒置。(好吧其实是我在学的时候有点转牛角尖了,一直问ai问题,仿佛是想把整个计算机领域吃透一般)希望这篇文章对大家有帮助,认真看完哦!欢迎指出理解有误的地方!!!
先看看 Java 中 new 一个对象会有哪些信息被创建出来
在 HotSpot 虚拟机中,一个对象在堆内存的存储布局可以划分为三部分(以 64 位操作系统为例,不用在意 32 位下的情况,他会被淘汰..) :
对象头:
实例数据:存类中声明的成员信息,如 int a = 2(占 4 字节)
对其填充:对象存储必须按 8 字节对齐(64 位系统的默认配置),如果 对象头+实例数据 所占的 bit 不是 8 的倍数,如为 65bit(随便举例),那么只这个部分会填充 7bit,变为 72bit(9 字节)。目的是:提高内存访问效率:CPU 读取内存时,对齐的数据能减少总线周期(如 64 位 CPU 一次读 8 字节),避免伪共享(False Sharing):对齐后,不同对象不会共享同一缓存行。(硬件知识)
再来看看 synchronized 到底是什么模样
人人都说 sy 重,重在哪里呢?
重量级锁的实现是基于 monitor 机制的,那就先谈谈 Monitor(管程):
他是操作系统层面的一个东西,提供了一种结构化的方式来管理共享数据和并发访问,其核心组成为 互斥锁与条件变量,是由操作系统的一些指令来控制的(这里不过多展开,因为我不会)
再说说 JVM 层面的具体实现:
JVM 中的 Monitor 是通过 C++实现的 ObjectMonitor 对象,当升级为重量级锁时底层会创建这个对象,并把它的地址放在对应 Java 对象的 mark word 中(再去看看上面的图),根据这个地址进行之后的一系列操作,字段有:
-
_owner
: 指向当前持有锁的线程。 -
_count
: 记录锁的重入次数。 -
_EntryList
: 等待获取锁的线程队列(阻塞队列)。 -
_WaitSet
: 调用wait()
方法后进入等待状态的线程队列。
再来说说为什么 sy 重:
在获取、释放等锁相关的操作时,本质上都是操作这个 ObjectMonitor 对象,线程的阻塞、唤醒、调度都是操作系统的职责,必须通过操作系统内核来完成(调用内核中的底层方法)这就牵扯到了从用户态到内核态的切换
用户态:
-
用户态的程序没有权限直接操作其他线程的状态(如从运行状态切换到阻塞状态)。
-
用户态程序也无法直接访问和修改操作系统的线程调度队列。
-
这些操作涉及到对底层系统资源的访问和管理,是操作系统的核心功能。
而这个切换是非常销毁后资源的,会执行很多指令来完成这项操作
可以粗略的认为:用户态是 CPU 执行应用程序代码(如 Java 字节码、Python 解释器代码),而内核态是 CPU 执行操作系统内核代码(如文件读写,内存分配等底层操作),但要注意的是 用户态和内核态的切换本质是 CPU 特权级别的变化,而不是仅仅是“谁在运行代码”,所以说可以“粗略认为”,帮助理解即可
-
系统调用是用户态程序进入内核态的唯一方式。
-
进入内核态需要保存当前用户线程的上下文(寄存器状态、程序计数器等,记录执行到了哪里方便下次回来接着执行),然后切换到内核的代码执行。
-
从内核态返回用户态时,需要恢复用户线程的上下文。
-
这个保存和恢复上下文的过程就是 上下文切换 (Context Switch),它是有开销的,通常比用户态的指令执行慢几个数量级。(注意这里讨论的是用户态和内核态的上下文切换,有人可能会想到线程的上下文切换,他们不是同一个概念,但相同点是都会造成额外的开销)
再来说说重量级锁的操作流程:
当一个线程尝试获取一个对象的 Monitor 时:
-
如果
_owner
为空,线程成功获取锁,设置_owner
为自身,_count
为 1。 -
如果
_owner
是当前线程,_count
加 1(重入)。 -
如果
_owner
是其他线程,当前线程进入_EntryList
阻塞等待。
当一个线程释放 Monitor 时:
-
_count
减 1。 -
如果
_count
变为 0,释放锁,_owner
置空。 -
然后从
_EntryList
或_WaitSet
中唤醒一个或多个线程,让它们有机会竞争锁。
这里插播一下线程的几个重要的状态(操作系统层面)
BLOCKED、WAITTING(TIME)都不会消耗 CPU 资源,在进入这个状态时会自动释放占用的资源
只有 RUNNING 才会消耗资源
而 RUNNABLE 知识代表这个线程可以开始干活了,但是还没有活干,等待 CPU 时间片分给他活,是不消耗资源的
而在 Java 层面
没有 RUNNING 状态
RUNNABLE 状态就包含了 RUNNABLE 与 RUNNING
所以说,sy 重的核心原因是:线程的阻塞、唤醒和调度是操作系统的职责,必须通过内核来完成。
既然我们知道了导致 sy 重的原因是线程阻塞引起的,解决的方法当然就是不让他阻塞咯,那么怎么让他不阻塞捏?
无锁化编程 CAS 应运而生,挑起了重担,他通过让线程自旋尝试获取锁的方法来规避去阻塞等待的方式。
有人可能会问:CAS 自旋不是会造成 CPU 空转吗?这不也在浪费资源吗?
是的,CAS 自旋消耗 CPU 资源,用户态与内核态之间的切换亦会浪费资源。但仍选择优化为 CAS 自旋的核心目的是 在“短时间锁竞争”和“长时间锁竞争”之间找到性能平衡(你想,如果在一个线程第一次尝试获取锁失败之后锁立马被释放了,然而他却去了阻塞队列....这得多造孽呀,如果再坚持一下的话....或许我和她的结果就会不一样了....😭,这样看适当自旋一下还是非常好的)
sy 采用的是先自旋,再阻塞的策略。(她一直不搭理我,我也不能一直舔吧....我也是有尊严的!!!)
“先自旋后阻塞”是一种 折中策略,通过 动态适应锁竞争情况,在 低延迟 和 高吞吐 之间取得平衡。
-
自旋:为短期锁竞争优化响应速度。
-
阻塞:为长期锁竞争优化系统资源利用率。
一句话,先自旋,不行再阻塞(翻译:先舔舔,不行咱就走呗,等着找下家~**)**
了解了整体思路,最后来看看 sy 中 偏向锁、轻量级锁以及重量级锁的具体实现:
按照我们上面的分析,产物应该就是轻量级锁(CAS)咯,我猜官方的想法是既然要走不阻塞这条路那就干脆极端一点来个无锁判断(偏向)得了,所以就又加了偏向锁,干脆 CAS 操作都不做了,很彻底!
偏向锁:
单线程竞争,当线程 A 第一次竞争到锁时,通过修改 MarkWord 中的偏向线程 ID、偏向模式。如果不存在其他线程竞争,那么持有偏向锁的线程将永远不需要进行同步(JVM 不会执行任何额外的同步操作(如 CAS、系统调用、内核态切换等),而是直接允许线程访问临界区。) .
什么时候升级为轻量级锁呢?
-
调用了对象的 hashCode,但偏向锁的对象 MarkWord 中存储的是线程 id,如果调用 hashCode 会导致偏向锁被撤销,(轻量级锁会在锁记录中记录 hashCode,重量级锁会在 Monitor 中记录 hashCode)
-
当有另外一个线程逐步来竞争锁的时候,就不能再使用偏向锁了,要升级为轻量级锁,使用的是等到竞争出现才释放锁的机制
-
竞争线程尝试 CAS 更新对象头失败,会等到全局安全点(此时偏向锁对应的 ThreadID 线程不会执行任何代码)撤销偏向锁,同时检查持有偏向锁的线程是否还在执行:
-
第一个线程正在执行 Synchronized 方法(处于同步块),它还没有执行完,其他线程来抢夺,该偏向锁会被取消掉并出现锁升级,此时轻量级锁由原来持有偏向锁的线程持有,继续执行同步代码块,而正在竞争的线程会自动进入自旋等待获得该轻量级锁
-
第一个线程执行完 Synchronized(退出同步块),则将对象头设置为无锁状态并撤销偏向锁,重新偏向。
-
题外话:Java15 以后逐步废弃偏向锁,需要手动开启------->维护成本高
轻量级锁
JVM 会为每个线程在当前线程的栈帧中创建用于存储锁记录(Lock Record)的空间,官方称为 DisplacedMarkWord。若一个线程获得锁时发现是轻量级锁,会把锁的 MarkWord 复制到自己的 DisplacedMarkWord 里面。然后线程尝试用 CAS 将锁的 MarkWord 替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示 MarkWord 已经被替换成了其他线程的锁记录,说明在与其它线程竞争锁,当前线程就尝试使用自旋来获取锁。
如果是自己执行了 synchronized 锁重入,那么再添加一条 Lock Record 作为重入的计数
自旋 CAS:不断尝试去获取锁,能不升级就不往上捅,尽量不要阻塞(升级为重量级锁)
轻量级锁的释放
在释放锁时,当前线程会使用 CAS 操作将 Displaced MarkWord 的内容复制回锁的 MarkWord 里面。如果没有发生竞争,那么这个复制的操作会成功。如果有其他线程因为自旋多次导致轻量级锁升级成了重量级锁,那么 CAS 操作会失败,此时会进入重量级锁解锁流程
自旋一定程度和次数(Java8 之后是自适应自旋锁------意味着自旋的次数不是固定不变的):
-
线程如果自旋成功了,那下次自旋的最大次数会增加,因为 JVM 认为既然上次成功了,那么这一次也大概率会成功
-
如果很少会自选成功,那么下次会减少自旋的次数甚至不自旋,避免 CPU 空转
轻量锁和偏向锁的区别:
-
争夺轻量锁失败时,自旋尝试抢占锁
-
轻量级锁每次退出同步块都需要释放锁,而偏向锁是在竞争发生时才释放锁
重量级锁
当线程尝试获取轻量级锁失败后,进入锁膨胀,创建 ObjectMonitor 对象并将锁中的 mark word 字段移入到该 Monitor 对象中,将该对象地址放入 mark word 中,接下来的流程在上文已经讲过了
锁释放时通过 Monitor 地址找到对象,将 owner 设置为 null,唤醒 EntyList 中 BLOCKED 线程去抢锁
补充一下 wait/notify 原理:
·Owner 线程发现条件不满足,调用 wait 方法,即可进入 WaitSet 变为 WAITING 状态
·BLOCKED 和 WAITING 的线程都处于阻塞状态,不占用 CPU 时间片
·BLOCKED 线程会在 Owner 线程释放锁时唤醒
·WAITING 线程会在 Owner 线程调用 notify 或 notifyAll 时唤醒,但唤醒后并不意味着立刻获得锁,仍需进入
EntryList 重新竞争
完结撒花🎉
#synchronized##java#