Java 高并发与多线程;:synchronized 关键字的实现原理

synchronzied 关键字应该是 Java 并发编程中最重要的内容了,甚至没有之一。在 JDK6 以前,synchronized 关键字还代表着一把重量级锁,因此在 JUC 包里还推出了 Lock 类来替代 synchronized,不过 JDK6 以后的 synchronized 经过优化,引入了偏向锁、轻量级锁和重量级锁的概念,从效率层面来说已经和 Lock 类不相上下了(当然 Lock 类有公平锁非公平锁和定时功能等)。本文将从内存对象、字节码和 JVM 层面去剖析 synchronized 关键字

1. synchronized 在内存对象中的表现

上一篇文章中我们介绍了一个 JOL 工具,并且用这个工具解释了对象头中的内容。本节中我们将验证不同锁级别对对象头的操作。在这里我们先回顾一下前一篇文章中的表格

锁状态 56 bit 1 bit 4 bit 1 bit 2 bit
无锁 31 bit 未用 25 bit hash 未用 分代年龄 是否偏向锁 01
偏向锁 54 bit 线程ID 2 bit Epoch 未用 分代年龄 是否偏向锁 01
轻量级锁 指向栈中锁记录的指针 00
重量级锁 指向系统互斥量Mutex的指针 (Linux实现) 10
GC标记 11

1.1 偏向锁状态下的对象头

偏向锁是最轻量级的锁,是第一个申请锁的线程获得的锁类型。它的特点为在不存在锁竞争的情况下,持有锁的线程不需要重复获取锁资源。这就像小明同学在图书馆占座,拿了本书往桌子上一放就代表占了个位置,那么在没有其它同学觊觎这个位子的时候,小明同学离开位置再回来的时候并不需要重新考虑这个位置是不是有人用了,直接坐下就好。

在 Java 中,偏向锁的获取就是这么一个过程,我们用 JOL 看一下。

首先我们需要设置 JVM 参数

-XX:BiasedLockingStartupDelay=0

这个设置原因是 JVM 在启动的时候,会启动很多带有 synchronized 关键字的线程,JVM 非常明确地知道这些线程之间一定会存在锁竞争的情况。在启动的如果还是使用偏向锁的话,会导致许多锁膨胀的情况,因此 JVM 会直接使用轻量级锁,然后再恢复偏向锁的使用(默认 5s)。

代码如下:

 
import org.openjdk.jol.info.ClassLayout; public class Test { public static void main(String[] args) { Object o = new Object(); System.out.println(ClassLayout.parseInstance(o).toPrintable()); synchronized (o) { System.out.println(ClassLayout.parseInstance(o).toPrintable()); } } } 

输出为

 
java.lang.Object object internals: OFFSET SIZE TYPE DESCRIPTION VALUE 0 4 (object header) 05 00 00 00 (00000101 00000000 00000000 00000000) (5) 4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0) 8 4 (object header) e5 01 00 20 (11100101 00000001 00000000 00100000) (536871397) 12 4 (loss due to the next object alignment) Instance size: 16 bytes Space losses: 0 bytes internal + 4 bytes external = 4 bytes total java.lang.Object object internals: OFFSET SIZE TYPE DESCRIPTION VALUE 0 4 (object header) 05 e8 ea 00 (00000101 11101000 11101010 00000000) (15394821) 4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0) 8 4 (object header) e5 01 00 20 (11100101 00000001 00000000 00100000) (536871397) 12 4 (loss due to the next object alignment) Instance size: 16 bytes Space losses: 0 bytes internal + 4 bytes external = 4 bytes total 

我们可以看到,在 synchronized 锁定对象后输出的对象头内容,第一组 bit 的最后三位变成了 101,并且其它位也有变化(其实就是线程 ID),表示此时该线程获取到了偏向锁。

当然肯定有同学也发现了一些异象,就是第一次输出对象头中也有 101 的情况但线程 ID 为 0。这里其实是 JVM 的一种优化,叫做匿名偏向锁,即对象创建了就默认为偏向锁状态,但是并没有线程 ID 写入,此时第一个来请求锁的线程只需要把自己的线程 ID 写入就好了。

1.2 轻量级锁状态下的对象头

轻量级锁也叫自旋锁,当偏向锁遇到竞争情况的时候,对象的锁级别便会升级为轻量级锁。

首先,为了排除 JVM 偏向锁延迟生效而产生轻量级锁情况,我们还是设置 JVM 参数:

-XX:BiasedLockingStartupDelay=0

同时,我们需要创建一个轻微竞争的场景,避免锁膨胀为重量级锁。

 
import org.openjdk.jol.info.ClassLayout; public class Test { public static void main(String[] args) { Object o = new Object(); Thread t1 = new Thread(() -> { synchronized (o) { // The body has to be easy enough  // Printing object header will raise competition level for lock  } }); Thread t2 = new Thread(() -> { synchronized (o) { System.out.println(ClassLayout.parseInstance(o).toPrintable()); } }); t1.start(); t2.start(); } } 

输出为:

 
java.lang.Object object internals: OFFSET SIZE TYPE DESCRIPTION VALUE 0 4 (object header) 48 f7 19 1b (01001000 11110111 00011001 00011011) (454686536) 4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0) 8 4 (object header) e5 01 00 20 (11100101 00000001 00000000 00100000) (536871397) 12 4 (loss due to the next object alignment) Instance size: 16 bytes Space losses: 0 bytes internal + 4 bytes external = 4 bytes total 

可以看到,输出中第一组 bit 的最后三位是 000,查表可知现在锁状态为轻量级锁。其余 56bit,是一个指向自己线程栈帧内一个叫 Lock Record 的记录的指针。当一个线程去获取轻量级锁的时候,主要会做以下几步:

  1. 在线程内创建一个叫 Lock Record 的空间,并且将对象的 Mark Word 复制到这块空间内,被称为 Displaced Mark Word。

  2. 使用 CAS 操作,尝试将对象原 Mark Word 替换为指向自己栈帧内 Lock Record 的指针。(这也是被称为自旋锁的原因)

  3. 如果替换成功,则获取到了对象的轻量级锁。

1.3 重量级锁状态下的对象头

当同步资源竞争加剧的时候,轻量级锁会膨胀(inflate)为重量级锁,申请重量级锁的线程首先会阻塞并释放 CPU 资源,直到获取到锁之后再重新运行。

至于什么时候属于竞争加剧,有以下两个条件,达成其一即可:

  1. 轻量级锁超过 10 次自旋,这个也可以通过-XX:PreLockSpin 来指定。在 JDK6 以后,有自适应自旋(Adaptive Self Spin)来控制。

  2. 自旋线程数超过 CPU 核数的一半。

重量级锁很容易复现,只要 synchronized 块中代码运行足够的时间即可

 
import org.openjdk.jol.info.ClassLayout; public class Test { public static void main(String[] args) { Object o = new Object(); Thread t1 = new Thread(() -> { synchronized (o) { try { Thread.sleep(10L); } catch (InterruptedException e) { e.printStackTrace(); } } }); Thread t2 = new Thread(() -> { synchronized (o) { System.out.println(ClassLayout.parseInstance(o).toPrintable()); } }); t1.start(); t2.start(); } } 

输出为:

 
java.lang.Object object internals: OFFSET SIZE TYPE DESCRIPTION VALUE 0 4 (object header) 2a f5 f5 17 (00101010 11110101 11110101 00010111) (401995050) 4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0) 8 4 (object header) e5 01 00 20 (11100101 00000001 00000000 00100000) (536871397) 12 4 (loss due to the next object alignment) Instance size: 16 bytes Space losses: 0 bytes internal + 4 bytes external = 4 bytes total 

第一组 bit 值的最后三位变成了 010,即当前对象锁等级为重量级锁。而其余的 bit 值部分,则是指向系统互斥量(mutex)的指针。

这里的互斥量(Mutex),可以简单理解为一把系统级别的锁,需要程序向操作系统申请。因此互斥量的资源十分有限,每次申请都需要在系统内的一个队列中排队等待。这就是重量级锁效率较低的原因。

2. synchronized 关键字在字节码中的表现

Java 源码在经过 javac 的编译后变为字节码文件,然后虚拟机解释执行字节码文件。对于 synchronized 关键字来说,此时虚拟机还不知道对象的锁级别,因此编译后的字节码文件应该是一样的。我们来看一下如下代码的字节码文件:

 
public class Test { public static void main(String[] args) { Object o = new Object(); synchronized (o) { } } } 

读者可以使用 IDEA 的 Bytecode Viewer 工具或者用如下命令:

javap -c -p Test.class

字节码输出如下:

 
Compiled from "Test.java" public class Test { public Test(); Code: 0: aload_0 1: invokespecial #1 // Method java/lang/Object."<init>":()V 4: return public static void main(java.lang.String[]); Code: 0: new #2 // class java/lang/Object 3: dup 4: invokespecial #1 // Method java/lang/Object."<init>":()V 7: astore_1 8: aload_1 9: dup 10: astore_2 11: monitorenter 12: aload_2 13: monitorexit 14: goto 22 17: astore_3 18: aload_2 19: monitorexit 20: aload_3 21: athrow 22: return Exception table: from to target type 12 14 17 any 17 20 17 any } 

我们可以看到,字节码中第 11 行的 monitorenter 就是进入同步块的指令,13 行的 monitorexit 就是退出同步块的指令。至于 19 行的 monitorexit,笔者猜测是同步块中抛异常后的退出指令(错了不要打我)。

3. synchronized 关键字在 JVM 中的执行过程

本节将深入到 JVM 的 C++ 源码中探究 synchronized 关键字是如何执行的。(当然笔者能力有限,也深不到哪去。。。笑哭脸.jpg)

3.1 进入同步块

我们的探究起点还是 monitorenter 指令,在 interpreterRuntime.cpp 文件中,有一个 monitorenter 函数,我截取了最关键的部分,是一个 if-else 判断,代码如下:

 
 if (UseBiasedLocking) { // Retry fast entry if bias is revoked to avoid unnecessary inflation  ObjectSynchronizer::fast_enter(h_obj, elem->lock(), true, CHECK); } else { ObjectSynchronizer::slow_enter(h_obj, elem->lock(), CHECK); } 

可以看出,在使用偏向锁的情况下,JVM 执行 fast_enter 函数,否则执行 slow_enter 函数。我们先从 fast_enter 入手,在 synchronizer.cpp 文件中可以找到对应函数。

 
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 { assert(!attempt_rebias, "can not rebias toward VM thread"); BiasedLocking::revoke_at_safepoint(obj); } assert(!obj->mark()->has_bias_pattern(), "biases should be revoked by now"); } slow_enter (obj, lock, THREAD) ; } 

这里面我们也只需要关注两部分,首先是 return 前的 revoke_and_rebias 函数。这个函数内容很长,在此不贴出来了,大意为在某些条件下可以成功设置偏向锁。在里面有很多之前提到过的操作,比如 CAS 操作和修改对象头等,有兴趣的同学可以查看 biasedLocking.cpp 文件。如果这个函数返回值为 BIAS_REVOKED_AND_REBIASED,则直接返回,否则将调用 slow_enter 函数。

那么 slow_enter 又做了什么呢?我们继续看

 
// ----------------------------------------------------------------------------- // Interpreter/Compiler Slow Case // This routine is used to handle interpreter/compiler slow case // We don't need to use fast path here, because it must have been // failed in the interpreter/compiler code. void ObjectSynchronizer::slow_enter(Handle obj, BasicLock* lock, TRAPS) { markOop mark = obj->mark(); assert(!mark->has_bias_pattern(), "should not see bias pattern here"); if (mark->is_neutral()) { // Anticipate successful CAS -- the ST of the displaced mark must  // be visible <= the ST performed by the CAS.  lock->set_displaced_header(mark); if (mark == (markOop) Atomic::cmpxchg_ptr(lock, obj()->mark_addr(), mark)) { TEVENT (slow_enter: release stacklock) ; return ; } // Fall through to inflate() ...  } else if (mark->has_locker() && THREAD->is_lock_owned((address)mark->locker())) { assert(lock != mark->locker(), "must not re-lock the same lock"); assert(lock != (BasicLock*)obj->mark(), "don't relock with same BasicLock"); lock->set_displaced_header(NULL); return; } // The object header will never be displaced to this lock,  // so it does not matter what the value is, except that it  // must be non-zero to avoid looking like a re-entrant lock,  // and must not look locked either.  lock->set_displaced_header(markOopDesc::unused_mark()); ObjectSynchronizer::inflate(THREAD, obj())->enter(THREAD); } 

记住,我们如果进行到 slow_enter 函数,说明我们没有使用偏向锁或者在 fast_enter 设置偏向锁失败后,因此此时应该使用轻量级锁。所以在上面代码中,我们可以找到一些用 CAS 操作设置对象头等。如果以上操作都没有成功的话,那代码的最后一行便提示了我们,轻量级锁膨胀(inflate)为重量级锁。

3.2 退出同步块

我们还是回到 interpreterRuntime.cpp 中,找到 monitorexit 函数。

 
//%note monitor_1 IRT_ENTRY_NO_ASYNC(void, InterpreterRuntime::monitorexit(JavaThread* thread, BasicObjectLock* elem)) #ifdef ASSERT  thread->last_frame().interpreter_frame_verify_monitor(elem); #endif  Handle h_obj(thread, elem->obj()); assert(Universe::heap()->is_in_reserved_or_null(h_obj()), "must be NULL or an object"); if (elem == NULL || h_obj()->is_unlocked()) { THROW(vmSymbols::java_lang_IllegalMonitorStateException()); } ObjectSynchronizer::slow_exit(h_obj(), elem->lock(), thread); // Free entry. This must be done here, since a pending exception might be installed on  // exit. If it is not cleared, the exception handling code will try to unlock the monitor again.  elem->set_obj(NULL); #ifdef ASSERT  thread->last_frame().interpreter_frame_verify_monitor(elem); #endif IRT_END 

这里面核心的内容是在中间部分,调用了 slow_exit 函数

 
void ObjectSynchronizer::slow_exit(oop object, BasicLock* lock, TRAPS) { fast_exit (object, lock, THREAD) ; } 

slow_exit 本质是调用 fast_exit 函数。

 
void ObjectSynchronizer::fast_exit(oop object, BasicLock* lock, TRAPS) { assert(!object->mark()->has_bias_pattern(), "should not see bias pattern here"); // if displaced header is null, the previous enter is recursive enter, no-op  markOop dhw = lock->displaced_header(); markOop mark ; if (dhw == NULL) { // Recursive stack-lock.  // Diagnostics -- Could be: stack-locked, inflating, inflated.  mark = object->mark() ; assert (!mark->is_neutral(), "invariant") ; if (mark->has_locker() && mark != markOopDesc::INFLATING()) { assert(THREAD->is_lock_owned((address)mark->locker()), "invariant") ; } if (mark->has_monitor()) { ObjectMonitor * m = mark->monitor() ; assert(((oop)(m->object()))->mark() == mark, "invariant") ; assert(m->is_entered(THREAD), "invariant") ; } return ; } mark = object->mark() ; // If the object is stack-locked by the current thread, try to  // swing the displaced header from the box back to the mark.  if (mark == (markOop) lock) { assert (dhw->is_neutral(), "invariant") ; if ((markOop) Atomic::cmpxchg_ptr (dhw, object->mark_addr(), mark) == mark) { TEVENT (fast_exit: release stacklock) ; return; } } ObjectSynchronizer::inflate(THREAD, object)->exit (true, THREAD) ; } 

这段代码关键在两个 if 块内。第一个 if 块的情况为 Displaced Mark Word 为空,说明是偏向锁,则做一些简单验证操作就可以返回。第二个 if 块内为把 Displaced Mark Word 写回对象头,如果成功就返回。否则说明有竞争存在,将锁膨胀为重量级锁然后退出。

4. 总结一下

  1. synchronized 关键字会修改被锁对象的对象头
  2. 锁级别从低到高为:偏向锁 -> 轻量级锁 -> 重量级锁。偏向锁适用于无竞争时候的对象同步,一旦发生竞争,则先升级为轻量级锁。如果竞争加剧,则再升级为重量级锁。
  3. synchronized 关键字在字节码层面的体现为 monitorenter 和 monitorexit
  4. JVM 层面,synchronized 关键字主要由 fast_enter、slow_enter、fast_exit 和 inflate 几个方法支撑。而这几个方法,是由 cmpxchg 函数支撑的。

 

链接:https://hacpai.com/article/1582982506027
来源:黑客派
协议:CC BY-SA 4.0 https://creativecommons.org/licenses/by-sa/4.0/

 

上一篇:运算符(上)


下一篇:CJson中一个十分有趣的二进制转换