前言
聊到Java中的锁(synchronized
关键字),我们需要了解重量锁、轻量锁(自旋锁)、偏向锁、锁消除、锁膨胀等一些知识。
下面我们来具体看一下。
锁优化
JDK中对synchronized
进行了许多优化。它们如下:
优化一
A: 重量级锁中的阻塞(挂起线程/恢复线程): 需要转入内核态中完成,有很大的性能影响。
B: 锁大多数情况都是在很短的时间执行完成。
解决方案: 引入轻量锁(通过自旋来完成锁竞争)。
优化二
A: 轻量级锁中的自旋: 占用CPU时间,增加CPU的消耗(因此在多核处理器上优势更明显)。
B: 如果某锁始终是被长期占用,导致自旋如果没有把握好,白白浪费CPU资源。
解决方案: JDK5中引入默认自旋次数为10(用户可以通过-XX:PreBlockSpin进行修改), JDK6中更是引入了自适应自旋(简单来说如果自旋成功概率高,就会允许等待更长的时间(如100次自旋),如果失败率很高,那很有可能就不做自旋,直接升级为重量级锁,实际场景中,HotSpot认为最佳时间应该是一个线程上下文切换的时间,而是否自旋以及自旋次数更是与对CPUs的负载、CPUs是否处于节电模式等息息相关的)。
优化三
A: 无论是轻量级锁还是重量级锁: 在进入与退出时都要通过CAS修改对象头中的Mark Word来进行加锁与释放锁。
B: 在一些情况下总是同一线程多次获得锁,此时第二次再重新做CAS修改对象头中的Mark Word这样的操作,有些多余。
解决方案: JDK6引入偏向锁(首次需要通过CAS修改对象头中的Mark Word,之后该线程再进入只需要比较对象头中的Mark Word的Thread ID是否与当前的一致,如果一致说明已经取得锁,就不用再CAS了)。
优化四
A: 项目中代码块中可能绝大情况下都是多线程访问。
B: 每次都是先偏向锁然后过渡到轻量锁,而偏向锁能用到的又很少。
解决方案: 可以使用-XX:-UseBiasedLocking=false禁用偏向锁。
优化五
A: 代码中JDK原生或其他的工具方法中带有大量的加锁。
B: 实际过程中,很有可能很多加锁是无效的(如局部变量作为锁,由于每次都是新对象新锁,所以没有意义)。
解决方法: 引入锁削除(虚拟机即时编译器(JIT)运行时,依据逃逸分析的数据检测到不可能存在竞争的锁,就自动将该锁消除)。
优化六
A: 为了让锁颗粒度更小,或者原生方法中带有锁,很有可能在一个频繁执行(如循环)中对同一对象加锁。
B: 由于在频繁的执行中,反复的加锁和解锁,这种频繁的锁竞争带来很大的性能损耗。
解决方法: 引入锁膨胀(会自动将锁的范围拓展到操作序列(如循环)外, 可以理解为将一些反复的锁合为一个锁放在它们外部)。
基本原理
上面说到的优化是十分笼统的,现在我们来分析一下JDK源码中锁实现的基本原理,进一步对Java的锁机制有更深入的了解。
对象头
在HotSpot虚拟机中,对象在内存中存储的布局可以分为三块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。
其中对象头(Header)包括两部分信息:
- Mark Word:用于存储对象自身的运行时数据, 如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等等。
- Klass Pointer:对象指向它的类的元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
- ArrayLength:用于记录数组长度的数据(如果当前对象不是数组,就没有这一部分数据)。
其中我们关注的就是对象头中的Mark Word
,其中存储着关于对象锁的一些信息。
对于处于不同状态的对象(无锁、轻量锁、偏向锁、重量锁),其Mark Word里的内容是不一样的,如下图分别是32位HotSpot虚拟机和64位HotSpot虚拟机对象头的存储结构:
我们来看下JVM源码,来证实下上图所说的内容。
我们可以通过 OpenJDK网站 OpenJDK Project来下载JDK源码,我这儿下载了JDK10 源码,那么我们可以在\jdk10-b09e56145e11\src\hotspot\share\oops
文件夹下找到klass.hpp
、klass.cpp
、markOop.cpp
、markOop.hpp
等文件。
PS: hotspot文件夹即为JVM源码文件夹。
无论是32位的JVM还是64位的JVM,均为 1bit偏向锁+2bit锁标志位。对于Java中的synchronized
关键字,就使用了Mark Word来标识对象加锁状态。
synchronized实现原理
我们刚才上面提到了锁优化的一些内容,对于synchronized
关键字,JVM到底进行了怎样的处理呢?
我们通过JVM synchronized
的源码来大致了解下。
我们先写一个简单类,如下:
1 | public class Test { |
一个是在代码块上加锁,另一个是对方法加锁。
对于这两种的同步,JVM都是基于监视器对象(Monitor)的进入和退出来实现的,但是两者的实现细节有所不同。
代码块同步是使用monitorenter和monitorexit指令实现的,而方法同步是使用字节码同步指令ACC_SYNCHRONIZED来实现的。
字节码同步指令ACC_SYNCHRONIZED原理:JVM通过使用Monitor来支持同步,JVM可以从方法常量池的方法表结构中的ACC_SYNCHRONIZED访问标志来得知一个方法是否声明为同步方法,当方法调用时,调用指令将会检查方法的ACC_SYNCHRONIZED访问标志是否被设置,如果设置了,执行线程就要求先成功持有Monitor,然后才能执行方法,最后当方法完成(无论是正常完成还是非正常完成)时释放管程,在方法执行期间,执行线程持有了Monitor,其他任何线程都无法在获取到同一个Monitor。
我们将上述Test.java文件编译为Test.class 文件,然后反编译成字节码来看一下。
如下指令:
1 | javap -verbose -c E:\WorkSpace\helputils\target\test-classes\com\zwt\helputils\Test.class |
如下图:
可以看到对于代码块同步,使用了monitorenter和monitorexit,而我们看到有两个monitorexit是因为保证出现异常monitor也能正确退出。对于方法同步,该方法有ACC_SYNCHRONIZED访问标志来保证同步性。
下面我们来分析下Synchronization部分的JVM源码,我们在jdk10-b09e56145e11\src\hotspot\share\interpreter\interpreterRuntime.cpp
文件中可以找到monitorenter和monitorexit的相关方法,如下图:
如果有启用偏向锁,则会使用ObjectSynchronizer::fast_enter
来尝试获得偏向锁,否则会使用ObjectSynchronizer::slow_enter
来尝试获得轻量锁。
使用ObjectSynchronizer::slow_exit
来释放锁。
偏向锁的获取
我们来看下ObjectSynchronizer::fast_enter
方法,它在jdk10-b09e56145e11\src\hotspot\share\runtime\synchronizer.cpp
文件里,如下:
方法revoke_and_rebias
为偏向锁的获取和撤销相关代码,它在jdk10-b09e56145e11\src\hotspot\share\runtime\biasedLocking.cpp
文件里,相关代码量较多,这儿就不过多展示了。
偏向锁获取的相关逻辑如下:
- 通过markOop mark = obj->mark()获取对象的markOop数据mark,即对象头的Mark Word;
- 判断mark是否为可偏向状态,即mark的偏向锁标志位为 1,锁标志位为 01;
- 判断mark中JavaThread的状态:如果为空,则进入4;如果指向当前线程,则执行同步代码块;如果指向其它线程,进入5;
- 通过CAS原子指令设置mark中JavaThread为当前线程ID,如果执行CAS成功,则执行同步代码块,否则进入5;
- 如果执行CAS失败,表示当前存在多个线程竞争锁,当达到全局安全点(safepoint),获得偏向锁的线程被挂起,撤销偏向锁,并升级为轻量级,升级完成后被阻塞在安全点的线程继续执行同步代码块;
偏向锁的撤销
偏向锁的撤销必须等待全局安全点(没有正在执行的字节码)时执行。revoke_at_safepoint
是偏向锁撤销相关代码,它也在它在jdk10-b09e56145e11\src\hotspot\share\runtime\biasedLocking.cpp
文件里。
偏向锁的撤销逻辑和偏向获取和撤销次数有关系,会走不同的逻辑,其相关逻辑如下:
- 偏向锁的撤销动作必须等待全局安全点;
- 暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态;
- 撤销偏向锁,恢复到无锁(标志位为 01)或轻量级锁(标志位为 00)的状态;
轻量锁的获取
当偏向锁处于关闭状态,或者多个线程竞争导致偏向锁升级为轻量锁,程序或尝试获取轻量锁。
其代码入口为jdk10-b09e56145e11\src\hotspot\share\runtime\synchronizer.cpp
文件里的ObjectSynchronizer::slow_enter
方法。
轻量锁的获取相关逻辑如下:
- markOop mark = obj->mark()方法获取对象的markOop数据mark;
- mark->is_neutral()方法判断mark是否为无锁状态:mark的偏向锁标志位为 0,锁标志位为 01;
- 如果mark处于无锁状态,则进入4,否则执行6;
- 把mark保存到BasicLock对象的_displaced_header字段;
- 通过CAS尝试将Mark Word更新为指向BasicLock对象的指针,如果更新成功,表示竞争到锁,则执行同步代码,否则执行6;
- 如果当前mark处于加锁状态,且mark中的ptr指针指向当前线程的栈帧,则执行同步代码,否则说明有多个线程竞争轻量级锁,轻量级锁需要膨胀升级为重量级锁;
假设线程A和B同时执行到临界区if (mark->is_neutral()):
- 线程AB都把Mark Word复制到各自的_displaced_header字段,该数据保存在线程的栈帧上,是线程私有的;
- Atomic::cmpxchg_ptr原子操作保证只有一个线程可以把指向栈帧的指针复制到Mark Word,假设此时线程A执行成功,并返回继续执行同步代码块;
- 线程B执行失败,退出临界区,通过ObjectSynchronizer::inflate方法开始膨胀锁;
轻量锁的释放
轻量锁的释放入口为jdk10-b09e56145e11\src\hotspot\share\runtime\synchronizer.cpp
文件里的ObjectSynchronizer::slow_exit
方法,它最终调用的为该文件里的ObjectSynchronizer::fast_exit
方法。
轻量锁的释放相关逻辑如下:
- 确保处于偏向锁状态时不会执行这段逻辑;
- 取出在获取轻量级锁时保存在BasicLock对象的mark数据dhw;
- 通过CAS尝试把dhw替换到当前的Mark Word,如果CAS成功,说明成功的释放了锁,否则执行4;
- 如果CAS失败,说明有其它线程在尝试获取该锁,这时需要将该锁升级为重量级锁,并释放;
重量锁
重量级锁通过对象内部的monitor实现,其中monitor的本质是依赖于底层操作系统的Mutex Lock实现,操作系统实现线程之间的切换需要从用户态到内核态的切换,切换成本非常高。
锁膨胀
一般情况下重量级锁都是由轻量锁膨胀来的,其方法实现为jdk10-b09e56145e11\src\hotspot\share\runtime\synchronizer.cpp
文件的ObjectSynchronizer::inflate
方法。
如下图:
膨胀过程的相关逻辑如下:
- 整个膨胀过程在自旋下完成;
- mark->has_monitor()方法判断当前是否为重量级锁,即Mark Word的锁标识位为 10,如果当前状态为重量级锁,执行3,否则执行4;
- mark->monitor()方法获取指向ObjectMonitor的指针,并返回,说明膨胀过程已经完成;
- 如果当前锁处于膨胀中,说明该锁正在被其它线程执行膨胀操作,则当前线程就进行自旋等待锁膨胀完成,这里需要注意一点,虽然是自旋操作,但不会一直占用cpu资源,每隔一段时间会通过os::NakedYield方法放弃cpu资源,或通过park方法挂起;如果其他线程完成锁的膨胀操作,则退出自旋并返回;
- 如果当前是轻量级锁状态,即锁标识位为 00,膨胀过程如下:
- 通过omAlloc方法,获取一个可用的ObjectMonitor monitor,并重置monitor数据;
- 通过CAS尝试将Mark Word设置为markOopDesc:INFLATING,标识当前锁正在膨胀中,如果CAS失败,说明同一时刻其它线程已经将Mark Word设置为markOopDesc:INFLATING,当前线程进行自旋等待膨胀完成;
- 如果CAS成功,设置monitor的各个字段:_header、_owner和_object等,并返回;
- 如果是无锁,重置monitor值;
重量锁的获取与释放
重量锁的获取包括锁的竞争、等待锁的释放与尝试获取步骤。
当锁膨胀完成并返回对应的monitor时,并不表示该线程竞争到了锁,真正的锁竞争发生在jdk10-b09e56145e11\src\hotspot\share\runtime\objectMonitor.cpp
文件里的ObjectMonitor::enter
方法里。
monitor竞争失败的线程,通过自旋执行ObjectMonitor::EnterI
方法等待锁的释放。当该线程被唤醒时,会从挂起的点继续执行,通过ObjectMonitor::TryLock
尝试获取锁。
当某个持有锁的线程执行完同步代码块时,会进行锁的释放,给其它线程机会执行同步代码,在HotSpot中,通过退出monitor的方式实现锁的释放,并通知被阻塞的线程,具体实现位于ObjectMonitor::exit
方法中。
其具体逻辑涉及到大量C++代码,这儿就不在对代码进行过多分析。
这儿总结下重量锁的竞争逻辑大致如下(ObjectMonitor::enter
方法相关逻辑):
- 通过CAS尝试把monitor的_owner字段设置为当前线程;
- 如果设置之前的_owner指向当前线程,说明当前线程再次进入monitor,即重入锁,执行_recursions ++ ,记录重入的次数;
- 如果之前的_owner指向的地址在当前线程中,这种描述有点拗口,换一种说法:之前_owner指向的BasicLock在当前线程栈上,说明当前线程是第一次进入该monitor,设置_recursions为1,_owner为当前线程,该线程成功获得锁并返回;
- 如果获取锁失败,则等待锁的释放;
等待锁释放的逻辑大致如下(ObjectMonitor::EnterI
方法相关逻辑):
- 当前线程被封装成ObjectWaiter对象node,状态设置成ObjectWaiter::TS_CXQ;
- 在for循环中,通过CAS把node节点push到_cxq列表中,同一时刻可能有多个线程把自己的node节点push到_cxq列表中;
- node节点push到_cxq列表之后,通过自旋尝试获取锁,如果还是没有获取到锁,则通过park将当前线程挂起,等待被唤醒;
- 当该线程被唤醒时,会从挂起的点继续执行,通过
ObjectMonitor::TryLock
尝试获取锁; ObjectMonitor::TryLock
方法本质就是通过CAS设置monitor的_owner字段为当前线程,如果CAS成功,则表示该线程获取了锁,跳出自旋操作,执行同步代码,否则继续被挂起;
重量锁的释放相关逻辑大致如下(ObjectMonitor::exit
方法的相关逻辑):
- 如果是重量级锁的释放,monitor中的_owner指向当前线程,即THREAD == _owner;
- 根据不同的策略(由QMode指定),从cxq或EntryList中获取头节点,通过
ObjectMonitor::ExitEpilog
方法唤醒该节点封装的线程,唤醒操作最终由unpark完成; - 被唤醒的线程,继续执行monitor的竞争;
锁的升级情况
锁的升级是单向的:无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁
有位笔者大神给出了锁的变换关系相关流程图,如下:
总结
本文主要介绍了synchronized
的特点和实现,了解了偏向锁、轻量锁、重量锁、锁撤销和锁膨胀的一些原理。对我们更好的理解Java锁机制提供了一些帮助。