深入理解 JUC:synchronized 关键字
关键字 synchronized 是 java 程序员在进入并发编程世界时的银弹,只要是遇到有并发访问安全的地方,会无脑的加上一个 synchronized 关键字进行修饰。但是随着对 java 并发编程的逐渐深入,我们也开始慢慢意识到 synchronized 是一个重量级的操作,曾经甚至有一段时间人们倡导使用 Lock 来代替 synchronized 关键字。不过 Lock 虽然灵活但也有其弊端,对开发人员写出线程安全且无死锁的多线程程序要求相对要提高了许多,好在 java 6 对 synchronized 关键字进行了大刀阔斧的优化,并推荐在 Lock 和 synchronized 均满足需求的场景下优先使用 synchronized 关键字。本文我们就一起来深入分析一下 synchronized 关键字的实现内幕。
站在字节码层面看 synchronized 关键字
关键字 synchronized 可以修饰实例方法、静态方法,以及代码块,对应的锁粒度分别为:
- 修饰实例方法:锁对象是方法所属类对象。
- 修饰静态方法:锁对象是方法所属类的 Class 对象。
- 修饰代码块:锁对象是 synchronized 关键字括号中指定的对象。
当程序执行 synchronized 关键字修饰的代码之前,需要先获取 synchronized 关键字对应的锁对象,并在执行完成之后释放持有的锁对象。
修饰代码块
在实现层面上,如果 synchronized 关键字修饰的是代码块,那么编译器在将 synchronized 代码块编译成字节码时,会在代码块的前后分别插入 monitorenter
和 monitorexit
指令,示例:
1 | private int count = 0; |
使用 javap -v
查看上述 java 代码对应的字节码:
1 | 0: aload_0 |
为什么会有 2 个 monitorexit 指令?
这主要是因为在上述实现中,关键字 synchronized 释放锁对象存在两种场景:一种是正常执行完成后释放;另外一种是发生异常后由 JVM 释放。如上述字节码所示,当正常执行时,指令 monitorexit
后面紧跟 goto 指令,跳转到第 24 行执行 return 返回;如果发生异常则会进入 goto 语句后面的逻辑,即执行第 2 个 monitorexit
指令,以确保异常的情况下锁也能够被释放,防止死锁。
修饰方法
如果 synchronized 关键字修饰的是方法(不管是实例方法,还是静态方法),则编译器在将 java 代码编译成字节码时会给相应的方法添加一个 ACC_SYNCHRONIZED
访问标记(记录在运行时常量池中)。运行时线程在执行方法之前会检查该标记以确认是否需要获取相应的监视器(monitor)锁,如果执行完成退出(不管是正常退出,还是异常退出)则会释放持有的监视器锁。如果在执行 synchronized 方法期间发生了异常,并且方法中未捕获相应异常,则在将异常向上层调用方抛出去之前会释放持有的监视器锁。
1 | public synchronized void syncMethod() { } |
针对上述 synchronized 方法编译得到的字节码示例:
1 | public synchronized void syncMethod(); |
上面介绍的 monitorenter
和 monitorexit
指令,以及 ACC_SYNCHRONIZED
访问标记,实际上都是基于 monitor 机制实现的,每个对象都有一个 monitor 与之关联。我们通常说的获取一个对象的锁,本质上就是尝试持有该对象的 monitor。实现层面上,monitor 在 HotSpot 虚拟机中对应 objectMonitor.cpp
类,采用 C++ 语言实现。
Java 对象头与锁优化策略
在 JVM 中,每个 java 对象都有一个对象头(object header),一个 java 对象头由标记字段(Mark Word)和类型指针(Klass Word)所构成。其中,标记字段用以存储对象的运行数据,如哈希码、GC 分代年龄,以及锁信息等,而类型指针则指向该对象所属的类。如下所示(引用自 https://gist.github.com/arturmkrtchyan/43d6135e8a15798cc46c):
1 | |----------------------------------------------------------------------------------------|--------------------| |
关键字 synchronized 用到的对象锁就存在于 java 对象头的 Mark Word 中。Mark Word 依据对象所处的状态可以分为无锁状态、偏向锁、轻量级锁、重量级锁,以及 GC 5 种状态,如下图所示(以 32 位 JVM 为例):
其中偏向锁的 epoch 字段可以理解为偏向锁的年代信息(或版本信息),具体作用将在下面介绍偏向锁时进行说明。
在 java 6 之前关键字 synchronized 的性能较差,那时候所依赖的 monitor 实现完全依靠操作系统内部的互斥锁,因需要在用户态和内核态之间进行切换,所以同步操作是一个重量级的操作。Java 6 对锁的实现引入了大量的优化措施,包括自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁,以及轻量级锁等技术,从而极大提升了 synchronized 关键字的性能,且相对于 Lock 在使用上更加简单,所以在 synchronized 能够满足需求的前提下更加推荐使用 synchronized 关键字。
关于锁消除和锁粗化的概念比较简单,这里简单说明一下,其它优化技术将在下文展开介绍。
锁消除 策略简单来说就是将一些实际运行过程中不存在竞争的锁对象予以剔除。基于逃逸分析技术,即时编译器能够明确哪些对象是不会逃逸的,这些非逃逸对象不会在多个线程之间进行共享,也就不存在锁竞争。这种情况下线程自身加锁解锁操作除了影响性能之外,没有任何收益,所以可以将这类锁予以消除,以提升程序性能。
锁粗化 策略可以理解为将多个连续的小范围锁合并为一个大范围锁,以减少频繁加锁解锁的操作次数。Java 并发编程主张在编写并发程序时尽量缩小临界区的范围,以降低锁定的时间和锁竞争的可能性,从而提升并发程序的性能,但是如果一个线程连续遇到多个小范围的锁,频繁的加锁解锁操作也是一种开销,锁粗化技术适用于优化这一类场景。
偏向锁
偏向锁的设计有这样一个前提,即 大部分情况下锁不仅不存在多线程竞争,而且总是由同一个线程多次获得 ,此时偏向锁能够降低线程获取锁的开销。
当线程首次获取一个对象锁时,如果该锁对象支持偏向锁,那么 JVM 会尝试基于 CAS 操作往目标锁对象的 Mark Word 中写入当前线程的线程 ID,同时设置 Mark Word 的偏向锁标记为 1,并将锁标志位设置为 01,标识当前锁类型是一个服务于当前线程的偏向锁。当线程再次尝试获取该对象锁时,只需要做以下检查:
- 锁对象是否是偏向锁类型,即偏向锁标记为 1,锁标志位为 01;
- 锁对象 Mark Word 中记录的线程 ID 是否等于当前线程的 ID;
- 锁对象 Mark Word 中记录的 epoch 值是否与锁对象所属 Class 类中记录的 epoch 值相同。
如果上述条件均满足,则可以直接获取该对象锁。
下面继续介绍一下 epoch 字段的含义,我们可以简单将其理解为偏向锁的年代信息(或版本信息)。
如果线程请求的锁对象是一个偏向锁,但其中记录的线程 ID 与当前线程 ID 不匹配,则 JVM 需要撤销该偏向锁(注意,此时 epoch 字段值必须匹配,否则当前线程可以认为该偏向锁已经失效,可以直接将偏向锁指向自己)。
如果一个类的某个实例的偏向锁被撤销次数超过指定阈值(默认为 20,可以通过 -XX:BiasedLockingBulkRebiasThreshold
参数指定),则 JVM 会将该偏向锁置为无效,同时将类的 epoch 值加 1,后续遇到小于该 epoch 值的偏向锁均可以视为无效。然而更新 epoch 值可能会导致那些已经获取到该类其它实例偏向锁的线程丢失锁定状态,所以 JVM 会遍历更新这些类实例 Mark Word 中记录的 epoch 值。为了保证线程安全,整个偏向锁撤销过程需要在所有线程处于安全点(SafePoint)时执行。
如果某个类的偏向锁被置为无效的次数过多(默认为 40,可以通过 -XX:BiasedLockingBulkRevokeThreshold
参数指定),则 JVM 会认为该类不适合采用偏向锁,因此会撤销所有类实例的偏向锁,并直接膨胀为轻量级锁。
关于偏向锁引入的假设,即“大部分情况下锁不仅不存在多线程竞争,而且总是由同一个线程多次获得”,存在争议,所以一些 JVM 调优建议使用 -XX:-UseBiasedLocking
参数关闭偏向锁。因为一旦有第 2 个线程尝试获取这把锁,JVM 就需要执行锁膨胀转变为轻量级锁,通过开启安全点日志可以看到不少 RevokeBiasd 的纪录,像 GC 一样 Stop The World。虽然只是很短暂的停顿,但取消之后对于多线程并应用反而能够提升性能。
轻量级锁
上面介绍的偏向锁适用于同一个线程多次获取同一个对象锁的场景,然而在实际并发应用程序中必然存在多个线程竞争同一个对象锁的情况。JVM 在处理时优先采取轻量级锁机制,以避免重量级锁带来的阻塞和唤醒开销。
当执行 加锁 操作时,JVM 首先会判断目标锁对象是否已经是重量级锁,如果不是则线程会在当前栈帧中创建一块区域用于存储锁记录,并将锁对象的 Mark Word 复制到锁记录中,然后线程会基于 CAS 操作尝试将锁对象中的 Mark Word 替换成指向当前栈帧中锁记录的指针,如果操作成功则当前线程成功获取到锁。在执行 CAS 时需要判断锁标志位是否为 01(即偏向锁标志),如果是则在更新锁记录成功之后会将锁标志位修改为 00(即轻量锁标志),如果不是则分为 2 种情况:
- 当前线程重复获取该轻量级锁,此时 JVM 会将锁记录清零,以表示该锁被重复获取。
- 其它线程持有该锁,此时 JVM 会将该锁膨胀为重量级锁,并阻塞当前线程。
当执行 解锁 操作时,JVM 会从线程的当前栈帧中弹出最顶层的锁记录,如果锁记录值为 0 则表示当前线程重复获取了该轻量锁,此时直接返回即可。如果不为 0,则需要基于 CAS 操作将锁对象 Mark Word 替换成当前锁记录中存储的值。这里比对的是锁对象 Mark Word 中记录的指针是否指向当前锁记录,如果是且替换成功则成功释放锁,否则需要将锁膨胀为重量级锁,并进入重量级锁的释放进程。
重量级锁
重量级锁一般依赖于操作系统的互斥锁机制进行实现,当线程尝试对重量级锁执行加锁操作时,如果目标重量级锁已经被占用,则该线程会被阻塞,并一直等到目标重量级锁执行解锁操作时被唤醒。
因为涉及到在操作系统用户态和内核态之间的切换,所以重量级锁的开销相对要大很多,为此 JVM 引入了自旋机制( 自旋锁 )来减少线程阻塞的可能。所谓自旋是指线程在阻塞之前先执行一段空循环(运行的是无用指令),期间轮询尝试获取目标锁,如果恰好能够成功加锁则无需再进入阻塞状态。
准确来说,自旋锁是在 java 4 中被引入的,不过那时默认是关闭的(可以通过 -XX:+UseSpinning
参数开启),直到 java 6 才默认开启。Java 6 中的自旋锁默认自旋次数为 10(可以通过 -XX:PreBlockSpin
参数调整),但是这种静态的配置无法预测运行时的需求,为此 java 6 还引入了 适应性自旋锁 ,其自旋次数会根据以往自旋等待时能否获取到锁来动态调整自旋的次数。
当然,自旋机制也有其自身的缺点,一方面是浪费 CPU 资源,另一方面就是破坏了公平机制。当一个重量级锁被释放时,处于自旋状态的线程相对于处于阻塞状态的线程能够更早的进行感知,也就间接增加了自旋线程成功获取锁的可能性。
总结
本文介绍了 synchronized 关键字的应用场景、实现机制,以及优化技术。相对于与之功能等价的 ReentrantLock 而言,因为在使用上更加简单、不易于出错,且有 JVM 各种锁优化策略的加持,在满足需求的前提下相较于 ReentrantLock 则更加推荐使用 synchronized 关键字。当然,ReentrantLock 的灵活性是 synchronized 关键字所欠缺的,我将在后面的文章中深入分析 ReentrantLock 的实现机制。