前言
从实战出发,只讲干货,这里是战斧的专栏。这一期我们还是一篇硬核内容,详细剖析Synchronized的原理。鉴于这样的硬核程度,我真诚建议读者需要具备一些synchronized的基础知识,当然,作为比较,也会稍微讲讲juc里的Lock,但Lock会有一篇专门的内容来讲述,这里只会简单提一下。
synchronized 与 Lock的异同
关于Synchronized与lock的问题,有相当多人都讲过,我也不卖关子,直接讲他们最本质的异同:模型相同、层级不同
模型相同
这里说的模型特指synchronized 膨胀到重量级锁之后的模型。这时,他和lock锁的模型是一致的,都是基于对象,都包含同步区域、阻塞队列、等待队列。当然,也都具备阻塞和唤醒线程的能力。
层级不同
synchronized 是jdk提供的关键字,其功能实现是c++完成的。而lock则是juc包内的,其功能是java语言实现的。应当注意的是,两者的几乎所有不同,都是因为层级不同导致的,例如以下方面
- 使用方法不同
- synchronized作为关键字直接修饰方法,或者在方法体中使用;而lock则需要手动申明出对象,然后在方法中使用
- synchronized编译后自带异常处理,在线程出现异常时,会自动释放锁;而lock则需要在代码中手动捕获和释放异常
- synchronized无法响应中断;而lock里常用的await方法则可以响应中断
- 扩展能力不同
- synchronized的用法固定和功能固定,只对外暴露一些jvm参数(如-XX:BiasedLockingBulkRebiasThreshold ),我们通常没法去改造他。而Lock则是java的一个接口,juc也提供了大量的类,我们完全可以根据需要使用和继承,来编写特定功能的锁,最终完成我们的功能,这一点相当重要,Lock锁之所以枝繁叶茂,各种功能齐全,难以一言蔽之,皆是因为它强大的扩展能力
- 功能范围不同
- 由于Lock强大的扩展性(主要是AQS的扩展性),导致synchronized和Lock在功能范围上根本不在一个级别
- synchronized是公平的-可重入的-独占锁。而Lock可以设定是否公平,是独占还是共享还是读写,甚至是否重入亦可设定。以至于在做功能对比时,通常synchronized 只能和 Lock下的ReentrantLock 做对比,而即使是ReentrantLock ,也比synchronized 功能更强,比如ReentrantLock可以允许多个条件队列,而synchronized只有一条。
了解synchronized
锁的种类
synchronized并不是单纯的一把锁,它其实分为两种:栈上锁阶段 及重量级锁阶段 。
栈上锁阶段:主要是在栈上做CAS,利用对象头和LockRecord(锁记录)来判断竞争锁的情况。该阶段又可以分为多个状态,如两种起始态:“无锁状态” 和 “匿名偏向状态”,进阶状态:“”偏向状态“ 和 “轻量锁状态”。
重量级锁阶段:基于objectMonitor对象的对象锁,只有该阶段才会出现线程的阻塞,才会出现阻塞与等待队列。我们在Object类里看到的wait() notify() 方法,其实是objectMonitor对象的方法。所以当你使用了wait() 或 notify() 后,必定会创建并进入重量级锁,这也是为什么这两个方法必须要放在synchronized代码块里的原因
锁的起始状态
上文已经说到,synchronized是有两个起始状态的:“无锁状态” 和 “匿名偏向状态”,我们先来看一张图:
这是一张64位虚拟机的对象头说明,我们可以看到有四种锁的状态, 有多少人是认为无锁状态升级后变成偏向锁状态的?其实无锁状态并不会变成偏向锁,无锁状态升级后直接就变成轻量级锁了。
在这里我们必须说明,偏向锁是jdk9引入的,并下放到jdk8u,换句话说偏向锁并不是一开始就有的。引入偏向锁的同时,一并提供了一个jvm参数-XX:+UseBiasedLocking,这个参数的意思就是开启偏向锁,当然你也可以选择关闭,只是jdk8u默认是开启的。是否开启偏向锁,将导致我们在创建对象时,对象头走向两种路线:
- 不开启偏向锁 : 直接创建无锁状态的对象头
- 开启偏向锁 : 直接创建匿名偏向状态的对象头,所谓匿名偏向,就是其他bit位和偏向锁一样,但threadID位置为空
锁的简单变化
看完了起始状态,我们再来看后续变化,简单的讲,其实就是就两条线 ,对应着两个起始状态。
- 无锁 > 轻量级锁 > 重量级锁
- 匿名偏向 > 偏向锁 > 轻量级锁 > 重量级锁
可以看到,只有初始状态为匿名偏向的,才会进入到偏向锁。如果一开始创建的对象头是无锁状态,遇到竞争后会直接进入轻量级锁。当然你或许还有疑问,比如当前是偏向锁,这时候我算一个hashcode,那hashcode又该存到哪里去呢?再比如偏向锁是怎么升级到轻量锁的?别急,我用一张图告诉你
了解各个锁的特点
无锁与匿名偏向
我们都知道锁的状态基于对象头。因此对象在被创建的时候,对象头肯定会有一个初始默认状态,注意此时尚未有任何线程来获取锁。
而这个初始状态包含 无锁状态、匿名偏向状态 两种可能。在jdk8u引入偏向锁后,默认为我们开启了“使用偏向锁”,因此在jdk8u以后,对象的创建初始都是匿名偏向状态。当然jvm也支持设置一个延迟时间,在JVM启动初期的一定时间内,为避免偏向撤销的损耗,直接以无锁状态作为初始状态。
偏向锁
首先,我们需要知道,偏向锁并不是一开始就有的。而是jdk9中针对synchronized的优化,并同步到了jdk8u中。之所以叫偏向锁,顾名思义是指它偏心,始终偏向第一个获得锁的线程,并把这个线程存储在对象头里,之后只要是该线程来获取锁,都能快速批准。偏向锁利用CAS来保证判断和竞争操作。因此这个阶段的效率是很高的
偏向锁需要讲的其实就两点:
1. 偏向锁适用于锁只有同一个线程来获取的场景
2. 偏向锁本质是一个标记,正因如此,即使获得偏向锁的线程退出了同步代码块,该标记也不会消失。当该线程下一次进入同步代码块时,仅判断一下对象头里的标记是不是本线程即可,也就是说仅一次CAS判断即可执行同步代码块,消耗极低.
轻量级锁
轻量级锁的存在其实很尴尬,在偏向锁没出现之前,它承担着重要功能,即以CAS的形式进行锁的判断和竞争,从而避免synchronized消耗太大。然而,在有了偏向锁后,它原本的功能很大程度上被偏向锁抢走了。
它的现状是,只有在多个线程轮流去执行同步代码块时才用的上。如果只有一个线程反复执行,偏向锁就够了。如果有多个线程同时竞争,则又只能靠重量级锁来解决。
轻量级锁的情况就是:线程开辟一块LockRecord空间,然后把锁对象头的MarkWord复制一份到自己的LockRecord空间下,并且开辟一块owner空间留作执行锁使用。然后对锁对象头做CAS去存入本线程LockRecord的位置,比如线程A成功了,那就意味着A线程获得锁,线程A的owner也会存一份锁对象的内存地址,形成一个互相引用的形式。
轻量级锁需要澄清两点
1. 关于轻量级锁CAS失败会进入自旋状态的问题,网络上众说纷纭,我没有找到对应源码,所以认定不存在该步骤
2. 很多文章都会在提到轻量级锁时,线程开辟一块LockRecord空间。但其实只要有线程试图获取某个锁(进入某同步代码块),都会在栈帧中找到一个空闲的LockRecord空间,栈帧在创建时就包含一个监视器对象数组,长度会在编译期确定,要获取锁时,除非该锁已经在数组中了,否则会在该数组中找到一个空闲的空间,以它作为存储锁记录的空间,这是获取锁要执行的第一个方法,和锁的状态和竞争情况无关。
重量级锁
实际上如果真的有两个线程同时竞争锁,靠前面的锁是无法解决的,因为synchronized并不是共享锁,它一次只允许一个线程获得锁(进入同步代码块),而另一个线程则会被阻塞。
比如上图中,一个线程B在CAS轻量级锁失败时,意味着轻量级锁发生了竞争,此时竞争失败的线程会向JVM申请一个互斥量,并且将锁对象头的前30bit通过CAS指向申请到的互斥量地址(这次CAS和上次CAS不同,具体可看后文),然后自己进入睡眠状态。而当锁的持有线程A继续运行直到完成时,线程A想要释放锁资源的时候,发现原来的30bit位没有指向自己了,此时线程A就会释放锁,取唤醒前30bit位中互斥量对应的睡眠状态的线程B,线程B唤醒后尝试获取对象锁
重量级锁需要注意几点:
1.重量级锁依赖于MonitorObject对象,每个java对象都有一个MonitorObject,所以每个java对象都可以作为同步锁
2. 重量级锁才会涉及线程内核态转换,做出阻塞和唤醒线程的动作,wait 和 notify方法其实是MonitorObject的方法
3. 重量级锁有两个队列,阻塞队列——执行到synchronized方法,但没获得锁,只能在方法外面排队,只有被唤醒并获得锁,才能进入方法体内部;等待队列——已经执行到synchronized方法内部,主动执行等待操作,暂时放弃锁,一旦被唤醒则并重新获得锁,则可以接着原来的代码执行
4. 等待队列的线程被唤醒后也会被扔到阻塞队列,和原本就在外面的阻塞线程同台竞争
锁升级流程图(c++源码级,全网唯一)
不多说废话,关于锁的升级,我直接祭出一张本人针对jdk8u c++源码,一个方法一个方法研究过后,写出的最细最全流程图。当然,我不敢说完全没有错漏,但这深刻度绝对少见。也因为是基于源码,所以我认为基本可以作为权威流程,对照其他文章加深理解。我图里也写了些总结,主要是纠正目前市面上一些文章的错误之处。