从源码揭秘偏向锁的升级

2023年 7月 16日 70.0k 0

我会和大家一起深入学习synchronized的原理,原理部分会涉及到两篇:

  • 偏向锁升级到轻量级锁的过程
  • 轻量级锁升级到重量级锁的过程

今天我们先来学习偏向锁升级到轻量级锁的过程。因为涉及到大量HotSpot源码,会有单独的一篇注释版源码的文章。

通过本篇文章,你能解答如下问题:

  • 详细描述下synchronized的实现原理(67%)
  • 为什么说synchronized是可重入锁?(67%)
  • 详细描述下synchronized的锁升级(膨胀)过程(67%)
  • 偏向锁是什么?synchronized是怎样实现偏向锁的?(100%)
  • Java 8之后,synchronized做了哪些优化?(50%)

准备工作

正式开始分析synchronized源码前,我们先做一些准备:

  • HotSpot源码准备:Open JDK 11;
  • 字节码工具,推荐jclasslib插件;
  • 用于跟踪对象状态的jol-core包。

Tips:

  • 可以使用javap命令和IDEA自带的字节码工具;
  • jclasslib的优势在于可以直接跳转到相关命令的官方站点。

示例代码

准备一个简单的示例代码:

public class SynchronizedPrinciple {
    private int count = 0;

    private void add() {
        synchronized (this) {
            count++;
        }
    }
}
复制代码

通过工具,我们可以得到如下字节码:

aload_0
dup
astore_1
monitorenter // 1
aload_0
dup
getfield #2 
iconst_1
iadd
putfield #2 
aload_1
monitorexit // 2
goto 24 (+8)
astore_2
aload_1
monitorexit // 3
aload_2
athrow
return
复制代码

synchronized修饰代码块,编译成了两条指令:

  • monitorenter:进入对象的监视器;
  • monitorexit:退出对象的监视器。

我们注意到,monitorexit出现了两次。注释2的部分是程序执行正常,注释3的部分是程序执行异常。Java团队连程序异常的情况都替你考虑到了,他真的,我哭死。

Tips:

  • 使用synchronized修饰代码块作为示例的原因是,修饰方法时仅在access_flag设置ACC_SYNCHRONIZED标志,并不直观;

  • Java并不是只能通过monitorexit退出监视器, Java曾在Unsafe类中提供过进出监视器的方法。

    Unsafe.getUnsafe.monitorEnter(obj);
    Unsafe.getUnsafe.monitorExit(obj);
    复制代码

Java 8可以使用,Java 11已经移除,具体移除的版本我就不太清楚了。

jol使用示例

可以通过jol-core来跟踪对象状态。

Maven依赖:

  
    org.openjdk.jol  
    jol-core  
    0.16  

复制代码

使用示例:

public static void main(String[] args) {
	Object obj = new Object();
	System.out.println(ClassLayout.parseInstance(obj).toPrintable());
}
复制代码

从monitorenter处开始

在HotSpot中,monitorenter指令对应这两大类解析方式:

  • 字节码解释器:bytecodeInterpreter
  • 模板解释器:templateTable_x86#monitorenter

由于bytecodeInterpreter基本退出了历史舞台,我们以模板解释器X86实现templateTable_x86为例。

Tips:

  • 按照惯例,源码只展示关键内容;
  • 推荐杨易老师的《深入解析Java虚拟机HotSpot》。

monitorenter的执行方法是templateTable_x86#monitorenter,该方法中,我们只需要关注4438行执行的__ lock_object(rmon),调用了interp_masm_x86#lock_object方法:

void InterpreterMacroAssembler::lock_object(Register lock_reg) {
	if (UseHeavyMonitors) {// 1
		// 重量级锁逻辑
		call_VM(noreg, CAST_FROM_FN_PTR(address, InterpreterRuntime::monitorenter),  lock_reg);  
} else {
	Label done;
	Label slow_case;
	if (UseBiasedLocking) {// 2
		// 偏向锁逻辑
		biased_locking_enter(lock_reg, obj_reg, swap_reg, tmp_reg, false, done, &slow_case);
	}
	// 3
	bind(slow_case);
	call_VM(noreg,   CAST_FROM_FN_PTR(address, InterpreterRuntime::monitorenter),  lock_reg);
    bind(done);
	......
}
复制代码

注释1和注释2的部分,是两个JVM参数:

// 启用重量级锁
-XX:+UseHeavyMonitors
// 启用偏向锁
-XX:+UseBiasedLocking
复制代码

注释1和注释3,调用
InterpreterRuntime::monitorenter方法,注释1是直接使用重量级锁的配置,那么可以猜到,注释3是获取偏向锁失败锁升级为重量级锁的逻辑。

对象头(markOop)

正式开始前,先来了解对象头(markOop)。实际上,markOop的注释已经揭露了它的“秘密“:

The markOop describes the header of an object. ...... Bit-format of an object header (most significant first, big endian layout below): 64 bits: unused:25 hash:31 -->| unused:1 age:4 biased_lock:1 lock:2 (normal object) JavaThread*:54 epoch:2 unused:1 age:4 biased_lock:1 lock:2 (biased object) ...... [JavaThread* | epoch | age | 1 | 01] lock is biased toward given thread [0 | epoch | age | 1 | 01] lock is anonymously biase

注释详细的描述了64位大端模式下Java对象头的结构:

Tips:

  • 也描述了32位markOop的结构,我没粘出来~~
  • markOop锁标志枚举

对象头中的大部分结构都很容易理解,但epoch是什么?

注释中将epoch描述为“used in support of biased locking”。OpenJDK wiki中Synchronization是这样描述epoch的:

An epoch value in the class acts as a timestamp that indicates the validity of the bias.

epoch类似于时间戳,表示偏向锁的有效性。它的在批量重偏向阶段(biasedLocking#
bulk_revoke_or_rebias_at_safepoint)更新:

static BiasedLocking::Condition bulk_revoke_or_rebias_at_safepoint(oop o, bool bulk_rebias, bool attempt_rebias_of_object, JavaThread* requesting_thread) {
	{
		if (bulk_rebias) {
			if (klass->prototype_header()->has_bias_pattern()) {
				klass->set_prototype_header(klass->prototype_header()->incr_bias_epoch());
			}
		}
	}
}
复制代码

JVM通过epoch来判断是否适合偏向锁,超过阈值后JVM会升级偏向锁。JVM提供了参数来调节这个阈值。

// 批量重偏向阈值
-XX:BiasedLockingBulkRebiasThreshold=20
// 批量撤销阈值
-XX:BiasedLockingBulkRevokeThreshold=40
复制代码

Tips:更新的是klass的epoch。

偏向锁(biasedLocking)

系统开启了偏向锁,会进入macroAssembler_x86#biased_locking_enter方法。该方法首先是获取对象的markOop:

Address mark_addr         (obj_reg, oopDesc::mark_offset_in_bytes());
Address saved_mark_addr(lock_reg, 0);
复制代码

我将接下来的流程分为5个分支,按照执行顺序和大家一起分析偏向锁的实现逻辑。

Tips:

  • 了解偏向锁流程即可,因此以图示为主,源码分析放在偏向锁源码分析中;
  • 偏向锁源码分析以注释为主,详细标注了每个分支;
  • 这部分实际上包含了撤销和重偏向两个跳转标签,分支图示中有说明;
  • 源码使用位掩码技术,为了便于区分,二进制数字用0B开头,并补齐4位。

分支1:是否可偏向?

偏向锁的前置条件,逻辑非常简单,判断当前对象markOop的锁标志,如果已经升级,执行升级流程;否则继续向下执行。

Tips:虚线部分逻辑位于其它类中。

分支2:是否重入偏向?

目前JVM已知markOop的锁标志位为0B0101,处于可偏向状态,但不清楚是已经偏向还是尚未偏向。HotSopt中使用anonymously形容可偏向但尚未偏向某个线程的状态,称这种状态为匿名偏向。此时对象头如下:

此时要做的事情就比较简单了,判断是否为当前线程重入偏向锁。如果是重入,直接退出即可;否则继续向下执行。

Tips:今天刷到一个帖子,Javaer和C++er争论可重入锁和递归锁,有兴趣的可以看一文看懂并发编程中的锁我简单解释了可重入锁和递归锁的关系。

分支3:是否依旧可偏向?

注释描述了不是重入偏向锁的情况:

At this point we know that the header has the bias pattern and that we are not the bias owner in the current epoch. We need to figure out more details about the state of the header in order to know what operations can be legally performed on the object's header.

此时可能存在两种情况:

  • 不存在竞争,重新偏向某个线程;
  • 存在竞争,尝试撤销。

偏向锁撤销的部分稍微复杂,使用对象klass的markOop替换对象的markOop,关键技术是CAS。

分支4:epoch是否过期?

目前偏向锁的状态是可偏向,且偏向其他线程。此时的逻辑只需要片段epoch是否有效即可。

重新偏向的可以用一句话描述,构建markOop进行CAS替换。

分支5:重新偏向

目前偏向锁的状态是,可偏向,偏向其它线程,epoch未过期。此时要做的是在markOop中设置当前线程,也就是偏向锁重新偏向的过程,和分支4的部分非常相似。

撤销和重偏向

获取偏向锁失败后,执行
InterpreterRuntime::monitorenter方法,位于interpreterRuntime中:

IRT_ENTRY_NO_ASYNC(void, InterpreterRuntime::monitorenter(JavaThread* thread, BasicObjectLock* elem))
	if (UseBiasedLocking) {
		// 完整的锁升级路径
		// 偏向锁->轻量级锁->重量级锁
	  ObjectSynchronizer::fast_enter(h_obj, elem->lock(), true, CHECK);
	} else {
		// 跳过偏向锁的锁升级路径
		// 轻量级锁->重量级锁
		ObjectSynchronizer::slow_enter(h_obj, elem->lock(), CHECK);
	}
IRT_END
复制代码

ObjectSynchronizer::fast_enter位于synchronizer.cpp#fast_enter:

void ObjectSynchronizer::fast_enter(Handle obj, BasicLock* lock, bool attempt_rebias, TRAPS) {
	if (UseBiasedLocking) {
		if (!SafepointSynchronize::is_at_safepoint()) {
			// 撤销和重偏向
			BiasedLocking::Condition cond = BiasedLocking::revoke_and_rebias(obj,  attempt_rebias, THREAD);
			if (cond == BiasedLocking::BIAS_REVOKED_AND_REBIASED) {
				return;
			}
		} else {
			BiasedLocking::revoke_at_safepoint(obj);
		}
	}
	// 跳过偏向锁
	slow_enter(obj, lock, THREAD);
}
复制代码

BiasedLocking::revoke_and_rebias的精简注释版放在了偏向锁源码分析的第2部分。

轻量级锁(basicLock)

如果获取偏向锁失败,此时会执行
ObjectSynchronizer::slow_enter,该方法位于synchronizer#slow_enter:

void ObjectSynchronizer::slow_enter(Handle obj, BasicLock* lock, TRAPS) {
	markOop mark = obj->mark();
	// 无锁状态 ,获取偏向锁失败后有撤销逻辑,此时变为无锁状态
	if (mark->is_neutral()) {
		// 将对象的markOop复制到displaced_header(Displaced Mark Word)上
		lock->set_displaced_header(mark);
		// CAS将对象markOop中替换为指向锁记录的指针
		if (mark == obj()->cas_set_mark((markOop) lock, mark)) {
			// 替换成功,则获取轻量级锁
			TEVENT(slow_enter: release stacklock);
			return;
		}
	} else if (mark->has_locker() && THREAD->is_lock_owned((address)mark->locker())) {  
	    //  重入情况
	    lock->set_displaced_header(NULL);
	    return;
	}
	
	// 重置displaced_header(Displaced Mark Word)
	lock->set_displaced_header(markOopDesc::unused_mark());
	// 锁膨胀
	ObjectSynchronizer::inflate(THREAD, obj(), inflate_cause_monitor_enter)->enter(THREAD);  
}
复制代码

直接引用《Java并发编程的艺术》中关于轻量级锁加锁的过程:

线程在执行同步块之前,JVM会先在当前线程的栈桢中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中,官方称为Displaced Mark Word。然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。

轻量级锁的逻辑非常简单,使用到的关键技术也是CAS。

此时markOop的结构如下:

在monitorexit处结束

处于偏向锁或者轻量级锁时,monitorexit的逻辑非常简单。有了monitorenter的经验,我们很容易分析到monitorexit的调用逻辑:

  • templateTable_x86#monitorexit
  • interp_masm_x86#un_lock
  • 锁的退出逻辑 偏向锁:macroAssembler_x86#biased_locking_exit 轻量级锁:interpreterRuntime#monitorexit ObjectSynchronizer#slow_exit ObjectSynchronizer#fast_exit
  • 代码就留给大家自行探索了,在这里给出我的理解。

    通常,我会简单的认为偏向锁退出时,什么都不需要做(即偏向锁不会主动释放);而对于轻量级锁来说,至少需要经历两个步骤:

    • 重置displaced_header;
    • 释放锁记录。

    因此,从退出逻辑上来说,轻量级锁的性能是稍逊于偏向锁的。

    总结

    我们对这一阶段的内容做个简单的总结,偏向锁和轻量级锁的逻辑并不复杂,尤其是轻量级锁。

    偏向锁和轻量级锁的关键技术都是CAS,当CAS竞争失败,说明有其它线程尝试抢夺,从而导致锁升级。

    偏向锁在对象markOop中记录第一次持有它的线程,当该线程不断持有偏向锁时,只需要简单的比对即可,适合绝大部分场景是单线程执行,但偶尔可能会存在线程竞争的场景。

    但问题是,如果线程交替持有执行,偏向锁的撤销和重偏向逻辑复杂,性能差。因此引入了轻量级锁,用来保证交替进行这种“轻微”竞争情况的安全。

    另外,关于偏向锁的争议比较多,主要在两点:

    • 偏向锁的撤销对性能影响较大;
    • 大量并发时,偏向锁非常鸡肋。

    实际上,Java 15中已经放弃了偏向锁(JEP 374: Deprecate and Disable Biased Locking),但由于大部分应用还跑在Java 8上,我们还是要了解偏向锁的逻辑。

    最后再辟个谣(或者是被打脸?),轻量级锁中并没有任何自旋的逻辑。

    Tips:好像漏掉了批量撤销和批量重偏向~~

    相关文章

    JavaScript2024新功能:Object.groupBy、正则表达式v标志
    PHP trim 函数对多字节字符的使用和限制
    新函数 json_validate() 、randomizer 类扩展…20 个PHP 8.3 新特性全面解析
    使用HTMX为WordPress增效:如何在不使用复杂框架的情况下增强平台功能
    为React 19做准备:WordPress 6.6用户指南
    如何删除WordPress中的所有评论

    发布评论