synchronized 详解
👉入门代码跳转快速入门查看
synchronized 底层实现原理分析
synchronized 是 JVM 内置锁,基于 Monitor(管程)机制实现,其底层依赖于操作系统的互斥原语 Mutex(互斥量)。正因为依赖于操作系统层面的调度,早期的 synchronized 是一个重量级锁,性能相对较低。
在方法级同步和同步代码块中,JVM 的实现方式有所不同:
- 同步方法:通过方法中的
access_flags设置ACC_SYNCHRONIZED标志来实现。 - 同步代码块:通过
monitorenter和monitorexit两条指令来实现。
重量级锁实现之 Monitor(管程/监视器)机制详解
Monitor,直译为“监视器”,在操作系统领域一般翻译为“管程”。管程是指管理共享变量以及对共享变量操作的过程,让它们支持并发。在 Java 1.5 之前,Java 语言提供的唯一并发原语就是管程,Java 1.5 之后提供的 J.U.C 并发包也是以管程为基础的。除了 Java 之外,C/C++、C# 等高级语言也都支持管程。synchronized 关键字以及 wait()、notify()、notifyAll() 这三个方法是 Java 中实现管程技术的组成部分。
Monitor 设计思路与 MESA 模型分析
管程(Monitor)是一种用于管理共享资源访问的程序结构,能确保同一时刻只有一个线程访问共享资源,解决并发编程中的互斥和同步问题。MESA 模型是管程的经典实现,主要由入口等待队列和条件变量等待队列构成。
1)入口等待队列:确保线程互斥,多个线程试图进入管程时,仅一个线程能成功,其余线程在入口等待队列中排队。
2)条件变量等待队列:解决线程同步问题,线程在管程内执行时,若条件不满足需等待其他线程操作结果,则进入相应条件变量的等待队列。
当线程被 notify 或 notifyAll 唤醒后,不会立即执行,而是先进入入口等待队列竞争管程的锁。只有竞争到锁后,线程才能继续执行。因此,被唤醒的线程需循环检验条件是否满足,即采用while (条件不满足) { wait(); } 的编程范式,以避免条件不一致问题。

管程中引入了条件变量的概念,且每个条件变量都对应有一个等待队列。条件变量和等待队列的核心作用是解决线程之间的同步问题。
Java 参考了 MESA 模型,其内置的管程(synchronized)对 MESA 模型进行了精简。在原版的 MESA 模型中,条件变量可以有多个,而在 Java 语言内置的管程里只有一个条件变量。模型如下图所示:

管程同步机制示例代码:
@Slf4j
public class WaitDemo {
final static Object obj = new Object();
public static void main(String[] args) throws InterruptedException {
new Thread(() -> {
log.debug("t1开始执行....");
synchronized (obj) {
log.debug("t1获取锁....");
try {
// 让线程在obj上一直等待下去
obj.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
log.debug("t1执行完成....");
}
},"t1").start();
new Thread(() -> {
log.debug("t2开始执行....");
synchronized (obj) {
log.debug("t2获取锁....");
try {
// 让线程在obj上一直等待下去
obj.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
log.debug("t2执行完成....");
}
},"t2").start();
// 主线程两秒后执行
Thread.sleep(2000);
log.debug("准备获取锁,去唤醒 obj 上阻塞的线程");
synchronized (obj) {
// 唤醒obj上一个线程
//obj.notify();
// 唤醒obj上所有等待线程
obj.notifyAll();
log.debug("唤醒 obj 上阻塞的线程");
}
}
}结果:
19:04:36.714 [t2] DEBUG com.juzicoding.sync.details.WaitDemo -- t2开始执行....
19:04:36.714 [t1] DEBUG com.juzicoding.sync.details.WaitDemo -- t1开始执行....
19:04:36.715 [t2] DEBUG com.juzicoding.sync.details.WaitDemo -- t2获取锁....
19:04:36.715 [t1] DEBUG com.juzicoding.sync.details.WaitDemo -- t1获取锁....
19:04:38.718 [main] DEBUG com.juzicoding.sync.details.WaitDemo -- 准备获取锁,去唤醒 obj 上阻塞的线程
19:04:38.720 [main] DEBUG com.juzicoding.sync.details.WaitDemo -- 唤醒 obj 上阻塞的线程
19:04:38.722 [t2] DEBUG com.juzicoding.sync.details.WaitDemo -- t2执行完成....
19:04:38.722 [t1] DEBUG com.juzicoding.sync.details.WaitDemo -- t1执行完成....ObjectMonitor 数据结构分析
java.lang.Object 类定义了 wait(),notify(),notifyAll() 方法,这些方法的具体实现依赖于 ObjectMonitor,这是 JVM 内部基于 C++ 实现的一套机制。
ObjectMonitor 的主要数据结构如下(摘自 hotspot 源码 ObjectMonitor.hpp):
ObjectMonitor() {
_header = NULL; // 对象头 markOop
_count = 0;
_waiters = 0,
_recursions = 0; // 锁的重入次数
_object = NULL; // 存储锁对象
_owner = NULL; // 标识拥有该monitor的线程(当前获取锁的线程)
_WaitSet = NULL; // 等待线程(调用wait)组成的双向循环链表,_WaitSet是第一个节点
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ; // 多线程竞争锁会先存到这个单向链表中 (FILO栈结构)
FreeNext = NULL ;
_EntryList = NULL ; // 存放在进入或重新进入时被阻塞(blocked)的线程 (也是存竞争锁失败的线程)
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
_previous_owner_tid = 0;
}重量级锁实现原理
synchronized 底层是利用 monitor 对象、CAS 以及 mutex 互斥锁来实现的。其内部维护了等待队列(_cxq 和 _EntryList)以及条件等待队列(_WaitSet)来存放相应阻塞的线程。
未竞争到锁的线程会被存储到等待队列中,获得锁的线程调用 wait 后会被存放到条件等待队列中。解锁和 notify 操作都会唤醒相应队列中的等待线程来重新争抢锁。由于阻塞和唤醒依赖于底层的操作系统实现,系统调用存在用户态与内核态之间的频繁切换,会有较高的性能开销,因此被称之为“重量级锁”。

在获取锁时,JVM 会将当前线程插入到 _cxq 的头部。而释放锁时,默认策略(QMode=0)是:如果 _EntryList 为空,则将 _cxq 中的元素按原有顺序插入到 _EntryList,并唤醒第一个线程。换言之,当 _EntryList 为空时,后来的线程反而会先获取锁。如果 _EntryList 不为空,则直接从 _EntryList 中唤醒线程。
为什么需要 _cxq 和 _EntryList 两个队列来存放线程?
因为在激烈竞争下,会有大量线程同时竞争锁。引入 _cxq 这个基于 CAS 的单向链表可以快速地 hold 住这些并发请求;而另外设计的 _EntryList 双向链表,则可以在每次唤醒时批量搬迁一些线程节点,从而有效降低 _cxq 尾部的并发竞争压力。
重量级锁的优化策略
JVM 内置锁在 JDK 1.5 之后版本做了重大的优化,引入了如锁粗化(Lock Coarsening)、锁消除(Lock Elimination)、轻量级锁(Lightweight Locking)、偏向锁(Biased Locking)、自适应自旋(Adaptive Spinning)等技术来减少锁操作的开销。经过这些优化,内置锁的并发性能已经基本与 ReentrantLock 持平。
锁粗化
锁粗化,简而言之就是将多个连续的加锁、解锁操作扩展为一个更大范围的锁。如果 JVM 检测到有一连串对同一对象的连续加锁、解锁操作,就会将其合并为一次范围更大的同步块。
加锁方式对比:
// 优化前:频繁加锁解锁
synchronized (lock) {
// 代码块 1
}
// 无关代码
synchronized (lock) {
// 代码块 2
}
// JVM 在运行时锁粗化优化后:
synchronized (lock) {
// 代码块 1
// 无关代码
// 代码块 2
}为什么锁粗化有效?
每次加锁和解锁都可能涉及线程切换和调度等开销。如果有大量微小的同步块频繁进行加锁/解锁,累积的开销会降低程序效率。通过锁粗化,能够大幅减少这类非必要的系统开销。
示例:
StringBuffer buffer = new StringBuffer();
/**
* 锁粗化场景
*/
public void append(){
// JVM检测到连续对buffer进行append(其内部有锁),会将其合并为一次范围更大的加锁操作
buffer.append("aaa").append(" bbb").append(" ccc");
}锁消除
锁消除主要应用在没有多线程竞争的局部上下文中。当一个数据的作用域仅限于单个线程内部时,该线程对数据的所有操作都不需要加锁。在 HotSpot JVM 中,这种优化主要是依赖**逃逸分析(Escape Analysis)**来实现的。
示例代码:
public class LockEliminationTest {
/**
* 锁消除
* -XX:+EliminateLocks 开启锁消除(jdk8默认开启)
* -XX:-EliminateLocks 关闭锁消除
*/
public void append(String str1, String str2) {
// stringBuffer 属于局部变量,不会逃逸出当前方法
StringBuffer stringBuffer = new StringBuffer();
stringBuffer.append(str1).append(str2);
}
public static void main(String[] args) throws InterruptedException {
LockEliminationTest demo = new LockEliminationTest();
long start = System.currentTimeMillis();
for (int i = 0; i < 100000000; i++) {
demo.append("aaa", "bbb");
}
long end = System.currentTimeMillis();
System.out.println("执行时间:" + (end - start) + " ms");
}
}测试结果对比:关闭锁消除执行时间 2347 ms,开启锁消除执行时间仅 735 ms,StringBuffer 的 append 是个同步方法,但在上述代码中 stringBuffer 属于局部变量,不可能被其他线程访问。JIT 编译器在运行时会发现这种情况,并自动消除内部的锁竞争。
CAS 自旋优化
在重量级锁竞争时,如果持有锁的线程很快就会释放锁,当前线程可以通过“自旋”(即循环等待)来避免被阻塞。自旋虽然会占用 CPU 时间,但在多核 CPU 环境下,适当的自旋可以有效避免线程挂起和恢复带来的系统内核切换开销。
在 Java 6 之后,自旋锁被优化为自适应自旋。即如果该对象刚刚的一次自旋操作成功过,JVM 就会认为这次自旋成功的概率较高,从而允许增加自旋次数;反之,如果很少成功,则会减少自旋次数甚至直接挂起线程,显得十分智能。
偏向锁
我们再思考一下,是否有这样的场景:一开始一直只有一个线程持有这个锁,也不会有其他线程来竞争,此时频繁的 CAS 是没有必要的,CAS 也是有开销的。所以 synchronized 就搞了个偏向锁,就是偏向一个线程,那么这个线程就可以直接获得锁。对于没有锁竞争的场合,偏向锁有很好的优化效果,可以消除锁重入(CAS操作)带来的开销。
轻量级锁
我们再思考一下,是否有这样的场景:多个线程都是在不同的时间段来请求同一把锁,此时根本就用不需要阻塞线程,连 monitor 对象都不需要,所以就引入了轻量级锁这个概念,避免了系统调用,减少了开销。
在锁竞争不激烈的情况下,这种场景还是很常见的,可能是常态,所以轻量级锁的引入很有必要。
轻量级锁是否存在自旋问题分析
- 错误的理解:轻量级锁加锁失败会自旋,失败一定次数后会膨胀升级为重量级锁
- 正确理解:轻量级锁不存在自旋,只有重量级锁加锁失败才会自旋。重量级锁加锁失败,会多次尝试cas和自适应自旋,如果一直加锁失败,就会阻塞当前线程等待唤醒
轻量级锁竞争没有自旋的原因其实是其设计并不是用于处理过于激烈的竞争场景,而是为了应对线程之间交替获取锁的场景。
synchronized 锁升级详解
synchronized 加锁是加在对象上的,那么对象是如何记录锁状态的?判断是否加锁成功的信息记录在哪里?
在 Hotspot 虚拟机中,对象在内存中的存储布局可以分为三块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding),而锁状态存储在对象头的 Mark Word 中。

- 对象头:包含哈希码、GC 分代年龄、锁状态标志、偏向线程 ID、数组长度(仅数组对象有)等。
- 实例数据:存放类中定义的各种属性数据,包括继承自父类的属性。
- 对齐填充:虚拟机要求对象的起始地址必须是 8 字节的整数倍,这部分仅起字节对齐作用。
为什么会有锁升级
在 JDK 1.6 之前,synchronized 是纯粹的重量级锁。只要加上这把锁,底层就会调用操作系统的互斥量(Mutex),导致线程在用户态和内核态之间频繁切换,非常消耗性能。
但 Java 开发者发现:在真实业务中,大多数情况下一把锁不仅没有激烈的并发竞争,甚至经常是由同一个线程反复获取。 每次都动用操作系统显得“杀鸡用牛刀”。 因此,在 JDK 1.6 引入了锁升级机制,目的就是尽量避免直接使用重量级锁,通过在对象头(Mark Word)里做标记来提升性能。
tip 锁升级的四种状态和流转条件
锁的升级是单向的:无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁。
第一步:偏向锁(单线程场景)
- 场景:这段同步代码从头到尾其实只有一个线程在玩。
- 逻辑:线程第一次进来时,只需要在锁对象头上贴个标签(记录自己的线程 ID)。以后它再来,只要看一眼对象头上的 ID 是自己,就直接放行。
- 优点:没有任何加锁解锁的消耗,速度最快。
- 注意:由于维护成本过高,且现代多线程应用场景下收益变低,JDK 15 已经默认废弃了偏向锁)
第二步:轻量级锁(交替执行场景)
- 场景:出现了另外的线程,但大家很讲武德,没有在同一时刻去抢。比如线程 A 上午执行,线程 B 下午执行,属于交替执行。
- 逻辑:一旦有第二个线程试图获取锁,偏向锁就会升级为轻量级锁。线程会在自己的栈空间里建一个“锁记录(Lock Record)”,然后尝试用 CAS 操作把锁对象头指向自己。
- 优点:全程在用户态完成,没有惊动操作系统,没有线程挂起。
第三步:重量级锁(并发火拼场景)
- 场景:情况恶化,两个或多个线程在同一时刻硬抢这把锁,发生了真正的物理冲突。
- 逻辑:轻量级锁的 CAS 操作失败,说明扛不住了,锁直接膨胀为重量级锁。这时候没抢到锁的线程只能乖乖去排队(阻塞挂起),这也就是传统的、依赖操作系统的加锁机制。
- 附带优化(自旋):为了防止线程刚被挂起,别人就释放锁了(挂起和唤醒太慢),没抢到锁的线程会先在原地自旋(空循环)几圈,如果等到了就直接进去,等不到再挂起。
简单来说:
synchronized锁升级是为了优化 JDK 1.6 之前的重量级锁性能问题。它把锁分为了四个状态,记录在对象的 Mark Word 里;- 最开始是偏向锁,用于只有一个线程一直获取锁的场景,只记录线程ID,基本零开销 (注:由于维护成本过高,且现代多线程应用场景下收益变低,JDK 15 已经默认废弃了偏向锁);
- 当有其他线程来交替获取锁时,升级为轻量级锁,通过 CAS 操作在用户态解决同步,避免操作系统干预;
- 当出现真正的并发竞争,也就是同一时刻大家一起抢锁时,就会升级为重量级锁,未获取到锁的线程会进入自旋,自旋失败后才会被阻塞挂起。 这种按需升级的设计,极大提升了不同竞争程度下的程序性能。