# 01-synchronized原理 ## 面试杀手锏:全面剖析 synchronized 锁升级流程 在准备面试时,一定要准备杀手锏,也就是你了解最深的几个内容,这样才能给面试官很深的印象 而 Java 并发相关的内容拿来做杀手锏非常合适,因为并发是基础性的内容,大家都学过,但是大家学的深度又参差不齐,因此通过并发相关内容就可以看出来面试者的水平高低 **因此并发相关的内容一定要深入原理,好好学习一下!** JDK1.6 之前,synchronized 使用重量级锁,性能开销很高 JDK1.6 引入了锁的优化:`偏向锁和轻量级锁` 同步锁共有 4 个状态:`无锁、偏向锁、轻量级锁、重量级锁`,这 4 个状态会随着竞争激烈而逐渐升级 synchronized 的锁的状态是记录在对象的 Mark Word 中的,因此这里需要先介绍一些 Mark Word ### 对象头 Mark Word 在 64 位 JVM 中,Mark Word 的长度为 64 位 - **Mark Word 到底是 Java 中对象的哪一部分呢?** 在 Java 中,每个对象在堆空间中存储是分为了 3 个部分:对象头、实例数据、对齐填充,对象头中就是存储一些对象的标识信息 而 Mark Word 就是在对象头中进行存储 Mark Word 的作用就是表示对象的线程锁的状态,并且存放对象的 hashCode image-20240222194643641 我们上边说了 Mark Word 是 64 位的,接下来看一下 Mark Word 如何分配这 64 位的空间 如下图,Mark Word 在以下共 5 种状态下的组成是不一样的,这里来介绍一下: ![image-20240222194117253](https://11laile-note-img.oss-cn-beijing.aliyuncs.com/image-20240222194117253.png) - **无锁时的 Mark Word** 在对象没有锁的时候,我们可以看到 Mark Word 中,前 25bit 没有用到 接下来的 29bit 存储了对象的 HashCode,以及使用 4bit 存储了对象的分代年龄 1bit 存储非偏向锁状态为 0 2bit 存储锁标志位 01,这是固定的 - **有偏向锁的 Mark Word** 当对象 `被偏向锁锁住` 的时候,会使用前 54bit 指向了持有该对象锁的线程,Epoch 代表了偏向锁的版本戳 偏向锁的标志被设置为 1,锁标志位仍然为 01 - **有轻量级锁的 Mark Word** 当对象 `被轻量级锁锁住` 的时候,Mark Word 中的组成又发生了变化 前 62bit 指向了线程栈帧中的 LockRecord 当一个线程持有当前对象锁,并且是轻量级锁的时候,会在线程栈中该线程的栈帧里创建一个 LockRecord 对象(下边在升级轻量级锁的时候会细讲) 你可以先简单理解为这个 LockRecord 就是线程持有该轻量级锁的一个标志 最后 2bit 的锁标志改为 00,表示轻量级锁 - **有重量级锁的 Mark Word** 当对象被 `重量级锁锁住` 的时候,前 62bit 会指向重量级锁,下边升级重量级锁会介绍 最后 2bit 的锁标志改为 10,表示重量级锁 - **有 GC 标志的 Mark Word** 这里了解一下就好了,被 GC 标记过的 Mark Word 就会将锁标志位改为 11 有了 Mark Word 的基础,接下来就可以学习 synchronized 的锁升级过程了! ### 偏向锁 当一个线程第一次竞争到锁,则拿到的就是偏向锁,此时不存在其他线程的竞争,因此偏向锁的性能是很高的,他会偏向第一个访问锁的线程 偏向锁的获取就是通过 CAS 操作将锁对象的 MarkWord 中的 ThreadId 修改为当前线程 ID 当持有偏向锁的线程再来访问的话,可以直接访问,不需要触发同步,连 CAS 操作都不需要 **为什么要引入偏向锁?** 偏向锁是 HotSpot 虚拟机的一项优化技术,可以提升单线程对同步代码块的访问性能 受益于偏向锁的应用程序往往是使用了早期 Java 集合的程序(JDK1.1),即 HashTable、Vector,在每次访问的时候都是 `线程同步操作` 而之后,出现了新的高性能并发数据结构 ConcurrentHashMap,使用偏向锁带来的性能提升就不明显了 Java 团队更推荐使用 `轻量级锁` 或者 `重量级锁` : - 如果竞争不激烈的话,并且每个线程对锁持有时间较短的情况下,可以使用轻量级锁,也就是 CAS 自旋等待获取锁 - 如果竞争激烈的情况下,或者每个线程持有锁的时间很长,如果还是用 CAS 自旋会导致大量线程在空转,大量占用 CPU 资源,因此要使用重量级锁 **为什么在 JDK15 之后就将偏向锁废弃掉了呢?(偏向锁撤销性能差)** 偏向锁在 JDK 15 之后就被废弃掉了,上边已经说了,偏向锁在之后的 JDK 中带来性能提升就不明显了,主要原因其实还是 `偏向锁的撤销` 性能较差 偏向锁只会出现在只有一个线程访问同步代码块时,只要此时有其他线程来访问,偏向锁就会撤销,进而升级为轻量级锁 那么在 `高并发` 场景下,基本上不会出现只有一个线程访问同步代码块的情况 因此会出现 `偏向锁撤销` 的情况,而偏向锁撤销需要等待进入全局安全点(safepoint)时,才会撤销,在 safepoint 时,所有的线程都暂停工作,因此偏向锁撤销的性能很差 ### 轻量级锁 如果有线程竞争的话,偏向锁升级为轻量级锁 轻量级锁是通过 `CAS 去自旋` 获取锁,适用于并发竞争不激烈,并且持锁时间较短的情况 当一个线程获取锁之后,其他线程只能自旋等待,不会阻塞,自选等待也就是空转占用 CPU,因此如果自旋时间很长,可以想象到对 CPU 很不友好 **接下来说一下线程竞争轻量级锁的过程** 当进入到 synchronized 代码块中,虚拟机会现在当前线程的栈帧中创建一个 `Lock Record` 的空间,用于存储当前锁对象的 Mark Word 拷贝,官方称这个拷贝的 Mark Word 为 Displaced Mark Word 如果当前线程 `开始抢占` 该锁,那么会先将锁对象的 Mark Word 复制到当前线程栈帧的 Lock Record 中去, **之后再通过 CAS 操作尝试将锁对象的 Lock Record 指针指向当前线程栈帧中的 Lock Record** ,并且将栈帧中的 owner 指针指向对锁对象的 Mark Word 如果通过 CAS 操作更新成功了,就说明该线程抢占到了该锁,将锁对象 Mark Word 的锁标志设置为 00,表示是轻量级锁 如果通过 CAS 操作更新失败,会检查 Mark Word 的 Lock Record 指针是否指向当前线程的栈帧,如果是的话,表明当前线程抢到了锁,可以直接进入,否则会自旋等待 ![image-20240222204929963](https://11laile-note-img.oss-cn-beijing.aliyuncs.com/image-20240222204929963.png) **这里说一下为什么需要设计 Lock Record 这个对象再去存储锁对象的 Mark Word** 其实是 `为了存储锁对象的 HashCode` ,对象在无锁状态下 HashCode 会存储在 Mark Word 中 ![image-20240222205423283](https://11laile-note-img.oss-cn-beijing.aliyuncs.com/image-20240222205423283.png) 但是当锁状态升级为偏向锁之后,原本存储 HashCode 的位置需要存储 `持有锁的线程信息` ,因此这也是偏向锁不能和 HashCode 同时存在的原因 ![image-20240222205821147](https://11laile-note-img.oss-cn-beijing.aliyuncs.com/image-20240222205821147.png) 因此,为了保存对象的 HashCode 信息,在对象上的锁撤销之后,恢复到 `无锁状态时` ,可以再将对象的 HashCode 信息给写到 Mark Word 上,设计出来了 Lock Record 在给对象加轻量级锁的时候,先将对象的 Mark Word 复制到线程的 Lock Record 中,此时 Lock Record 中保存了对象的 HashCode 在对象上的锁被释放之后,再将 Lock Record 中的 HashCode 赋值给对象的 Mark Word 即可 ![image-20240222210115563](https://11laile-note-img.oss-cn-beijing.aliyuncs.com/image-20240222210115563.png) - **这里说一下自适应自旋:** 自旋锁在 JDK 1.4 中就已经引入了,在之前的版本中,自旋次数默认是 10 次(一般认为 10 次自旋大概等于线程挂起的开销) 在 JDK1.6 之后对自旋锁进一步优化,进入了 `自适应自旋` **自适应自旋:** 自旋的次数不再是一个固定的值了,而是由前一次在该锁上的 `自旋时间` 及 `锁的拥有者的状态` 来决定的 - 如果在当前锁对象上,上次就自旋成功获取锁了,那么当前线程来获取锁时,就会认为自选成功的概率比较大,因此允许自旋相对更长的时间来获取锁 - 如果在当前锁对象上,很少通过自旋获取锁,那么之后在获取锁的时候,可能就直接跳过自旋了 ### 重量级锁 当 CAS 自旋达到一定次数,就会升级为重量级锁,避免长时间的 CAS 资源耗费 CPU 性能 在重量级锁中,当线程发现锁已经被占用了,就会将自己挂起,而不是一直占用 CPU 进行空转 而重量级锁的 `性能不高的原因` 就是因为要不断挂起、唤醒线程,进行线程状态的变更, Hostspot 虚拟机采用 `内核线程` 实现线程模型,也就是说 Java 线程都是直接映射到操作系统线程的,因此线程相关的操作都需要在内核态由操作系统执行,导致了用户态切和内核态之间的切换,因此重量级锁性能不高! - **先说一下 Monitor 的概念:** 每个 Java 对象都与一个 Monitor 相关联,Monitor 的主要目的是确保在任何给定时间,只有一个线程能够执行与特定对象相关联的临界区代码。Monitor 是通过对象头(Object Header)和内置锁(Intrinsic Lock)来实现的,在 JVM 中 Monitor 的具体实现是 ObjectMonitor - **接下来说一下,重量级锁的抢占流程(了解一下整体的流程就行,这点的代码都已经是 JVM 层面的了,比较底层):** 1、当线程准备要抢占重量级锁时,会创建一个 ObjectMonitor (JVM 代码中的 ObjectMonitor)的对象,里边有两个队列 EntryList、WaitSet ![image-20240222220919185](https://11laile-note-img.oss-cn-beijing.aliyuncs.com/image-20240222220919185.png) 2、如果该锁正在被其他线程使用,当前线程会先进入到 EntryList 队列中 3、当重量级锁被释放之后,JVM 会指定 EntryList 队列头部的第一个线程为 OnDeck Thread,也就是准备拿到锁的线程 4、如果持有锁的线程被 Object.wait() 方法阻塞,就会转移到 WaitSet 队列,等待被 notify() 或 notifyAll() 唤醒之后进入到 EntryList 队列中 ![image-20240222212240919](https://11laile-note-img.oss-cn-beijing.aliyuncs.com/image-20240222212240919.png) 这里说一下重量级锁的抢占是 `非公平锁` ,因为线程来抢占重量级锁之前都会先通过 CAS 自选获取锁,如果获取不到了才会进入到队列等待获取重量级锁,因此这对于队列中的线程是不公平的! 当获取了重量级锁之后,就会将锁对象中的 `锁指针` 指向 ObjectMonitor 对象 ![image-20240226204046427](https://11laile-note-img.oss-cn-beijing.aliyuncs.com/image-20240226204046427.png) ### 总结 最后总结一下 `无锁升级到偏向锁` 比较简单,将对象的 Mark Word 指向当前线程即可 一旦发生多线程竞争锁,就会升级到 `轻量级锁` ,这个抢占过程是通过 CAS 来完成的,主要是通过 CAS 更新锁对象的 Mark Word 值,更新成功说明抢占到了轻量级锁 如果 CAS 达到一定次数,会升级到 `重量级锁` ,这个过程是多个线程在 ObjectMonitor 的阻塞队列中进行排队的 ## synchronized 深入剖析 ### 面试官:synchronized 关键字可以保证可见性吗? ![image-20240226185752791](https://11laile-note-img.oss-cn-beijing.aliyuncs.com/image-20240226185752791.png) **synchronized 是可以保证可见性的** **先介绍一下可见性是什么东西:** 线程要修改一个变量,这个变量是在主内存中存储的,线程修改时,要先去主内存读取一份到自己的工作内存中,这个工作内存是线程私有的,其他线程看不到,因此如果当前线程修改完毕,没有及时刷新到主内存,或者其他线程读取的时候,没有及时去主内存中读取最新值,就会导致出现数据的不一致问题,也就是数据的不可见,那么保证数据的可见性,就是要保证多个线程中的数据一致性,避免其中一个线程修改变量之后,其他线程看不见变量的更新! **接下来说一下可见性的保证:** 先从底层说起,可见性的保证,在底层其实是通过 MESI 协议来保证的,也就是保证多个处理器(CPU)和主内存之间的数据一致性,从而保证在操作系统层面上,多个线程之间对数据的更新是可见的 这里说一下 MESI 如何保证数据一致性,只简单说一下,毕竟我们不是专攻底层的 在 MESI 协议中,主要有 `两个关键机制` 来保证数据的一致性:flush 和 refresh ![image-20240226145818519](https://11laile-note-img.oss-cn-beijing.aliyuncs.com/image-20240226145818519.png) - **flush** 将自己更新的值刷新到高速缓存里去,让其他处理器在后续可以通过一些机制从自己的高速缓存里读到更新后的值 并且还会给其他处理器发送一个 flush 消息,让其他处理器将对应的缓存行标记为无效,确保其他处理器不会读到这个变量的过时版本 - **refresh** 处理器中的线程在读取一个变量的值的时候,如果发现其他处理器的线程更新了变量的值,必须从其他处理器的高速缓存(或者是主内存)里,读取这个最新的值,更新到自己的高速缓存中 **上边是硬件级别保证可见性的原理,那么在上层保证可见性其实是基于内存屏障来做的,内存屏障的作用可以理解为强制去读取最新值以及将最新值刷回主内存,也就是有内存屏障的地方会强制线程去执行 refresh 和 flush 动作,从而保证数据的一致性** **synchronized 保证可见性:** 那么 synchronized 保证可见性其实也就是通过 `内存屏障` 来保证的,在进入 synchronized 代码块和退出的时候,都会插入内存屏障,目的就是保证在进入的时候,强制去执行 refresh 操作,这样可以保证读取到变量的最新值,而在退出 synchronized 代码块时,也就是对变量的修改已经完成了,此使强制去执行 flush 操作,可以保证将变量的最新值给刷新到主内存中去 **如下,在 synchronized 中添加的内存屏障:** ```java int b = 0; int c = 0; synchronized (this) { --> monitorenter --> Load 内存屏障 --> Acquire 内存屏障 int a = b; c = 1; --> Release 内存屏障 } --> monitorexit --> Store 内存屏障 ``` - `Acquire 屏障 = LoadLoad + LoadStore` - Acquire 屏障确保一个线程在执行到屏障之后的内存操作之前,能看到其他线程在屏障之前的所有内存操作的结果 - `Release 屏障 = LoadStore + StoreStore` - Release 屏障用于确保一个线程在执行到屏障之后的内存操作之前,其他线程能看到该线程在屏障之前的所有内存操作的结果 这里再介绍一下 JVM 中的内存屏障,也不用都背会,了解内存屏障这个东西就可以了,背会其实没有意义 JMM 中有 4 类`内存屏障`:(Load 操作是从主内存加载数据,Store 操作是将数据刷新到主内存) - `LoadLoad` :确保该内存屏障前的 Load 操作先于屏障后的所有 Load 操作。对于屏障前后的 Store 操作并无影响屏障类型 - `StoreStore` :确保该内存屏障前的 Store 操作先于屏障后的所有 Store 操作。对于屏障前后的Load操作并无影响 - `LoadStore` :确保屏障指令之前的所有Load操作,先于屏障之后所有 Store 操作 - `StoreLoad` :确保屏障之前的所有内存访问操作(包括Store和Load)完成之后,才执行屏障之后的内存访问操作。全能型屏障,会屏蔽屏障前后所有指令的重排 ### 为什么不建议在高并发场景下使用 synchronized? ![image-20240227095725254](https://11laile-note-img.oss-cn-beijing.aliyuncs.com/image-20240227095725254.png) 这首先我们要了解 `高并发场景的特点` 以及 `synchronized 底层加锁的原理` 是怎样的! **首先说一下 synchronized 底层加锁的原理:** **synchronized 在 JDK1.6 之后引入了锁的优化,随着多线程竞争的激烈程度不同,使用的锁也不同** - 当没有线程竞争,此时为 `无锁` 状态 - 如果只有一个线程不停访问同步代码块,此时会使用 `偏向锁` - 如果有两个以上线程并发访问,偏向锁会撤销,并升级为 `轻量级锁` (偏向锁在 JDK15 之后就被废弃了,因为撤销带来性能开销比较大) - 如果在轻量锁 CAS 自旋达到一定次数还没有拿到锁,就会撤销轻量级锁,升级为 `重量级锁` ,其实重量级锁的开销是比较大的,因为底层涉及到 在高并发场景下,并发度肯定是比较高的,**不建议使用 synchronized 的原因主要有以下几点:** - 由于并发度比较高,因此 synchronized 一定会升级到重量级锁,但是重量级锁的性能是不太高的,因为线程要阻塞再唤醒,需要用户态和内核态之间切换 - synchronized 没有读写锁优化 - synchronized 不能对线程唤醒,也就是你线程如果获取不到锁的话会一直阻塞 在使用 synchronized 的时候,一定要 **直接将偏向锁给禁掉** ,因为大多数情况下,偏向锁都需要撤销升级为轻量级锁,而偏向锁的撤销性能是比较差的! 所以如果优化的话,对于第一个点来说,将等待线程阻塞再唤醒,个人感觉优化空间不大 第二个点就是读写锁的优化,读读之间不互斥,大幅度增强 `读多写少` 场景下的性能! 第三个点就是需要一个 `tryLock(timeout)` 功能,在指定时间获取不到锁的时候,可以直接将线程超时了,不去拿锁了 - **为什么说需要 `tryLock(timeout)` 这个功能呢?** 假设这样一种场景,有一个任务在某个时间点可能多个线程同时要来执行,但是只要有一个线程执行完毕之后,其他线程就不需要执行了 那么假设在这个需要执行任务的时间点,大量线程同时过来执行,也就是大量线程都进入阻塞队列等待获取锁,第一个线程拿到锁执行任务之后,此时后边的线程都不需要执行该任务了,但是由于没有这个超时功能,导致后边的线程还需要在队列中等待获取锁,再一个个进入同步代码块,发现任务已经执行过了,不需要自己再执行了,之后再退出同步代码块 因此这个 `tryLock(timeout)` 的作用就是 **将大量线程的串行操作转为并行操作** ,大量线程发现指定时间内获取不了锁了,直接超时,不获取锁了,这样后边的线程再来看就发现任务已经执行过了,不需要再去获取锁执行任务了 ![image-20240226200621006](https://11laile-note-img.oss-cn-beijing.aliyuncs.com/image-20240226200621006.png) 这里 `tryLock(timeout)` 的情况只是举一个特殊的情况,其实是参考了分布式环境下,更新 Redis 缓存时会出现这种情况,但是在分布式环境下肯定不会使用 synchronized ,因此这里主要是举个例子说一下 tryLock 的作用! 上边主要说了 synchronized 的缺点,一方面是为了应对面试,另一方面也可以通过各种问题来引发自己的思考,让自己对 synchronized 的理解更加深入 一般在写项目使用分布式锁还是多一些,毕竟高并发项目肯定不会使用单节点部署 而单机项目的话,一般也不会追求极致的性能,使用 synchronized 也没有什么问题