Java synchronized锁机制

前言

聊到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虚拟机对象头的存储结构:

upload successful

我们来看下JVM源码,来证实下上图所说的内容。

我们可以通过 OpenJDK网站 OpenJDK Project来下载JDK源码,我这儿下载了JDK10 源码,那么我们可以在\jdk10-b09e56145e11\src\hotspot\share\oops文件夹下找到klass.hppklass.cppmarkOop.cppmarkOop.hpp等文件。

PS: hotspot文件夹即为JVM源码文件夹。

upload successful

无论是32位的JVM还是64位的JVM,均为 1bit偏向锁+2bit锁标志位。对于Java中的synchronized关键字,就使用了Mark Word来标识对象加锁状态。

synchronized实现原理

我们刚才上面提到了锁优化的一些内容,对于synchronized关键字,JVM到底进行了怎样的处理呢?

我们通过JVM synchronized 的源码来大致了解下。

我们先写一个简单类,如下:

1
2
3
4
5
6
7
8
9
10
11
public class Test {
public int i;
public void test(){
synchronized (this){
i++;
}
}
public synchronized void test1(){
i++;
}
}

一个是在代码块上加锁,另一个是对方法加锁。

对于这两种的同步,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

如下图:

upload successful

可以看到对于代码块同步,使用了monitorenter和monitorexit,而我们看到有两个monitorexit是因为保证出现异常monitor也能正确退出。对于方法同步,该方法有ACC_SYNCHRONIZED访问标志来保证同步性。

下面我们来分析下Synchronization部分的JVM源码,我们在jdk10-b09e56145e11\src\hotspot\share\interpreter\interpreterRuntime.cpp文件中可以找到monitorenter和monitorexit的相关方法,如下图:

upload successful

如果有启用偏向锁,则会使用ObjectSynchronizer::fast_enter来尝试获得偏向锁,否则会使用ObjectSynchronizer::slow_enter来尝试获得轻量锁。

使用ObjectSynchronizer::slow_exit来释放锁。

偏向锁的获取

我们来看下ObjectSynchronizer::fast_enter方法,它在jdk10-b09e56145e11\src\hotspot\share\runtime\synchronizer.cpp文件里,如下:

upload successful

方法revoke_and_rebias为偏向锁的获取和撤销相关代码,它在jdk10-b09e56145e11\src\hotspot\share\runtime\biasedLocking.cpp文件里,相关代码量较多,这儿就不过多展示了。

偏向锁获取的相关逻辑如下:

  1. 通过markOop mark = obj->mark()获取对象的markOop数据mark,即对象头的Mark Word;
  2. 判断mark是否为可偏向状态,即mark的偏向锁标志位为 1,锁标志位为 01;
  3. 判断mark中JavaThread的状态:如果为空,则进入4;如果指向当前线程,则执行同步代码块;如果指向其它线程,进入5;
  4. 通过CAS原子指令设置mark中JavaThread为当前线程ID,如果执行CAS成功,则执行同步代码块,否则进入5;
  5. 如果执行CAS失败,表示当前存在多个线程竞争锁,当达到全局安全点(safepoint),获得偏向锁的线程被挂起,撤销偏向锁,并升级为轻量级,升级完成后被阻塞在安全点的线程继续执行同步代码块;

偏向锁的撤销

偏向锁的撤销必须等待全局安全点(没有正在执行的字节码)时执行。revoke_at_safepoint是偏向锁撤销相关代码,它也在它在jdk10-b09e56145e11\src\hotspot\share\runtime\biasedLocking.cpp文件里。

upload successful

偏向锁的撤销逻辑和偏向获取和撤销次数有关系,会走不同的逻辑,其相关逻辑如下:

  1. 偏向锁的撤销动作必须等待全局安全点;
  2. 暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态;
  3. 撤销偏向锁,恢复到无锁(标志位为 01)或轻量级锁(标志位为 00)的状态;

轻量锁的获取

当偏向锁处于关闭状态,或者多个线程竞争导致偏向锁升级为轻量锁,程序或尝试获取轻量锁。

其代码入口为jdk10-b09e56145e11\src\hotspot\share\runtime\synchronizer.cpp文件里的ObjectSynchronizer::slow_enter方法。

upload successful

轻量锁的获取相关逻辑如下:

  1. markOop mark = obj->mark()方法获取对象的markOop数据mark;
  2. mark->is_neutral()方法判断mark是否为无锁状态:mark的偏向锁标志位为 0,锁标志位为 01;
  3. 如果mark处于无锁状态,则进入4,否则执行6;
  4. 把mark保存到BasicLock对象的_displaced_header字段;
  5. 通过CAS尝试将Mark Word更新为指向BasicLock对象的指针,如果更新成功,表示竞争到锁,则执行同步代码,否则执行6;
  6. 如果当前mark处于加锁状态,且mark中的ptr指针指向当前线程的栈帧,则执行同步代码,否则说明有多个线程竞争轻量级锁,轻量级锁需要膨胀升级为重量级锁;

假设线程A和B同时执行到临界区if (mark->is_neutral()):

  1. 线程AB都把Mark Word复制到各自的_displaced_header字段,该数据保存在线程的栈帧上,是线程私有的;
  2. Atomic::cmpxchg_ptr原子操作保证只有一个线程可以把指向栈帧的指针复制到Mark Word,假设此时线程A执行成功,并返回继续执行同步代码块;
  3. 线程B执行失败,退出临界区,通过ObjectSynchronizer::inflate方法开始膨胀锁;

轻量锁的释放

轻量锁的释放入口为jdk10-b09e56145e11\src\hotspot\share\runtime\synchronizer.cpp文件里的ObjectSynchronizer::slow_exit方法,它最终调用的为该文件里的ObjectSynchronizer::fast_exit方法。

upload successful

轻量锁的释放相关逻辑如下:

  1. 确保处于偏向锁状态时不会执行这段逻辑;
  2. 取出在获取轻量级锁时保存在BasicLock对象的mark数据dhw;
  3. 通过CAS尝试把dhw替换到当前的Mark Word,如果CAS成功,说明成功的释放了锁,否则执行4;
  4. 如果CAS失败,说明有其它线程在尝试获取该锁,这时需要将该锁升级为重量级锁,并释放;

重量锁

重量级锁通过对象内部的monitor实现,其中monitor的本质是依赖于底层操作系统的Mutex Lock实现,操作系统实现线程之间的切换需要从用户态到内核态的切换,切换成本非常高。

锁膨胀

一般情况下重量级锁都是由轻量锁膨胀来的,其方法实现为jdk10-b09e56145e11\src\hotspot\share\runtime\synchronizer.cpp文件的ObjectSynchronizer::inflate方法。

如下图:

upload successful

膨胀过程的相关逻辑如下:

  1. 整个膨胀过程在自旋下完成;
  2. mark->has_monitor()方法判断当前是否为重量级锁,即Mark Word的锁标识位为 10,如果当前状态为重量级锁,执行3,否则执行4;
  3. mark->monitor()方法获取指向ObjectMonitor的指针,并返回,说明膨胀过程已经完成;
  4. 如果当前锁处于膨胀中,说明该锁正在被其它线程执行膨胀操作,则当前线程就进行自旋等待锁膨胀完成,这里需要注意一点,虽然是自旋操作,但不会一直占用cpu资源,每隔一段时间会通过os::NakedYield方法放弃cpu资源,或通过park方法挂起;如果其他线程完成锁的膨胀操作,则退出自旋并返回;
  5. 如果当前是轻量级锁状态,即锁标识位为 00,膨胀过程如下:
    • 通过omAlloc方法,获取一个可用的ObjectMonitor monitor,并重置monitor数据;
    • 通过CAS尝试将Mark Word设置为markOopDesc:INFLATING,标识当前锁正在膨胀中,如果CAS失败,说明同一时刻其它线程已经将Mark Word设置为markOopDesc:INFLATING,当前线程进行自旋等待膨胀完成;
    • 如果CAS成功,设置monitor的各个字段:_header、_owner和_object等,并返回;
  6. 如果是无锁,重置monitor值;

重量锁的获取与释放

重量锁的获取包括锁的竞争、等待锁的释放与尝试获取步骤。

当锁膨胀完成并返回对应的monitor时,并不表示该线程竞争到了锁,真正的锁竞争发生在jdk10-b09e56145e11\src\hotspot\share\runtime\objectMonitor.cpp文件里的ObjectMonitor::enter方法里。

monitor竞争失败的线程,通过自旋执行ObjectMonitor::EnterI方法等待锁的释放。当该线程被唤醒时,会从挂起的点继续执行,通过ObjectMonitor::TryLock尝试获取锁。

当某个持有锁的线程执行完同步代码块时,会进行锁的释放,给其它线程机会执行同步代码,在HotSpot中,通过退出monitor的方式实现锁的释放,并通知被阻塞的线程,具体实现位于ObjectMonitor::exit方法中。

upload successful

其具体逻辑涉及到大量C++代码,这儿就不在对代码进行过多分析。

这儿总结下重量锁的竞争逻辑大致如下(ObjectMonitor::enter方法相关逻辑):

  1. 通过CAS尝试把monitor的_owner字段设置为当前线程;
  2. 如果设置之前的_owner指向当前线程,说明当前线程再次进入monitor,即重入锁,执行_recursions ++ ,记录重入的次数;
  3. 如果之前的_owner指向的地址在当前线程中,这种描述有点拗口,换一种说法:之前_owner指向的BasicLock在当前线程栈上,说明当前线程是第一次进入该monitor,设置_recursions为1,_owner为当前线程,该线程成功获得锁并返回;
  4. 如果获取锁失败,则等待锁的释放;

等待锁释放的逻辑大致如下(ObjectMonitor::EnterI方法相关逻辑):

  1. 当前线程被封装成ObjectWaiter对象node,状态设置成ObjectWaiter::TS_CXQ;
  2. 在for循环中,通过CAS把node节点push到_cxq列表中,同一时刻可能有多个线程把自己的node节点push到_cxq列表中;
  3. node节点push到_cxq列表之后,通过自旋尝试获取锁,如果还是没有获取到锁,则通过park将当前线程挂起,等待被唤醒;
  4. 当该线程被唤醒时,会从挂起的点继续执行,通过ObjectMonitor::TryLock尝试获取锁;
  5. ObjectMonitor::TryLock方法本质就是通过CAS设置monitor的_owner字段为当前线程,如果CAS成功,则表示该线程获取了锁,跳出自旋操作,执行同步代码,否则继续被挂起;

重量锁的释放相关逻辑大致如下(ObjectMonitor::exit方法的相关逻辑):

  1. 如果是重量级锁的释放,monitor中的_owner指向当前线程,即THREAD == _owner;
  2. 根据不同的策略(由QMode指定),从cxq或EntryList中获取头节点,通过ObjectMonitor::ExitEpilog方法唤醒该节点封装的线程,唤醒操作最终由unpark完成;
  3. 被唤醒的线程,继续执行monitor的竞争;

锁的升级情况

锁的升级是单向的:无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁

有位笔者大神给出了锁的变换关系相关流程图,如下:

upload successful

总结

本文主要介绍了synchronized的特点和实现,了解了偏向锁、轻量锁、重量锁、锁撤销和锁膨胀的一些原理。对我们更好的理解Java锁机制提供了一些帮助。

参考文档

  1. Java Synchronised机制
  2. jdk源码剖析二: 对象内存布局、synchronized终极原理
  3. Native+Monitors+Design
  4. OpenJDK projects



-------------文章结束啦 ~\(≧▽≦)/~ 感谢您的阅读-------------

您的支持就是我创作的动力!

欢迎关注我的其它发布渠道