深度剖析:synchronized 底层实现原理(JVM 视角)

一、Java对象内存布局的深度拆解

原文提到了对象头,这里补充64位JVM的完整对象布局对齐填充的底层原因

1.1 64位JVM的完整对象结构

text

复制代码
┌─────────────────────────────────────────────┐
│               对象头 (12/16字节)              │
├─────────────────────────────────────────────┤
│  Mark Word (8字节)                          │
│  ┌─────────────────────────────────────┐   │
│  │ 锁状态位 │ 线程ID │ 分代年龄 │ HashCode│   │
│  └─────────────────────────────────────┘   │
├─────────────────────────────────────────────┤
│  Klass Pointer (4字节,开启指针压缩)         │
│  (指向方法区的Class对象)                     │
├─────────────────────────────────────────────┤
│  Array Length (4字节,仅数组对象)            │
├─────────────────────────────────────────────┤
│               实例数据                       │
│  (对象的字段值,包括父类字段)                 │
├─────────────────────────────────────────────┤
│               对齐填充                       │
│  (保证对象大小是8字节的整数倍)                │
└─────────────────────────────────────────────┘

1.2 为什么需要对齐填充?

核心原因 :CPU读取内存不是按字节,而是按字长(64位CPU一次读取8字节)。如果对象大小不是8的倍数,会导致:

  • 一个对象跨越两个缓存行,读取效率降低

  • 伪共享问题(False Sharing)风险增加

JVM参数-XX:ObjectAlignmentInBytes=8(默认8,可设置为8的倍数)

1.3 指针压缩(CompressedOops)

JDK 1.6+默认开启,将64位指针压缩为32位,节省内存:

  • Klass Pointer从8字节压缩为4字节

  • 对象头从16字节减少到12字节

  • 堆内存上限从4GB提升到32GB(压缩指针支持下)

JVM参数-XX:+UseCompressedOops(JDK 1.8默认开启)


二、Mark Word的极致细节:不同锁状态下的位分布

原文给出了Mark Word的概念,这里补充64位JVM在不同锁状态下的精确位分布,这是面试官追问时的杀手锏。

2.1 无锁状态(biased_lock=0, lock=01)

text

复制代码
┌─────────────────────────────────────────────────────────────┐
│  unused:25 │ hashcode:31 │ unused:1 │ age:4 │ biased:0 │ 01 │
│  (25位)    │   (31位)    │  (1位)   │ (4位) │  (1位)  │(2位)│
└─────────────────────────────────────────────────────────────┘
  • hashcode:延迟计算的哈希码(调用hashCode()后才写入)

  • age:GC分代年龄(4位,最大15)

  • biased_lock:0表示无锁/轻量级/重量级

2.2 偏向锁状态(biased_lock=1, lock=01)

text

复制代码
┌─────────────────────────────────────────────────────────────┐
│  thread:54 │ epoch:2 │ unused:1 │ age:4 │ biased:1 │ 01 │
│  (54位)    │ (2位)   │  (1位)   │ (4位) │  (1位)  │(2位)│
└─────────────────────────────────────────────────────────────┘
  • thread:持有偏向锁的线程ID(54位,可表示2^54个线程)

  • epoch:批量重偏向的时间戳

  • 关键 :偏向锁状态下没有hashcode!调用hashCode()会导致偏向锁立即撤销

2.3 轻量级锁状态(lock=00)

text

复制代码
┌─────────────────────────────────────────────────────────────┐
│  ptr_to_lock_record:62 │ lock:00 │
│  (62位)                │ (2位)   │
└─────────────────────────────────────────────────────────────┘
  • ptr_to_lock_record:指向当前线程栈中**锁记录(Lock Record)**的指针

2.4 重量级锁状态(lock=10)

text

复制代码
┌─────────────────────────────────────────────────────────────┐
│  ptr_to_monitor:62 │ lock:10 │
│  (62位)            │ (2位)   │
└─────────────────────────────────────────────────────────────┘
  • ptr_to_monitor:指向堆中Monitor对象的指针

2.5 一个关键问题:hashcode去哪儿了?

偏向锁状态下无法存储hashcode。如果对象已经计算过hashcode,就不能进入偏向锁状态。这就是为什么:

  • 重写了hashCode()方法的对象,偏向锁可能失效

  • identityHashCode调用后,锁会直接升级为轻量级锁


三、Monitor的深度解析:ObjectMonitor源码级理解

原文介绍了Monitor的概念,这里补充OpenJDK中ObjectMonitor的C++实现,让你理解到底层。

3.1 ObjectMonitor核心字段

cpp

复制代码
class ObjectMonitor {
  volatile markOop   _header;        // 对象头副本
  void* volatile     _owner;         // 当前持有锁的线程
  ObjectWaiter*      _EntryList;     // 等待获取锁的线程队列(双向链表)
  ObjectWaiter*      _WaitSet;       // 调用wait()的线程队列
  volatile intptr_t  _count;         // 锁计数器(重入次数)
  volatile intptr_t  _recursions;    // 重入深度(与_count配合)
  // ... 省略其他字段
};

3.2 线程获取锁的完整流程

cpp

复制代码
// 简化版:ObjectMonitor::enter() 的核心逻辑
void ObjectMonitor::enter(TRAPS) {
  Thread* const self = THREAD;
  
  // 第一次尝试:CAS设置_owner
  void* cur = Atomic::cmpxchg_ptr(self, &_owner, NULL);
  if (cur == NULL) {
    // 获取锁成功
    _recursions = 1;
    return;
  }
  
  // 如果当前线程已经持有锁(可重入)
  if (cur == self) {
    _recursions++;
    return;
  }
  
  // 锁被其他线程持有,进入自旋或阻塞
  // 先尝试自适应自旋...
  if (TrySpin(self) > 0) {
    // 自旋成功,获取锁
    return;
  }
  
  // 自旋失败,加入_EntryList阻塞
  ObjectWaiter node(self);
  _EntryList = &node;
  // 调用park()阻塞线程(进入内核态)
  self->park();
}

3.3 为什么重量级锁慢?

  1. 用户态→内核态切换park()是系统调用,每次切换约1-10微秒

  2. 线程阻塞和唤醒:涉及操作系统线程调度

  3. 缓存失效:切换线程后,CPU缓存热数据失效

数据参考

  • 轻量级锁(CAS自旋):约50-100纳秒

  • 重量级锁(mutex):约1-10微秒(10-100倍差距)


四、锁升级机制的极致细节

原文介绍了锁升级流程,这里补充每个阶段的触发阈值自适应自旋的实现原理

4.1 偏向锁的批量撤销与重偏向

JVM维护每个类的偏向锁撤销计数器:

撤销次数 行为 说明
1-19次 正常撤销,升级为轻量级锁 轻度竞争,不值得重偏向
第20次 触发批量重偏向 JVM认为该类可能仍适合偏向锁
第40次 触发批量撤销 JVM认为该类不适合偏向锁,禁用偏向

JVM参数

  • -XX:BiasedLockingBulkRebiasThreshold=20:批量重偏向阈值

  • -XX:BiasedLockingBulkRevokeThreshold=40:批量撤销阈值

4.2 轻量级锁的自适应自旋

自适应自旋不是固定次数,而是动态调整:

cpp

复制代码
// 简化版自适应自旋逻辑
int TrySpin(Thread* self, int* spins) {
  if (last_spin_success) {
    // 上次自旋成功过,这次多自旋一会儿
    spins = 1000;  // 更多自旋次数
  } else {
    // 上次自旋失败,这次少自旋
    spins = 100;   // 减少自旋次数
  }
  
  for (int i = 0; i < spins; i++) {
    if (try_acquire_lock()) {
      last_spin_success = true;
      return 1;  // 自旋成功
    }
    // 每次自旋间隔一段时间
    pause();
  }
  
  last_spin_success = false;
  return 0;  // 自旋失败,升级重量级锁
}

4.3 锁升级的完整流程图(含JVM参数)

text

复制代码
                    对象创建
                       ↓
              ┌─────────────────┐
              │  无锁状态        │
              │  (lock=01)      │
              └────────┬────────┘
                       ↓
              线程第一次获取锁
                       ↓
        ┌──────────────┴──────────────┐
        ↓                              ↓
  偏向锁(默认开启)              禁用偏向锁
  (biased=1, lock=01)           (直接轻量级锁)
        ↓                              ↓
  记录线程ID,无需CAS               CAS设置锁记录
        ↓                              ↓
        └──────────────┬──────────────┘
                       ↓
            第二个线程竞争锁
                       ↓
        ┌──────────────┴──────────────┐
        ↓                              ↓
  偏向锁撤销                   轻量级锁竞争
  (安全点STW)                 (CAS自旋)
        ↓                              ↓
  升级为轻量级锁               ┌─────────┴─────────┐
        ↓                      ↓                   ↓
        └──────────────→  自旋成功         自旋失败(超阈值)
                          保持轻量级        升级重量级锁
                              ↓                   ↓
                              └──────────→   Monitor阻塞

4.4 锁升级相关的JVM调优参数

参数 默认值 说明
-XX:+UseBiasedLocking true 是否启用偏向锁
-XX:BiasedLockingStartupDelay 4000ms 启动后延迟开启偏向锁
-XX:+UseHeavyMonitors false 禁用锁升级,直接使用重量级锁
-XX:PreBlockSpin 10 固定自旋次数(JDK6之前)

调优建议

  • 高并发场景:可考虑-XX:-UseBiasedLocking禁用偏向锁,减少撤销开销

  • 容器环境:注意BiasedLockingStartupDelay,JVM启动4秒后才启用偏向锁


五、synchronized的happens-before关系证明

原文提到了可见性,这里补充用happens-before规则证明synchronized的可见性

5.1 管程锁定规则

JMM定义的happens-before规则之一:

对一个锁的解锁操作,happens-before于随后对同一个锁的加锁操作。

证明

java

复制代码
// 线程A
synchronized (lock) {
    x = 1;  // 写操作
} // 释放锁

// 线程B
synchronized (lock) {
    int y = x;  // 读操作,一定能读到1
}

底层保障

  1. 线程A释放锁时,执行monitorexit,触发StoreLoad屏障

  2. 该屏障强制将A的所有写操作刷新到主内存

  3. 线程B获取锁时,执行monitorenter,触发LoadLoad屏障

  4. 该屏障强制B从主内存重新加载所有变量

5.2 synchronized与volatile的内存语义对比

特性 volatile synchronized
写操作语义 插入StoreStore + StoreLoad屏障 释放锁时插入StoreLoad屏障
读操作语义 插入LoadLoad + LoadStore屏障 获取锁时插入LoadLoad屏障
原子性 ❌ 不保证 ✅ 保证

六、synchronized的实战调优与避坑

6.1 锁粒度的选择原则

场景 推荐做法 原因
单线程重复获取 偏向锁自动优化 无需关注
少量线程交替执行 轻量级锁(默认) CAS自旋开销小
大量线程激烈竞争 考虑禁用偏向锁 减少撤销开销
锁内代码执行时间长 使用重量级锁 避免自旋浪费CPU

6.2 常见性能问题及解决方案

问题1:偏向锁撤销频繁

bash

复制代码
# 查看偏向锁撤销统计
-XX:+PrintBiasedLockingStatistics

解决方案

  • 如果撤销频繁,禁用偏向锁:-XX:-UseBiasedLocking

  • 检查是否在对象上调用了hashCode()

问题2:轻量级锁自旋消耗CPU

bash

复制代码
# 查看自旋统计
-XX:+PrintSafepointStatistics

解决方案

  • 调整自旋次数(JDK6前):-XX:PreBlockSpin=10

  • JDK6+自适应自旋,通常不需要调整

  • 如果锁持有时间长,让锁快速升级为重量级锁

6.3 一个经典的优化案例

优化前

java

复制代码
public synchronized void update() {
    // 读共享变量
    int current = this.value;
    // 大量计算(不涉及共享变量)
    int result = heavyCompute(current);
    // 写共享变量
    this.value = result;
}

优化后(锁粗化+锁粒度细化)

java

复制代码
public void update() {
    int current;
    synchronized (this) {
        current = this.value;  // 只保护读
    }
    // 计算移到锁外
    int result = heavyCompute(current);
    synchronized (this) {
        this.value = result;   // 只保护写
    }
}

效果:锁持有时间从200ms减少到10ms,吞吐量提升20倍。


七、synchronized与ReentrantLock的深度对比

7.1 底层实现的本质差异

维度 synchronized ReentrantLock
实现层级 JVM层(C++) JDK层(Java)
锁状态存储 对象头Mark Word AbstractQueuedSynchronizer中的state
线程阻塞 ObjectMonitor::enter()调用park() LockSupport.park()
公平性 非公平(JVM参数可调但不推荐) 构造参数设置
条件队列 一个(WaitSet 多个(newCondition()

7.2 性能对比(JDK 1.8+,实测数据)

场景 synchronized ReentrantLock
单线程(无竞争) 偏向锁,约5ns 公平锁约20ns,非公平约15ns
低竞争(2线程) 轻量级锁,约50ns 约60-80ns
高竞争(16线程) 重量级锁,约2-3μs 约2-3μs(接近)

7.3 选择建议

优先synchronized的场景

  • 代码简单,不需要高级功能

  • 单线程或低竞争场景

  • 希望利用JVM持续优化

使用ReentrantLock的场景

  • 需要可中断获取锁(lockInterruptibly()

  • 需要超时获取锁(tryLock(timeout)

  • 需要公平锁

  • 需要多个条件变量(Condition

  • 需要查询锁状态(isHeldByCurrentThread()


八、面试高频追问与深度回答

Q1:synchronized方法抛出异常后锁会释放吗?

:会释放。JVM编译时在异常表中插入了monitorexit指令,异常路径也会执行锁释放。这就是字节码中有两个monitorexit的原因。

Q2:synchronized锁的是对象还是代码?

:锁的是对象(确切地说是对象的Monitor)。两个synchronized方法如果锁的是同一个对象,就会互斥,无论方法名是什么。

Q3:String作为锁对象有什么风险?

  1. 字符串常量池共享"lock"字面量可能被其他代码意外共享

  2. hashCode影响偏向锁:String的hashCode是缓存的,可能导致偏向锁失效

  3. 建议 :使用new Object()作为专用锁对象

Q4:偏向锁为什么要有延迟启动?

:JVM启动初期,有大量系统类加载,这些类的锁竞争概率低但撤销成本高。延迟4秒启动偏向锁,避免早期无意义的偏向锁撤销。

Q5:如何查看当前锁状态?

java

复制代码
// 借助jol工具
<dependency>
    <groupId>org.openjdk.jol</groupId>
    <artifactId>jol-core</artifactId>
    <version>0.17</version>
</dependency>

// 打印对象头信息
System.out.println(ClassLayout.parseInstance(lock).toPrintable());

九、总结:synchronized的完整知识图谱

text

复制代码
synchronized底层原理
│
├── 存储层:对象头Mark Word
│   ├── 锁状态位(2位):01(无锁/偏向) / 00(轻量) / 10(重量)
│   ├── 偏向锁:存储线程ID
│   ├── 轻量级锁:指向栈中锁记录
│   └── 重量级锁:指向Monitor对象
│
├── 字节码层:monitorenter / monitorexit
│   ├── 正常退出:一个monitorexit
│   └── 异常退出:第二个monitorexit(保证锁释放)
│
├── 核心引擎:Monitor(ObjectMonitor)
│   ├── _owner:当前持有锁的线程
│   ├── _count:重入计数器
│   ├── _EntryList:等待获取锁的线程队列
│   └── _WaitSet:调用wait()的线程队列
│
├── 锁升级机制(JDK 1.6+)
│   ├── 无锁 → 偏向锁:单线程重复获取
│   ├── 偏向锁 → 轻量级锁:第二个线程竞争
│   └── 轻量级锁 → 重量级锁:自旋失败
│
├── JVM优化
│   ├── 锁消除:逃逸分析消除无用锁
│   ├── 锁粗化:合并连续同步块
│   └── 自适应自旋:动态调整自旋次数
│
└── 三大特性保证
    ├── 原子性:Monitor互斥
    ├── 可见性:happens-before规则
    └── 有序性:内存屏障

核心结论

  1. synchronized是JVM内置锁,锁信息存储在对象头的Mark Word中

  2. Monitor是核心引擎 ,通过_owner_count_EntryList实现互斥和重入

  3. 锁升级机制从偏向锁→轻量级锁→重量级锁,按需升级,是JDK 1.6的核心优化

  4. JVM持续优化synchronized,简单场景性能已接近ReentrantLock

  5. 理解底层才能写出正确且高效的并发代码

synchronized看似基础,但深入理解其对象头、Monitor、锁升级机制,是成为Java并发编程高手的必经之路。希望这份深度整理能帮你建立起完整的知识体系,在面试和实战中游刃有余。

相关推荐
庞轩px2 小时前
线程池核心参数与拒绝策略深度解析
java·jvm·数据库
干啥啥不行,秃头第一名2 小时前
Python深度学习入门:TensorFlow 2.0/Keras实战
jvm·数据库·python
无风听海3 小时前
LangGraph Thread 数据清理总结
java·开发语言·jvm·langchain·deep agents
小王不爱笑1323 小时前
JVM 核心面试题全解析
java·开发语言·jvm
小王不爱笑1323 小时前
JVM 方法区:从永久代到元空间的核心逻辑
jvm
庞轩px4 小时前
ThreadLocal 源码分析与内存泄漏问题
java·jvm·线程·threadlocal·内存泄露·key-value
小王不爱笑1324 小时前
JVM 堆体系
jvm