『深入学习Java』(六) synchronized 为什么这么厉害?

203 阅读8分钟

『深入学习Java』(六) synchronized 为什么这么厉害?

前言

前几篇我们总结了 java 中线程与锁一些基础知识与常见用法。这一节我们稍微深入一些,学习 synchronized 的底层原理。

synchronized 为什么这么有用?

写代码时,我们只需要在合适的地方加上 synchronized 关键字,就可以避免并发问题。

那么 synchronized 怎么如此神奇的作用呢?这是因为 java 在 JVM 层面对 synchronized 做了一些处理。

Java 编程语言为线程之间的通信提供了多种机制。这些方法中最基本的是同步(synchronization),它是使用监视器(monitors)实现的。

Java 中的每个对象都与一个监视器相关联,线程可以锁定或解锁监视器。一次只有一个线程可以锁定监视器。任何其他试图锁定该监视器的线程都会被阻塞,直到它们能够获得监视器的锁。

一个线程可能会多次锁定一个特定的监视器;每次解锁都会反转一次锁定操作的效果。

synchronized语句会尝试在该对象的监视器上执行锁定操作。执行完锁定操作后,将执行 synchronized 的代码块。如果代码块执行完成,同一个监视器上自动执行解锁操作。

另外 java 中还其他机制提供了替代的同步方式,例如volatile变量的读取和写入以及 JUC 包。这个我们后边再继续学习。

每个对象除了有一个关联的监视器之外,还有一个关联的等待集(wait set)。等待集是一组线程。

创建对象时,其等待集为空。将线程添加到等待集中和从等待集中删除线程是原子操作。等待集仅通过方法 wait/notify/notifyAll 操作。

等待集操作也可能受到线程中断状态和 Thread处理中断的类方法的影响。

Monitor

Monitor 一般被翻译为监视器或管程。

Java 中的每个对象都可以与一个监视器相关联,如果使用 synchronized 给对象加了重量级锁之后,该对象头的 Mark Word 中就被设置指向 Monitor 对象的指针。

 public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=3, args_size=1
         0: getstatic     #2                  // Field lock:Ljava/lang/Object;
         3: dup
         4: astore_1
         5: monitorenter
         6: getstatic     #3                  // Field i:I
         9: iconst_1
        10: iadd
        11: putstatic     #3                  // Field i:I
        14: aload_1
        15: monitorexit
        16: goto          24
        19: astore_2
        20: aload_1
        21: monitorexit
        22: aload_2
        23: athrow
        24: return
.....

我们可以看到 monitor 指令,monitorenter/monitorexit,用于控制临界区代码。

Monitor 的大致结构如下图,我们通过5个线程(thread1-5)竞争一个锁,来描述 Monitor 中的各部分。

monitor 示例

  • Owner Monitor 同一时刻只能有一个 Owner。thread 3 执行 synchronized(lock) 时,会将 Owner 设置为 thread3 的 id。
  • EntyList 当 thread3 加锁运行的过程中,thread4、5也运行到 synchronized(lock) 时,Monitor 的 Owner 已被设置为 thread3。此时,thread4、5 就会进入 EntryList 中,处于 BLOCKED 状态。 当 thread3 运行完 synchronized 代码块后,就唤醒 EntryList 中等待的线程来竞争锁。
  • Wait Set Wait Set 是获得过锁,然后在运行代码块过程中调用了 wait 方法,线程进入WAITING状态。 只有当调用 lock.notify/notityAll 方法时,才会进入 EntryList 重新竞争锁。

Java 对象头

上面我们提到,如果使用 synchronized 给对象加了重量级锁之后,该对象头的 Mark Word 中就被设置指向 Monitor 对象的指针。

下面是从 github 借鉴的 java 对象头图,gist.github.com/arturmkrtch…

 Klass Word 我们先不关注,只关注 Mark Word 部分。

|------------------------------------------------------------------------------------------------------------|--------------------|
|                                            Object Header (128 bits)                                        |        State       |
|------------------------------------------------------------------------------|-----------------------------|--------------------|
|                                  Mark Word (64 bits)                         |    Klass Word (64 bits)     |                    |
|------------------------------------------------------------------------------|-----------------------------|--------------------|
| unused:25 | identity_hashcode:31 | unused:1 | age:4 | biased_lock:1 | lock:2 |    OOP to metadata object   |    正常            |
|------------------------------------------------------------------------------|-----------------------------|--------------------|
| thread:54 |       epoch:2        | unused:1 | age:4 | biased_lock:1 | lock:2 |    OOP to metadata object   |    偏向锁          |
|------------------------------------------------------------------------------|-----------------------------|--------------------|
|                       ptr_to_lock_record:62                         | lock:2 |    OOP to metadata object   |    轻量级锁        |
|------------------------------------------------------------------------------|-----------------------------|--------------------|
|                     ptr_to_heavyweight_monitor:62                   | lock:2 |    OOP to metadata object   |    重量级锁        |
|------------------------------------------------------------------------------|-----------------------------|--------------------|
|                                                                     | lock:2 |    OOP to metadata object   |    GC 标记         |
|------------------------------------------------------------------------------|-----------------------------|--------------------|
字段名位数含义备注
identity_hashcode3131位的对象标识hashCode
age4对象的分代年龄对象在Survivor区复制一次,年龄增加1。
biased_lock1偏向锁标记。0:启用;1:未启用。
lock2锁状态标记位。biased_lock=0 & lock= 01:无锁;
biased_lock=1 & lock= 01:偏向锁;
lock = 00:轻量级锁;
lock = 10 : 重量级锁;
lock = 11:GC标志。
thread54持有偏向锁的线程ID。
epoch2偏向锁的时间戳。
ptr_to_lock_record62轻量级锁状态下,
指向栈中锁记录的指针。
ptr_to_heavyweight_monitor6重量级锁状态下,
指向对象监视器Monitor的指针。

我们可以看到,对象大致可以分有锁无锁状态(biased_lock:1 bits),其中有锁时,又分为偏向锁、轻量级锁、重量级锁。只有重量级锁才关联 Monitor。

通过 jol 工具包,我们可以观测到对象头的具体信息。

无锁

public class JOLSample_01_noLock {
    public static void main(String[] args){
        Object o = new Object();
        out.println(ClassLayout.parseInstance(o).toPrintable());
    }
}

image

我们看到,前两行出来的就是 Mark Word 部分,由于 JVM 采用了小段存储。

 我们将 VALUE 重新编排后得到:

Mark Word:
hashcode (31bit): 0000000 00000000 00000000 00000000 
age (4bit): 0000
biasedLockFlag (1bit): 0
LockFlag (2bit): 01

由于我们首要关注锁机制,所以我们直接取最后三位001出来看。

根据我们上面梳理的表格,biased_lock=0 并且 lock= 01 为无锁状态。

偏向锁

偏向锁,顾名思义就是总是偏向某一个线程的

首先,我们需要知道,JVM 有个机制叫做偏向延迟,即即使开启了偏向锁,也需要有个延时过程才能加上锁。

启动偏向锁模式 -XX:+UseBiasedLocking;设置偏向延迟(JVM 默认4s)-XX:BiasedLockingStartupDelay=4

public class JOLSample_13_BiasedLocking {
    public static void main(String[] args) throws Exception {
        TimeUnit.SECONDS.sleep(6);
        final A a = new A();
        ClassLayout layout = ClassLayout.parseInstance(a);

        out.println("**** Fresh object");
        out.println(layout.toPrintable());

        synchronized (a) {
            out.println("**** With the lock");
            out.println(layout.toPrintable());
        }

        out.println("**** After the lock");
        out.println(layout.toPrintable());
    }

    public static class A {
        // no fields
    }
}

  • synchronized 前
objlayout.JOLSample_13_BiasedLocking$A 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)

//Mark Word:
//ThreadID(54bit): 00000000 00000000 00000000 00000000 00000000 00000000 000000
//epoch: 00
//age (4bit): 0000
//biasedLockFlag (1bit): 1
//LockFlag (2bit): 01
// biased_lock=1 & lock= 01:偏向锁
// 但是这里还没有线程id,这因为还没进入同步代码块。
  • synchronized 中
objlayout.JOLSample_13_BiasedLocking$A object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0     4        (object header)       05 e0 29 03 (00000101 11100000 00101001 00000011) (53075973)
      4     4        (object header)       00 00 00 00 (00000000 00000000 00000000 00000000) (0)

//Mark Word:
//ThreadID(54bit): 00000000 00000000 00000000 00000000 00000011 00101001 111000
//epoch: 00
//age (4bit): 0000
//biasedLockFlag (1bit): 1
//LockFlag (2bit): 01
// biased_lock=1 & lock= 01:偏向锁
// 这里已经有线程id了。
  • synchronized 后
objlayout.JOLSample_13_BiasedLocking$A object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0     4        (object header)      05 e0 29 03 (00000101 11100000 00101001 00000011) (53075973)
      4     4        (object header)      00 00 00 00 (00000000 00000000 00000000 00000000) (0)

//Mark Word:
//ThreadID(54bit): 00000000 00000000 00000000 00000000 00000011 00101001 111000
//epoch: 00
//age (4bit): 0000
//biasedLockFlag (1bit): 1
//LockFlag (2bit): 01
// biased_lock=1 & lock= 01:偏向锁
// 持有偏向锁后,若没有其他线程来竞争锁,将一直保持偏向状态。

偏向锁在获取到 Mark Word 中线程id时是自己后,不就会再进行 CAS 操作,减少性能损耗。

如果有其他线程尝试获取锁时,发现 Mark Word 中保存的不是自己的线程Id,JVM 就会将锁升级为轻量级锁。

另外在 JDK 15 版中,偏向锁已经被默认关闭,后续将弃用。相关资料阅读 openjdk.org/jeps/374

偏向锁在同步系统中引入了大量复杂的代码,并且对其他热点组件也具有入侵性。
这种复杂性是理解代码各个部分的障碍,也是在同步系统内进行重大设计更改的障碍。 为此,我们希望禁用、反对并最终取消对偏向锁定的支持。

轻量级锁

轻量级锁是指,线程没有同一时间的竞争(时间是错开的)。

   public static void main(String[] args) throws Exception {
        final A a = new A();
        ClassLayout layout = ClassLayout.parseInstance(a);
        Thread thread = new Thread(() -> {
            synchronized (a) {
                out.println("**** in thread");
                out.println(layout.toPrintable());
            }
        });
        thread.start();
        thread.join();
        synchronized (a) {
            out.println("**** With the lock");
            out.println(layout.toPrintable());
        }
    }
objlayout.JOLSample_LightWeightLock$A object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0     4        (object header)    e8 f3 97 21 (11101000 11110011 10010111 00100001) (563606504)
      4     4        (object header)    00 00 00 00 (00000000 00000000 00000000 00000000) (0)
//Mark Word:
//	javaThread*(62bit,include zero padding): 00000000 00000000 00000000 00000000 00100001 10010111 11110011 111010
//	LockFlag (2bit): 00

objlayout.JOLSample_LightWeightLock$A object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0     4        (object header)     08 f1 eb 02 (00001000 11110001 11101011 00000010) (49017096)
      4     4        (object header)     00 00 00 00 (00000000 00000000 00000000 00000000) (0)
// Mark Word:
//javaThread*(62bit,include zero padding): 00000000 00000000 00000000 00000000 00000010 11101011 11110001 000010
//LockFlag (2bit): 00

在代码即将进入同步块的时候,如果此同步对象没有被锁定(锁标志位为“01”状态),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word。

image

然后,虚拟机将使用CAS操作尝试把对象的Mark Word更新为指向Lock Record的指针。

image

如果这个更新动作成功了,即代表该线程拥有了这个对象的锁,并且对象Mark Word的锁标志位(Mark Word的最后两个比特)将转变为“00”,表示此对象处于轻量级锁定状态。

如果这个更新操作失败了,那就意味着至少存在一条线程与当前线程竞争获取该对象的锁。

虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是说明当前线程已经持有该轻量级锁,再次获取到该锁,也就是锁重入。

image

如果出现两条以上的线程争用同一个锁的情况,那轻量级锁就不再有效,必须要膨胀为重量级锁,锁标志的状态值变为“10”,此时Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也必须进入阻塞状态。这里在锁膨胀时,还有一个自旋的操作,我们后续再了解。

轻量级锁在解锁时,也是使用的 CAS 操作,如果 mark word 为null时,会判定为重入直接清除掉。

image

另外,轻量级锁在 CAS 解锁失败时,也需要膨胀成为重量级锁(Monitor),然后再解锁。

重量级锁(Monitor)

    public static void main(String[] args) throws Exception {
        final A a = new A();
        ClassLayout layout = ClassLayout.parseInstance(a);
        Thread thread = new Thread(() -> {
            synchronized (a) {
                out.println(layout.toPrintable());
               PrintObjectHeader.printObjectHeader(a);
            }
        });
        Thread thread1 = new Thread(() -> {
            synchronized (a) {
                out.println(layout.toPrintable());
                PrintObjectHeader.printObjectHeader(a);
            }
        });
        thread.start();
        thread1.start();
    }
objlayout.JOLSample_HWeightLock$A object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0     4        (object header)     1a a8 56 1d (00011010 10101000 01010110 00011101) (492218394)
      4     4        (object header)     00 00 00 00 (00000000 00000000 00000000 00000000) (0)

//Mark Word:
//javaThread*(62bit,include zero padding): 00000000 00000000 00000000 00000000 00011101 01010110 10101000 000110
//LockFlag (2bit): 10

monitor 的图在第二小节已经有了,不再另外画了。

小结

这篇文章主要讲述 synchronized 的运作原理,了解了 synchronized 如何解决同步问题,其中也涉及到锁升级过程。

我们再来总结一下,每种类型的锁都解决了什么问题。

  • 重量级锁 Monitor - 线程是映射到操作系统的原生内核线程之上,需要切换线程用户态/核心态,采用 Monitor 解决同步问题。
  • 轻量级锁 - 采用 CAS / 自旋 操作。并发度低的情况下,避免用户态/核心态转换效率过低。
  • 偏向锁 - 在无竞争的情况,避免轻量级锁在加解锁时 CAS/自旋 的操作。

参考资料

  1. tianshuang / jol-samples
  2. arturmkrtchyan/ObjectHeader32.txt
  3. docs.oracle.com/javase/spec…
  4. quarkus.io/blog/biased…
  5. (六) synchronized的源码分析
  6. 《深入理解 Java 虚拟机:JVM 高级特性与最佳实践》by 周志明