Java中synchronized关键字的底层实现

66 阅读9分钟

从最开始学Java的时候,便听说int类型比Integer类型要小很多,那么究竟是小在哪里呢?Integerint究竟多了什么东西?这是本文要着重介绍的对象头,并介绍Monitor和锁。

对象头

对象头是Java对象在内存布局中的第一部分,包含了对象的元数据信息。每个Java对象在堆内存中都由对象头实例数据对齐填充三部分组成。

下面以32位虚拟机来介绍对象头的结构:

  1. 普通对象

image.png 普通对象的对象头共64位,包括:

  • 32位的Mark Word:最核心的部分,存储了对象的运行时数据
  • 32位的Klass Word:指向方法区中类元数据的指针,JVM通过这个指针确定对象属于哪个类。
  1. 数组对象

image.png 数组对象相对于普通对象,多了一个array length字段,用于存放数组声明时的大小。

对象头的作用

对象头的关键是Mark Word字段,因此本节深入探索Mark Word

在正常的情况下,Mark Word的结构如下:

image.png 下面来介绍这些字段:

  • hashcode字段是JVM在对象内部维护的一个内部标识。它是一个31位的整数(在32/64位系统中位置不同),也称为一致性哈希码。这个值是一个懒加载的,只有当JVM第一次需要用到这个内部哈希码时,才会计算并存入Mark Word

    • 首次调用默认的Object.hashCode()方法(即没有重写过的)。
    • 首次调用System.identityHashCode(obj)方法
    • 在某些锁升级(如偏向锁)等情况下,也可能被计算。
  • age字段和垃圾回收机制的分代年龄有关

  • biased_lock字段,代表偏向锁是否启用

  • 剩下的两位可以理解为当前的对象锁模式。

Monitor

Monitor是一种操作系统级的同步原语Java用它来实现 synchronized 的语义。每一个Java 对象都可以关联到一个Monitor(通常延迟创建)。

HotSpot中,MonitorC++类 ObjectMonitor 实现,其核心结构如下:

ObjectMonitor() {
    _header       = NULL;  // 存储 Displaced Mark Word,用于 GC 等
    _count        = 0;     // 重入计数
    _waiters      = 0;     // 等待在 WaitSet 上的线程数
    _recursions   = 0;     // 重入次数 (和 _count 功能重叠,锁实现里以 recursions 为主)
    _object       = NULL;  // 关联的 Java 对象
    _owner        = NULL;  // 当前持有锁的线程
    _WaitSet      = NULL;  // 处于 wait 状态的线程队列 (条件变量队列)
    _cxq          = NULL;  // 竞争锁的线程单向链表 (Contention List,LIFO)
    _EntryList    = NULL;  // 有资格成为候选的线程队列 (双向链表)
    _succ         = NULL;  // 被唤醒的继任线程
}

Monitor中的核心队列与线程流转:

  • _owner:同一时刻只能有一个线程将 _owner 设置为自身(通过CAS操作),持有Monitor 就代表成功进入临界区,即获取重量级锁。

  • _cxq (Contention Queue) :当线程初次竞争锁失败时,会尝试自旋一段时间,若仍未获得锁,则会被包装为一个 ObjectWaiter 节点,以CAS方式插入 _cxq 链表的头部。这是后进先出 (LIFO) 结构,目的可能是利用缓存热度。

  • _EntryList:当持有锁的线程释放锁时,会从 _cxq 或 _EntryList 中选取一个线程作为“继承者” (_succ)。通常流程是:先将 _cxq 中的所有线程迁移到 _EntryList 中,并组织成双向链表,再从 _EntryList 头部唤醒一个线程去竞争锁。被唤醒的线程将自己设为 _owner 并继续执行。

  • _WaitSet:当持有锁的线程调用 Object.wait() 时,它会立即释放锁,将自己包装为 ObjectWaiter 加入 _WaitSet 集合,并阻塞自身。当其他线程调用 notify() 或 notifyAll() 时,被唤醒的线程会从 _WaitSet 移到 _cxq 或 _EntryList 中(取决于具体策略),重新参与锁竞争。

由上,可以简要抽取以下的模型:

  • Owner:当前持有锁的线,通过CAS操作设置
  • Entry Set:等待获取锁的队列
  • Wait Set:调用wait方法后进入等待状态的线程队列
  • 其他组件:
    • Recursion:记录锁重入的次数
    • Count:线程获取锁的次数

image.png

只有在对象作为锁的时候,才会创建一个对应的Monitor

当通过synchronized去对一个对象加锁时,如果该锁不被其他线程持有,将获得这把锁,Owner变成该线程,如果该锁已经被其他线程持有,将会进入EntryList阻塞。

当线程获得锁之后,该对象的Mark Word将从

image.png 变成

image.png

也就是说,对象头中的Mark word变成了一个指向monitor的指针,而原本的Mark word信息保存在了Monitor中的_header字段中。

synchronized的内存语义

进入synchronized块的内存语义是把在synchronized块内使用到的变量线程的工作内存中清除,这样在synchronized块内使用到该变量时就不会线程的工作内存中获取,而是直接从主内存中获取

退出synchronized块的内存语义是把在synchronized块内对共享变量的修改刷新到主内存

其实这也是加锁释放锁的语义,当获取锁后会清空锁块内本地内存中将会被用到的 共享变量,在使用这些共享变量时从主内存进行加载,在释放锁时将本地内存中修改的共享变量刷新到主内存

这在字节码层面是使用monitorentermonitorexit插入在同步块的开始和结尾实现的。

锁升级机制

虽然代码中使用synchronized,但是实际上可能根本就不存在多线程竞争资源,因此锁升级机制就出现了。

HotSpot JVM 中,对象头的Mark Word记录了锁的状态,升级路径主要经过以下几个阶段:

  1. 无锁状态:一个新创建的对象,尚未被任何线程锁定。
  2. 偏向锁:适用于只有一个线程反复进入同步块的场景。第一次获取锁时,会在对象头和栈帧中记录偏向的线程ID。之后该线程再进入时,只需简单检查线程ID是否匹配,无需任何原子操作(如CAS),开销极低。
  3. 轻量级锁:当有少量线程交替竞争(即竞争不激烈,没有同时抢)时升级为此状态。它通过CPU的CAS操作在对象头和线程栈帧中复制、替换锁记录来实现同步,避免了操作系统级互斥量的阻塞开销。
  4. 重量级锁:当有多线程激烈竞争时,轻量级锁会通过自旋尝试获取锁。若自旋失败(或达到阈值),锁会膨胀为重量级锁。此时,未获取到锁的线程会被挂起,进入操作系统的等待队列,等待被唤醒。这是开销最大的锁。

偏向锁

当如果一个锁在绝大多数时间里,都被同一个线程反复获得,那么JVM就可以让这个线程后续的加锁操作无需再进行任何同步操作

为了实现偏向,对象头中的Mark Word会记录第一个获得它的线程的ID,以及一个偏向时间戳(epoch)

当对象被创建后,如果没有禁用偏向锁,将会在一定时间后,Mark word会从

image.png 变成

image.png 最开始,thread是0,因为此时还未偏向于任何一个线程。

加锁

当一个线程T1第一次获取这个对象锁后:

  1. JVM检查对象是否处于可偏向状态。
  2. 通过一次CAS操作,尝试将当前线程T1id写入对象的Mark Word。
  3. 如果CAS成功thread将记录T1id​ 。这意味着锁偏向了线程T1。这次操作开销稍大,因为包含了CAS。

当一个线程T1再次进入这个同步块:

  1. JVM检查对象头中的线程id
  2. 发现就是自己(T1),则直接通过,没有任何原子操作或系统调用。

解锁

线程T1执行完同步块,并不会主动释放偏向锁。它不会将Mark Word中的线程ID清空。对象依然保持在 “已偏向于T1” ​ 的状态。这为T1下次快速进入创造了条件。

锁升级

当有另一个线程T2也试图获取这个锁时:

  1. 撤销(Revoke) :JVM需要“主持公道”,撤销对T1的偏向。这个过程必须在全局安全点进行,它会暂停拥有偏向锁的线程T1。

  2. 检查T1状态

    • 如果T1已经退出同步块,则撤销完成,将对象恢复到 “可偏向状态” (但此时T2还未获得锁)。
    • 如果T1仍在执行同步块,则将偏向锁升级为轻量级锁

轻量级锁

在大多数情况下,同步代码块在运行期间不存在竞争,如果每次加锁都使用操作系统的重量级互斥量,会造成巨大的性能开销。

加锁

当锁升级为轻量级锁后,一个线程获取锁后,Mark Word

image.png 变成

image.png 加锁时,会在当前线程的栈帧中,创建一个LOCK RECORD,原本的Mark Word复制到LOCK RECORD中的Displaced Mark Word

image.png 其加锁过程如下:

  1. 检查对象头是否是无锁状态
  2. 创建LOCK RECORD
  3. CAS更新

解锁

  1. 检查当前锁是否为轻量级锁(锁标志位=00)
  2. 从对象头获取指向锁记录的指针
  3. 使用CAS将对象头恢复为Displaced Mark Word

锁升级

升级的条件是:

  1. CAS尝试失败 && 自旋次数 > 阈值,JVM默认采用自适应自旋,自旋次数阈值是动态计算的
  2. 第三个线程参与竞争
  3. 调用wait

重量级锁

通过synchronized获取重量级锁时,synchronized会被编译为monitorentermonitorexit指令。

image.png

重量锁的“重”和轻量锁的“轻”

重量锁之所以重,是因为它需要依赖操作系统的互斥锁来实现线程同步,这会涉及到用户态到内核态的切换,并且线程竞争失败会阻塞,后续获得锁后要切换线程的上下文,而且还需要关联一个Monitor

轻量级锁是完全在在用户态完成的,竞争失败也只是自旋