深入剖析 Java synchronized

152 阅读22分钟

在 Java 的进阶学习中,多线程环境下对 临界区代码的同步处理 是一个非常重要的内容。其中,synchronized 关键字作为实现临界资源同步最直接、最简洁的方式,扮演着关键角色

尽管 synchronized 使用简单,但它的底层实现却涉及 JVM 锁机制对象头Monitor锁升级 等复杂概念。很多人仅仅停留在“加锁能保证线程安全”的层面,却不知道:

  • 为什么 synchronized 既能修饰方法,也能修饰代码块?
  • 锁到底存储在对象的哪个部分?
  • synchronized 源码是如何实现的
  • 重量级锁 加锁解锁流程
  • 锁升级是如何优化性能的?

本文将从 使用方式字节码分析对象头结构锁升级过程性能优化 等多个维度,深入解析 synchronized 的底层原理,帮助你在高并发场景下写出更高效的代码,本文大量参考了 B 站视频 UP 黑马程序员满老师

临界区

临界区 是指一段可能被多个线程同时执行的代码,而这段代码访问了 共享资源(变量、集合或者文件),由于多个线程同时访问这些共享资源,可能会导致数据不一致或程序行为异常,例如下面这个非常经典的例子

两个线程同时对 count 进行累加和累减操作,最终结果应当为 0,但由于 count++count-- 操作不是原子性的而导致最后结果五花八门

这就是最常见的线程安全问题,使用 synchronzed 就能规避这个问题🙋

synchronized 的三种实现

synchronized 可以用于 方法代码块 中,其主要有以下三种典型使用方式:

修饰代码块

synchronized 中使用同一个 锁对象 锁住临界区的代码

private static final Object LOCK = new Object();
​
Thread thread1 = new Thread(() -> {
    synchronized (LOCK) {
        for (int i = 0; i < LOOP_NUM; i++) {
            count++;
        }
    }
});
​
Thread thread2 = new Thread(() -> {
    synchronized (LOCK) {
        for (int i = 0; i < LOOP_NUM; i++) {
            count--;
        }
    }
});

这是最灵活的方法,通过锁住具体的代码可以实现更细粒度的锁控制

修饰实例方法

synchronized 也可以加载实例方法上,相当于对方法整体加锁,加锁对象为 当前实例对象

class ThreadSafeCounter {
    private int count = 0;
​
    public synchronized void increment() {
        count++;
    }
​
    public synchronized void decrement() {
        count--;
    }
​
    public int getCount() {
        return this.count;
    }
}
​
ThreadSafeCounter threadSafeCounter = new ThreadSafeCounter();
Thread thread1 = new Thread(() -> {
    for (int i = 0; i < LOOP_NUM; i++) {
        threadSafeCounter.increment();
    }
})
Thread thread2 = new Thread(() -> {
    for (int i = 0; i < LOOP_NUM; i++) {
        threadSafeCounter.decrement();
    }
});

使用的时候创建出 实例对象,等同于 synchronized(this),当然创建出两个实例,两个实例之间的对象加锁是互不干扰的

修饰静态方法

synchronized 关键字还可以加在 静态方法 上,加锁对象为当前 class 类

private static synchronized void increment() {
    for (int i = 0; i < LOOP_NUM; i++) {
        count--;
    }
}

例如下面两个方法,加锁的对象就是 SynchronizedLearning.class

Thread thread2 = new Thread(SynchronizedLearning::increment);
Thread thread1 = new Thread(SynchronizedLearning::decrement);

字节码分析

对字节码的分析能让我们对 java 程序更加了解,首先对 synchronized 加锁的代码块进行分析,通过 javap 工具可以查看字节码的信息

  private static void lambda$main$0();
    descriptor: ()V
    flags: ACC_PRIVATE, ACC_STATIC, ACC_SYNTHETIC
    Code:
      stack=2, locals=3, args_size=0
         0: getstatic     #17                 // Field LOCK:Ljava/lang/Object;
         3: dup
         4: astore_0
         5: monitorenter
         6: iconst_0
         7: istore_1
         8: iload_1
         9: ldc           #19                 // int 100000
        11: if_icmpge     28
        14: getstatic     #13                 // Field count:I
        17: iconst_1
        18: iadd
        19: putstatic     #13                 // Field count:I
        22: iinc          1, 1
        25: goto          8
        28: aload_0
        29: monitorexit
        30: goto          38
        33: astore_2
        34: aload_0
        35: monitorexit
        36: aload_2
        37: athrow
        38: return
      Exception table:
         from    to  target type
             6    30    33   any
            33    36    33   any

上面的字节码对应了上面 thread1 的代码部分,我们主要对加锁的字节码进行重点分析

0: getstatic

用于获取 锁对象,是本类中的静态字段 LOCK,并压入操作数栈中

3: dup

dup 命令用于 复制操作数栈顶部元素(也就是 LOCK 对象)原因是接下来两个地方要用这个锁对象

  • monitorenter 加锁操作需要使用
  • 如果发生异常,monitorexit 需要再次使用到
5: monitorenter

🔒 加锁指令!为什么是 monitor,后面会提到 synchronized 在不同线程竞争情况下,采用操作系统中 monitor 实现

  • JVM 会尝试获取对象锁(即对 LOCK 对象加锁)
  • 如果其它线程已持有锁,则会阻塞直到获取锁

从这里开始进入 synchronized 控制的代码块区域

29: monitorexit
30: goto          38
......
38: return

这里 monitorexit 是去释放锁,执行完代码块的任务,然后 38 行🔚 方法执行结束

但是可以看到在方法执行结束之前,还有部分字节码

33: astore_2
34: aload_0
35: monitorexit
36: aload_2
37: athrow

这部分就是 JVM 高明的地方,考虑到开发者都没考虑到的地方,这里要结合下面的 Exception table

Exception table:
   from    to  target type
       6    30    33   any
      33    36    33   any

这张表里记录了所有可能 出现异常的代码块部分 和 处理部分

这里第一行 6 - 30 行代码是整个加锁和处理部分,如果这部分代码出现了任何异常,字节码都会跳到 33 行字节码开始执行,也就是上面部分代码

这里就做了一件事情,monitorexit 去释放 synchronized 加的锁,防止出现这部分代码一直占住锁

这部分是 JVM 给我们生成的,开发者不需要去考虑 synchronized 代码块中出现异常时 锁释放 问题,这也正是 JVM 厉害👍的地方

以上是 synchronized 针对代码块进行加锁,如果是针对 实例方法 或者 静态方法 加锁,对应的方法字节码如下

  public synchronized void increment();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_SYNCHRONIZED
    Code:
      stack=3, locals=1, args_size=1
         0: aload_0
         1: dup
         2: getfield      #2                  // Field count:I
         5: iconst_1
         6: iadd
         7: putfield      #2                  // Field count:I
        10: return
      LineNumberTable:
        line 89: 0
        line 90: 10
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      11     0  this   Lioc/ThreadSafeCounter;

其中 flags 中会记录 ACC_SYNCHRONIZED,标志这个方法被 synchronized 关键词修饰,如果是 静态方法(ACC_STATIC) ,JVM 就会锁住 class,如果是 实例方法,则锁住对象本身(this

private static void lambda$main$0(ioc.ThreadSafeCounter);
    descriptor: (Lioc/ThreadSafeCounter;)V
    flags: ACC_PRIVATE, ACC_STATIC, ACC_SYNTHETIC

Mark Word

在 JVM 中,每个对象在堆内存中的存储结构分为 对象头实例数据对齐填充 三部分:

  • 对象头 是 JVM 为每个对象预留的元信息区域,最重要的内容有两部分

    • Mark Word,后面将会重点介绍
    • Klass Pointer,是指向 元数据(Class Metadata) ,找到自己的类信息,包括字段定义、方法表、接口、父类、GC信息等等
  • 实例对象,用于存储类的字段信息

  • 对齐填充(padding) ,为了提高内存访问效率,JVM 要求对象大小是 8 字节的整数倍,若对象头 + 实例数据加起来不是 8 的倍数,就会 自动添加填充字节

Mark Wordsynchronized 实现锁的关键,存储了当前锁的类型和一些对象状态,下面是 64 位 JVM 中 Mark Word 的结构,在不同锁状态下 Mark Word 存储内容也不同

|--------------------------------------------------------------------------------|--------------------|
|                                  Mark Word (64 bits)                           |       State        |
|--------------------------------------------------------------------------------|--------------------|
| unused:25 | identity_hashcode:31 | unused:1 | age:4 | biased_lock:0 | lock: 01 |       Normal       |
|--------------------------------------------------------------------------------|--------------------|
| thread:54 |       epoch:2        | unused:1 | age:4 | biased_lock:1 | lock: 01 |       Biased       |
|--------------------------------------------------------------------------------|--------------------|
|                       ptr_to_lock_record:62                         | lock: 00 | Lightweight Locked |
|--------------------------------------------------------------------------------|--------------------|
|                     ptr_to_heavyweight_monitor:62                   | lock: 10 | Heavyweight Locked |
|--------------------------------------------------------------------------------|--------------------|
|                                                                     | lock: 11 |    Marked for GC   |
|--------------------------------------------------------------------------------|--------------------|

其中 lock 位表示了当前对象的不同加锁状态:

  • 01:无锁 / 偏向锁
  • 00:轻量级锁
  • 10:重量级锁
  • 11:GC 标记

接下来结合 synchronized 锁升级的过程来详细讲述 Mark Word 中的内容

synchronized 锁升级

这里为了能让 Java 输出 Mark Word 数据,使用了 JOL 工具来查看当前对象 Mark Word 信息,但 JOL 生成对象头信息过多,不方便我们查看,这里我自己写了一个工具类将对象头中 Mark Word 信息展示出来,并转成 二进制,方便我们查看

<!-- https://mvnrepository.com/artifact/org.openjdk.jol/jol-core -->
<dependency>
    <groupId>org.openjdk.jol</groupId>
    <artifactId>jol-core</artifactId>
    <version>0.17</version>
</dependency>

在 JDK 15 之后废弃⚠️偏向锁,考虑到目前 JDK 都已经升级到 24版本,不对偏向锁做介绍

无锁状态

对象刚创建时,属于无锁状态

Object lock = new Object();
System.out.println(MarkWordUtils.getMarkWordBinary(lock));

通过 JOL 工具在控制台打印出了 lock 对象头信息

0000000000000000000000000000000000000000000000000000000000000001

如上面介绍的 lock01biased_lock0,表示这个对象处于 无锁状态

轻量级锁
1)加锁场景

当两个或者多个线程 交替 执行同步代码块,注意 没有竞争!没有竞争!没有竞争!

System.out.println("锁创建时 Mark Word: " + MarkWordUtils.getMarkWordBinary(lock) + "\t\t锁状态:" + MarkWordUtils.getLock(lock));

Thread t1 = new Thread(() -> {
    synchronized (lock) {
        System.out.println("t1 占用锁 Mark Word: " + MarkWordUtils.getMarkWordBinary(lock) + "\t锁状态:" + MarkWordUtils.getLock(lock));
    }
});
t1.start();
t1.join();      // 阻塞等待,保证 t1 线程执行完成再执行 t2 线程

Thread t2 = new Thread(() -> {
    synchronized (lock) {
        System.out.println("t2 占用锁 Mark Word: " + MarkWordUtils.getMarkWordBinary(lock) + "\t锁状态:" + MarkWordUtils.getLock(lock));
    }
});

t2.start();
t2.join();
System.out.println("锁全部释放 Mark Word: " + MarkWordUtils.getMarkWordBinary(lock) + "\t锁状态:" + MarkWordUtils.getLock(lock));

在上面的代码中,t1t2 虽然都是使用 lock 作为锁对象,但是两个线程并 不是同时加锁,轮流使用 lock 对象,下面是输出内容

可以看到当 t1 进入同步代码块的时候,lockMark Word 后两位变成了 00,即锁当前状态为轻量级锁

2)源码分析

这里查看 synchronized 加锁的 jdk 17 源码 来分析轻量级加锁过程,代码位置在 src/hotspot/share/runtime/synchronizer.hpp

void ObjectSynchronizer::enter(Handle obj, BasicLock*lock, JavaThread*current) {
    // ... 前面代码省略 ...
    markWord mark = obj -> mark();
    assert (!mark.has_bias_pattern(),"should not see bias pattern here");

    if (mark.is_neutral()) {
        lock -> set_displaced_header(mark);
        if (mark == obj()->cas_set_mark(markWord::from_pointer (lock), mark)){
            return;
        }
        // ... 后续代码省略 ...
    }
    // ... 后续代码省略 ...
}

这里只要先看 if 部分代码即可,ObjectSynchronizer::enter 方法对应上面字节码中的 monitorenter 命令,现在逐行来看加锁过程

  • mark.is_neutral() 用来确定 对象未被锁定(无锁状态)
  • lock->set_displaced_header(mark):保存原始 Mark Word,lock 是当前线程栈上分配的 锁记录(Lock Record) , set_displaced_header 将 mark 设置到 锁记录

轻量级锁记录 源码在同目录下 basicLock

class BasicLock {
    // ... 前面代码省略 ...
    private:
        volatile markWord _displaced_header;
    // ... 后续代码省略 ...
}
class BasicObjectLock {
    // ... 前面代码省略 ...
    private:
        BasicLock _lock;  // the lock, must be double word aligned
        oop _obj;         // object holds the lock;
    // ... 后续代码省略 ...
};

锁记录中主要存储了两个重要信息

  • BasicLock _lock 用于保存 锁对象的原始 Mark Word
  • oop _obj 用于指向 加锁对象

再回到轻量级锁加锁源码中,obj()->cas_set_mark(markWord::from_pointer(lock), mark)原子交换 当前对象 Mark Word 和 锁记录指针进行交换

  • from_pointer(lock) 获取轻量级锁的 地址指针

  • obj()->cas_set_mark 是一个 CAS(Compare-And-Sweep) 操作,简化成 obj->cas(new, old),CAS 操作保证了操作的 原子性

    • 比较 old 值和 obj->mark 值是否相同,都是原始对象 Mark Word
    • new 值是轻量级锁记录 地址指针 + 00(轻量级锁 Mark Word 标识)
    • new 和 old 值进行交换,解锁的时候能根据地址解锁
  • lock 对象 Mark Word 最后两位变成 00,轻量级锁加成功

3)重入锁

当前对象已经加了轻量级锁,并且是自己再次加锁。允许 同一个线程 多次获取 同一把锁,这种锁被称为「可重入的(reentrant)

new Thread(() -> {
    synchronized (lock) {
        synchronized (lock) {
            
        }
    }
}).start();

对于已经 持有轻量级锁 的当前线程再次加锁,往栈桢中添加 对象存储 null 的锁记录,源码也很好理解

else if (mark.has_locker() &&
             current->is_lock_owned((address)mark.locker())) {
    assert(lock != mark.locker(), "must not re-lock the same lock");
    assert(lock != (BasicLock*)obj->mark().value(), "don't relock with same BasicLock");
    lock->set_displaced_header(markWord::from_pointer(NULL));
    return;
}

if 条件判断当前是重入轻量级锁,添加锁记录代码为 lock->set_displaced_header(markWord::from_pointer(NULL));,往栈桢中添加存储为 NULL 锁记录

3)解锁步骤

源码中解锁方法为 ObjectSynchronizer::exit,对应字节码中的 monitorexit 命令,下面是轻量级锁的解锁部分源码

void ObjectSynchronizer::exit(oop object, BasicLock*lock, JavaThread*current) {
    markWord mark = object -> mark();
    markWord dhw = lock -> displaced_header();
    // 判断是否为重入的锁
    if (dhw.value() == 0) {
        return;
    }

    // 轻量级锁解锁流程
    if (mark == markWord::from_pointer (lock)){
        if (object -> cas_set_mark(dhw, mark) == mark) {
            return;
        }
    }
    // 省略后续代码...

}

首先先获取加 锁对象的 Mark Word锁记录头中存储的信息,这里对象信息已经交换,mark 存储的是 锁记录地址指针,dhw 存储原始对象 Mark Word

dhw.value() == 0 上面提到,如果是同一个线程重入的锁的锁记录头中存储的信息为 NULL,则不用执行任何操作,直接返回

mark == markWord::from_pointer(lock) 为 true 时,说明

  • Mark Word 最后两位是 00(轻量级锁标识)
  • 锁被当前线程持有

markWord::from_pointer(lock) 为 false 时说明:

  • 当前线程不持有轻量级锁
  • 当前锁对象已经成为重量级锁

这是就会都会进入锁膨胀,并执行重量级锁解锁步骤

因此可以确认 当前线程仍持有该对象的轻量级锁,进入解锁流程,object -> cas_set_mark(dhw, mark) == mark 再把锁记录中的原始 Mark Word 通过 CAS 交换到锁对象的 Mark Word 中

  • CAS 执行成功,成功解锁轻量级锁,对象重新变为 无锁状态
  • CAS 解锁失败,进入重量级锁解锁流程
重量级锁

如果轻量级锁加锁失败,分成下面几种情况:

  • 默认关闭轻量级锁加锁步骤
  • CAS 竞争失败
  • 其他线程已经占用轻量级锁

此时都会升级成为 重量级锁,进入重量级锁的加锁流程(锁膨胀),交由 内核 中的 Monitor 管理

  // An async deflation can race after the inflate() call and before
  // enter() can make the ObjectMonitor busy. enter() returns false if
  // we have lost the race to async deflation and we simply try again.
  while (true) {
    ObjectMonitor* monitor = inflate(current, obj(), inflate_cause_monitor_enter);
    if (monitor->enter(current)) {
      return;
    }
  }
1)Moniter 对象

当前有多个线程进行竞争时,就会触发 锁膨胀,从轻量级锁升级为 重量级锁,重量级锁会由 Moniter 对象进行管理

System.out.println("锁创建时 Mark Word: " + MarkWordUtils.getMarkWordBinary(lock) + "\t\t锁状态:" + MarkWordUtils.getLock(lock));
Thread t1 = new Thread(() -> {
    synchronized (lock) {
        TimeUnit.MICROSECONDS.sleep(1);
        System.out.println("t1 占用锁 Mark Word: " + MarkWordUtils.getMarkWordBinary(lock) + "\t锁状态:" + MarkWordUtils.getLock(lock));
    }
});
Thread t2 = new Thread(() -> {
    synchronized (lock) {
        System.out.println("t2 占用锁 Mark Word: " + MarkWordUtils.getMarkWordBinary(lock) + "\t锁状态:" + MarkWordUtils.getLock(lock));
    }
});
// 启动线程,阻塞等待,省略代码
System.out.println("锁全部释放 Mark Word: " + MarkWordUtils.getMarkWordBinary(lock) + "\t锁状态:" + MarkWordUtils.getLock(lock));

这时两个对象发生争抢,lock 对象的 Mark Word 最后两位为 10,锁状态为 重量级锁

这里锁释放了不会恢复 无锁状态,依旧会处于重量级锁状态

Monitor 对象负责管理重量级锁,主要由三部分组成:WaitSet,EntryList 和 Owner

  • Owner:当前持有线程,唯一能执行同步代码块,唯一能调用 wait() 方法
  • EntryList:「想要锁但还没得到」的线程队列,线程状态为 BLOCKED
  • WaitSet:「主动放弃 Owner,等待条件」的线程(调用 wait() 方法),等待 notify 唤醒
2)锁膨胀

锁膨胀总共分为 4 种情况

  • Inflated:已经膨胀完毕
  • Stack-locked:从轻量级锁开始膨胀
  • Inflating:膨胀中
  • Neutral:从无锁状态开始膨胀
for (;;) {
    const markWord mark = object->mark();
    assert(!mark.has_bias_pattern(), "invariant");

    // The mark can be in one of the following states:
    // *  Inflated     - just return
    // *  Stack-locked - coerce it to inflated
    // *  INFLATING    - busy wait for conversion to complete
    // *  Neutral      - aggressively inflate the object.
    // *  BIASED       - Illegal.  We should never see this
}

锁膨胀在一个 死循环 中,每一次都会获取对象的 Mark Word,判断当前状态,其中从无锁对象膨胀和从轻量级锁开始膨胀逻辑相似,只介绍从轻量级锁开始膨胀

  1. Inflated 膨胀完成
// CASE: inflated
if (mark.has_monitor()) {
    ObjectMonitor* inf = mark.monitor();
    markWord dmw = inf->header();
    return inf;
}

如果当前已经是重量级锁,直接获取 Moniter 对象返回

  1. Stack-lock 从轻量级锁开始膨胀
if (mark.has_locker()) {
    // 1. 创建 moniter 对象
    ObjectMonitor* m = new ObjectMonitor(object);
    // 2. 将当前对象头设置为全0,为膨胀中标识
    markWord cmp = object->cas_set_mark(markWord::INFLATING(), mark);
    // 3. CAS 失败,删除 moniter 对象
    if (cmp != mark) {
        delete m;
        continue;       // Interference -- just retry
    }
    // 4.1 获取原始 mark word 值
    markWord dmw = mark.displaced_mark_helper();
    // 4.2 将原始 Mark word 存入 moniter 中
    m->set_header(dmw);
    // 4.3 设置 mointer 对持锁线程
    m->set_owner_from(NULL, mark.locker());
    // 4.4 锁对象头设置新的 mark word
    object->release_set_mark(markWord::encode(m));
    _in_use_list.add(m);
    return m;
}

mark.has_locker() 用来判断当前对象是否为轻量级锁,创建一个新的 moniter 对象,通过 CAS 并将当前对象 mark word 全部置为 0,标志 膨胀中 状态,关键是下面设置重量级锁的 4 个步骤

  1. mark.displaced_mark_helper() 获取原始的 mark word 的值
  2. m->set_header(dmw) 把原始 mark word 值存储在 moniter
  3. m->set_owner_from(NULL, mark.locker()); 设置 moniterOwner,即当前持重量级锁的线程
  4. object->release_set_mark(markWord::encode(m))monitor 的地址 encode 编码 + 10 存入锁对象 mark word 中

经过上面操作,会保证只有一个线程针对 lock 锁对象加 重量级锁 成功

  1. inflating 膨胀中

当前处于膨胀中的状态代码很好理解,read_stable_mark 方法等待一会

if (mark == markWord::INFLATING()) {
      read_stable_mark(object);
      continue;
}

所有处于膨胀中的线程都是上一步 CAS 加锁竞争失败的,会等待真正加锁的线程加锁成功,在这里 循环等待

3)误区:轻量级锁真的有自旋吗?

网上有些说法说 轻量级锁 膨胀为 重量级锁 时会去尝试自旋等待轻量级锁释放,自旋 10 次失败升级为重量级锁

甚至问 AI 有时也会得到这样的结果,还有一个合理的理由:避免升级成重量级锁的性能开销。但实际上却是如此吗?

这个说法完全是错误的。上面关于轻量级锁膨胀为重量级锁的源码已经剖析过了,当有线程发生竞争的时候,就会立刻执行锁膨胀流程,不会去自旋 10 次。而上面唯一有循环等待的过程只有当线程处于 膨胀中 时会去循环等待加锁线程成功

4)重量级锁自旋

当对象已经升级成为 重量级锁 后,其他线程再来争抢重量级锁失败后,不会直接进入 Moniter 对象中的 EntryList,也就是并不会直接进入 BLOCKED 堵塞状态,而会选择 自旋 的方式尝试等待获取 重量级锁

重量级锁自旋获取锁的逻辑在源码中为 ObjectMonitor::TrySpin 方法,其中自旋方式分为:

  • 固定自旋 1000 次(jdk 1.6 后被禁用)
  • 自适应自旋

这里源码过长,挑重要的部分片段来解释 自适应自旋 获取锁的逻辑,与自适应自旋相关参数

// 自旋次数上限
int ObjectMonitor::Knob_SpinLimit    = 5000;
// 成功奖励
static int Knob_Bonus               = 100;
// 失败惩罚
static int Knob_Penalty             = 200;
// 自旋次数下限
static int Knob_Poverty             = 1000;
// 预自旋次数
static int Knob_PreSpin             = 10;

首先会进入 预自旋 流程,自旋 11

for (ctr = Knob_PreSpin + 1; --ctr >= 0;) {
    // 进行自旋获取锁...
}
  • 自旋成功获取锁,直接返回
  • 自旋失败,进入下面 完整自旋

进入 完整自旋 逻辑,当前自旋次数由全局变量 _SpinDuration (初始 5000 次)记录

  • 自旋失败,下次 -200 次,最少减为 0(0 表示下次完成预自旋直接进入阻塞)
  • 自旋成功,下次 +200 次,至少加为 1000(1000 值是指从 0 开始累加的话,直接从 1000 开始累加)

当自旋全部失败后,失败线程就会进入重量级锁加锁流程

6)_cxq 队列

这里的加锁流程是指当前 Owner 已经被占用,其他线程的「挣扎过程」,当失败线程执行完自旋获取锁的流程后,会进入 ObjectMonitor::EnterI 方法

  1. 线程使用 TATAS (Test-And-Test-And-Set) 尝试 快速获取锁,即使上面 5000 次自旋失败了,线程还会努力 挣扎一次,如果成功了,直接将 Owner 设置为当前线程,直接返回
// Try the lock - TATAS
if (TryLock (current) > 0) {
    // ...
    return;
}

2. 再次进入 自适应自旋尝试,因为上一次失败了次数 -200,如果上次自旋次数是 5000 次,那这次会自旋 4800 次

if (TrySpin(current) > 0) {
    return;
}

3. 自旋还是失败后,会讲当前线程加入 Monitor 对象中的 _cxq 栈

ObjectWaiter node(current);
node.TState = ObjectWaiter::TS_CXQ;
// CAS操作 将当前线程加入_cxq队列
for (;;) {
    node._next = nxt = _cxq;
    if (Atomic::cmpxchg(&_cxq, nxt, &node) == nxt) break;
    // 如果CAS失败,重试获取锁
    if (TryLock(current) > 0) return;
}

创建 ObjectWaiter 节点表示当前线程,压入栈(LIFO 后进先出),并设置一个 责任线程

if (nxt == NULL && _EntryList == NULL) {
    Atomic::replace_if_null(&_Responsible, current);
}

如果没有责任线程且队列为空,设置当前线程为 责任线程。所有加入 _cxq 栈中的线程都会使用 park() 命令让自己 阻塞

  1. 设置责任线程定期唤醒,非责任线程一直阻塞
for (;;) {
    if (TryLock(current) > 0) break;
    
    // 责任线程使用定时park,非责任线程无限期park
    if (_Responsible == current) {
        current->_ParkEvent->park((jlong)recheckInterval);
        recheckInterval = MIN2(recheckInterval*8, MAX_RECHECK_INTERVAL);
    } else {
        current->_ParkEvent->park();
    }
    
    // 唤醒后再次尝试获取锁
    if (TryLock(current) > 0) break;
}

通过设置责任线程的机制,能够防止线程永久挂起,并且节省了性能的开销

7)解锁步骤
  • 若当前线程为轻量级锁,但调用的是 ObjectMonitor::exit 重锁解锁方法,会先加重锁,再解锁
  • 若为重入锁(重量级锁重入的时候会对 Monitor 中的 _recursions 累加),则计数 -1 直接返回

重量级锁正常解锁过程,首先讲 Owner 设置为 NULL,顺序执行下来会出现 4 中不同情况

  1. _cxqEntryList 全部为空,直接返回
// 没有等待线程,直接返回
if ((intptr_t(_EntryList)|intptr_t(_cxq)) == 0) {
    return; 
}

2. 若 EntryList 为空,_cxq 有数据,则将 _cxq 列表全部转移到 EntryList 中

for (;;) {
    assert(w != NULL, "Invariant");
    ObjectWaiter* u = Atomic::cmpxchg(&_cxq, w, (ObjectWaiter*)NULL);
    if (u == w) break;
    w = u;
}

通过使用 Atomic::cmpxchg 保证队列转移的原子性

转换前 _cxq (LIFO 单向链表):
+---+    +---+    +---+
| C |--->| B |--->| A |---> NULL
+---+    +---+    +---+

转换步骤:
1. 断开 _cxq 链接
2. 遍历链表建立双向链接

转换后 _EntryList (FIFO 双向链表):
          +---+    +---+    +---+
NULL <--- | A | <->| B | <->| C | ---> NULL
          +---+    +---+    +---+
          ^
          |
      _EntryList 指向这里

3. 当前 EntryList 不为空,从队列获取 头节点unpark() 唤醒,返回

w = _EntryList;
if (w != NULL) {
    ExitEpilog(current, w);
    return;
}

wEntryList 的头结点,这里调用 ExitPilog 方法来唤醒 w 线程,主要有四个步骤

// 1. 设置继承者
_succ = Wakee->_thread;  

// 2. 释放锁
release_clear_owner(current);  

// 3. 内存屏障保证操作顺序
OrderAccess::fence();  

// 4. 唤醒线程
Trigger->unpark();  

OrderAccess::fence() 是一个 全内存屏障(full memory barrier) ,用于保证了 2 步骤一定先于 4 步骤执行,即肯定先释放锁再唤醒线程

最后这里用一张流程图来简单梳理 重量级锁的解锁流程

8)误区:锁释放,会从 EntryList 中随机挑选一个线程唤醒吗?

很多八股文都会说当 Owner 释放了重量级锁后,会随机从 EntryList 中挑选一个线程唤醒获得锁,这的是这样吗?

结论显然是错误的,我们上面已经剖析过源码,竞争的线程会加入 _cxq 栈中的(LIFO,先进后出),然后再将 _cxq 内容加入 EntryList 队列中(FIFO,先进先出),因此唤醒顺序一定是确认的,下面的示例代码将证明这一点

final Object lock = new Object();
new Thread(() -> {
    synchronized (lock) {
        try {
            System.out.println("t0 加锁成功");
            System.in.read(); // 阻塞 t0
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }
}, "t0").start();

TimeUnit.MICROSECONDS.sleep(100);
new Thread(() -> {
    synchronized (lock) {
        System.out.println("t1 获取锁");
    }
}, "t1").start();
TimeUnit.MICROSECONDS.sleep(100);
new Thread(() -> {
    synchronized (lock) {
        System.out.println("t2 获取锁");
    }
}, "t2").start();
TimeUnit.MICROSECONDS.sleep(100);
new Thread(() -> {
    synchronized (lock) {
        System.out.println("t3 获取锁");
    }
}, "t3").start();

t0 线程优先获取锁,并阻塞,让 t1,t2,t3 先后加锁,最后查看获取锁的先后情况

最后执行结果说明了顺序一定是如下图所示,EntryList 是一个 先进先出 的队列,t3 最先被唤醒,并非网上说的随机挑选一个线程获取锁

9)为什么有了 EntryList 还需要 _cxq

逻辑上,我们只需要一个队列就可以管理阻塞的线程,然后唤醒。但实现上,我们把这个队列分成了两部分: _cxqEntryList,主要为了避免 ABA 问题惊群效应,以及 兼顾了吞度量和公平性

  1. ABA 问题

ABA 问题 是并发编程中的一个经典问题,主要出现在特别是在使用 CAS(Compare-And-Swap) 操作时可能发生

对于共享变量 x = a, 执行 cas(a, b) 是原子性的将 x 的值交换成 b,并且会把交换的旧值返回

  • 当前 x 为 a,则交换成功,x = b
  • 当前 x 被其他线程修改成了 c,则交换失败,本线程需要重试

当 CAS 操作结合 时,就会引发 ABA 问题:,例如下面的示例

这个图已经基本反映了 ABA 问题 的核心逻辑:一个线程在用 CAS 操作时,只判断了「值有没有变化」,没考虑「值是否曾经被改过」

  • _cxq 是 LIFO(栈)结构,插入简单,不容易出现 ABA
  • 只有 JVM 自己在合适时机把 _cxq 中的线程“批量转移”到 EntryList → 所以可以集中加锁、避免高频竞 → 避免 ABA 问题

  1. 惊群效应

惊群效应 指的是当 一个资源释放时,所有等待的线程都被唤醒并竞争,但最终只有一个线程能成功获取锁,其他线程又得重新挂起,导致 大量无意义的上下文切换和CPU争抢

通过 _cxq + EntryList 每次只唤醒一个线程(EntryList) 减少竞争,提高吞吐量

  1. 兼顾吞吐量和公平性

_cxq 相当于缓存了最近竞争的线程,提高了整体的 吞吐量

EntryList 采用 FIFO 顺序可以一定的 公平性,防止线程 饿死(长时间获取不到锁)