深入理解 synchronized 的锁优化
大家好,我是树哥!
我们都知道 synchronized 关键字能实现线程安全,但是你知道这背后的原理是什么吗?今天我们就来讲一讲 synchronized 实现线程同步背后的原因,以及相关的锁优化策略吧。
背后的原理
synchronized 关键字经过编译之后,会在同步块的前后分别形成 monitorenter 和 monitorexit 这两个字节码指令,这两个字节码的执行需要指明一个要锁定或解锁的对象。如果 Java 程序中指明了对象参数,那么就用这个对象作为锁。
如果没有指定,那么就根据 synchronized 修饰的是实例方法还是类方法,去拿对应的对象实例或 Class 对象来作为锁对象。因此我们可以知道,synchronized 关键字实现线程同步的背后,其实是 Java 虚拟机规范对于 monitorenter 和 monitorexit 的定义。
在 Java 虚拟机规范对 monitorenter 和 monitorexit 的行为描述中,有两点需要特别注意。
synchronized 同步块对同一条线程是可冲入的,也就是不会出现自己把自己锁死的问题。 同步课在已进入的线程执行完之前,会阻塞后面其他线程的进入。
synchronized 关键字在 JDK1.6 版本之前,是通过操作系统的 Mutex Lock 来实现同步的。而操作系统的 Mutex Lock 是操作系统级别的方法,需要切换到内核态来执行。这就需要从用户态转换到内核态中,因此我们说 synchronized 同步是重量级的操作。
锁优化
在 JDK1.6 版本中,HotSpot 虚拟机开发团队花了很大的精力去实现各种锁优化技术,如:适应性自旋、锁消除、锁粗话、偏向锁、轻量级锁等。其中最重要的是:自旋锁、轻量级锁、偏向锁这三个,我们重点讲这三个锁优化。
自旋锁与自适应自旋
对于重量级的同步操作来说,最大的消耗其实是内核态与用户态的切换。很很多时候,对于共享数据的操作时间可能很短,比内核态切换到用户态这个耗时还短。
于是有人就想:如果有多个线程并发去获取锁的时候,如果能让后面那个请求锁的线程「稍等一下」,不放弃 CPU 的执行时间,看看持有锁的线程是否会很快释放锁。为了让线程等待,我们只需让线程执行一个忙循环(自旋),这项技术就是所谓的自旋锁。 从理论上来看,如果所有线程都很快地获取锁、释放锁,那么自旋锁是可以带来较大的性能提升的。自旋锁在 JDK 1.4.2 中就已经引入,默认自旋 10 次。但自旋锁默认是关闭的,在 JDK 1.6 中才改为默认开启了。
自旋等待虽然避免了线程切换的开销,但还是要占用处理器的时间。如果锁被占用的时间段,自旋等待的效果就会非常好。但如果锁被长时间占用,那么自旋的线程就会白白消耗处理器的资源,从而带来性能上的浪费。
为了解决特殊情况下自旋锁的性能消耗问题,在 JDK1.6 的时候引入了自适应的自旋锁。 自适应意味着自旋时间不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者状态决定。如果在同一锁对象上,自旋等待刚刚成功获得过锁,那么虚拟机认为这次自旋也很有可能再次成功,进而允许线程自旋更长时间,例如自旋 100 个循环。
但如果对于某个锁,自旋很少成功获得过。那虚拟机为了避免浪费 CPU 资源,有可能省略掉自旋过程。有了自旋锁,随着程序运行和性能监控信息的不断完善,虚拟机对锁的状态预测就越准,虚拟机也会变得越来越聪明。
轻量级锁
轻量级锁是 JDK1.6 加入的新型锁机制,名字中的「轻量级」是相对于操作系统互斥量这个重量级锁而言的。轻量级锁诞生的原因,是由于对于绝大部分的锁而言,整个同步周期都不存在竞争。如果没有竞争的话,那就没必要使用重量级锁了,于是就诞生了轻量级锁来提高效率。
对于轻量级锁来说,其同步的流程如下:
在代码进入同步块的时候,如果此同步对象没有被锁定(锁标志位为 01 状态),那么虚拟机会在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的 Mark Word 拷贝。 虚拟机将使用 CAS 操作尝试将对象的 Mark Word 更新为指向 Lock Record 的指针。如果更新动作成功了,那么线程就泳衣了该对象的锁,并且对象 Mark Word 的锁标志位就变成了 00,表示此对象处于轻量级锁定状态。
简单地说,轻量级锁的同步流程可以总结为:使用 CAS 操作,在线程栈帧与锁对象建立双向的指针。
在没有线程竞争的情况下,轻量级锁使用 CAS 自旋操作避免了使用互斥量的开销,提高了效率。但如果存在锁竞争,除了互斥量的开销外,还额外发生了 CAS 操作。因此在有竞争的情况下,轻量级锁会比传统的重量级锁更慢。
偏向锁
偏向锁是 JDK1.6 中引入的一项优化,它的意思是这个锁会偏向于第一个获得它的线程。如果在接下来的执行过程中,该锁没有被其他线程获取,则持有偏向锁的线程将永远不需要再进行同步。 对于偏向锁来说,其同步流程如下所示:
假设当前虚拟机启动了偏向锁,那么当锁对象第一次被线程获取的时候,虚拟机将会把对象的锁标志位设置为 01,偏向锁位设置为 1。 同时使用 CAS 操作将线程 ID 记录在对象的 MarkWord 之中。如果 CAS 操作成功,那么持有偏向锁的线程进入锁对应的同步块时,虚拟机将不再进行任何同步操作。 当有另外一个线程尝试去获取这个锁时,根据锁对象目前是否处于锁定状态,将其恢复到未锁定(01)或轻量级锁定(00)状态。随后的同步操作,就向上面介绍的轻量级锁那样执行。
可以看到偏向锁还是需要做一些 CAS 操作,但是对比起轻量级锁来说,其要设置的内容大大减少了,因此也提高了一些效率。
偏向锁可以提高带有同步但无竞争的程序性能。 它同样是一个带有效益权衡(Trade Off)性质的优化,也就是说,它并不一定总是对程序运行有利,如果程序中大多数的锁总是被多个不同的线程访问,那偏向模式就是多余的。
优化后的锁获取流程
经过 JDK1.6 的优化,synchronized 同步机制的流程变成了:
首先,synchronized 会尝试使用偏向锁的方式去竞争锁资源,如果能够竞争到偏向锁,表示加锁成功直接返回。 如果竞争锁失败,说明当前锁已经偏向了其他线程。需要将锁升级到轻量级锁,在轻量级锁状态下,竞争锁的线程根据自适应自旋次数去尝试抢占锁资源。 如果在轻量级锁状态下还是没有竞争到锁,就只能升级到重量级锁。在重量级锁状态下,没有竞争到锁的线程就会被阻塞。处于锁等待状态的线程需要等待获得锁的线程来触发唤醒。
上面的锁获取流程,可以用如下的示意图来表示:
总结
本文首先简单讲解了 synchronized 关键字实现同步的原理,其实是通过 Java 虚拟机规范对于 monitorenter 和 monitorexit 的支持,从而使得 synchronized 能够实现同步。而 synchronized 同步本质上是通过操作系统的 mutex 锁来实现的。由于操作操作系统 mutex 锁太过于消耗资源,因此在 JDK1.6 后 HotSpot 虚拟机做了一系列的锁优化,其中最重要的便是:自旋锁、轻量级锁、偏向锁。这三个锁的诞生原因,以及提升的点如下表所示。
现状 | 锁名称 | 收益 | 使用场景 |
---|---|---|---|
大多数情况下,等待锁的时间比操作系统 mutex 短得多 | 自旋锁 | 减少内核态与用户态切换的开销 | 线程获取锁时间较短的情况 |
大多数情况下,锁同步期间没有线程竞争 | 轻量级锁 | 与自旋锁相比,减少了自旋时间 | 没有线程竞争锁 |
大多数情况下,锁同步期间没有线程竞争 | 偏向锁 | 与轻量级锁相比,减少了多余的对象复制操作 | 没有线程竞争锁 |
从上面表格可以看到,自旋锁、轻量级锁、偏向锁,他们的优化是逐渐深入的。
对于重量级锁来说,自旋锁减少了互斥量的内核、用户态切换开销。 对于自旋锁来说,轻量级锁减少了自旋等待的时间。 对于轻量级锁来说,偏向于减少了多余的对象复制操作。
参考资料
深入理解 Java 虚拟机:JVM 高级特性与最佳实践(第 2 版)- 周志明 - 微信读书
【Java 面试】为什么引入偏向锁、轻量级锁,介绍下升级流程 - 跟着 Mic 学架构 - 博客园
不可不说的 Java “锁” 事 - 美团技术团队
推荐阅读