Lazy loaded image
Java丨synchronized 和 ReentrantLock:从 Monitor、锁升级到死锁排查
Words 10245Read Time 26 min
2025-10-12
type
Post
status
Published
date
Oct 12, 2025
slug
java5
summary
tags
技术探索
category
icon
password
Java 并发里,锁是绕不开的核心。
只要多个线程会同时访问共享资源,就要考虑一个问题:
谁可以访问?
什么时候可以访问?
访问期间其他线程怎么办?
出现异常后锁能不能释放?
等锁的线程会阻塞在哪里?
锁竞争激烈时性能会不会急剧下降?
线上出现线程卡死时怎么定位?
Java 里最常见的两类锁是:
synchronized
ReentrantLock
这篇文章就围绕它们展开,把对象锁、类锁、Monitor、锁升级、锁优化、ReentrantLock 源码、死锁和实际使用建议讲清楚。

一、为什么需要锁?

先看一个最简单的问题。
多个线程同时执行 count++
count++ 看起来是一行代码,但它不是原子操作。
它至少包含三步:
第一步,读取 count
第二步,把读取到的值加 1。
第三步,把结果写回 count
多个线程交错执行时,就可能出现丢失更新。
所以需要锁来保护临界区。
锁的本质是:
在多个线程访问共享资源时,让某段代码同一时刻只被一个线程执行。

二、synchronized 的三种用法

synchronized 常见有三种使用方式。

1. 修饰实例方法

这里锁的是当前对象,也就是 this
如果有两个不同的 UserService 实例,它们之间互不影响。
关键点:
synchronized 实例方法锁的是 this
同一个对象上的同步实例方法互斥。
不同对象之间不是同一把锁。

2. 修饰静态方法

静态同步方法锁的是类对象,也就是 Class 对象。
关键点:
static synchronized 锁的是 Class 对象。
同一个类的静态同步方法共用同一把类锁。
类锁和对象锁不是同一把锁。

3. 修饰代码块

同步代码块锁的是括号里的对象。
这种方式比同步方法更灵活,因为可以控制锁粒度。
这种写法的好处是:
订单逻辑和库存逻辑用不同的锁。
两类操作互不影响。
可以减少不必要的竞争。

三、对象锁和类锁不是同一把锁

看一段代码:
这两个线程可以同时执行。
原因是:
instanceMethod() 锁的是 demo 这个实例对象。
staticMethod() 锁的是 ObjectAndClassLockDemo.class
它们不是同一把锁,所以不会互斥。
这个点在实际代码中很容易被忽略。
如果本来想保护同一份共享资源,却一个地方用了对象锁,一个地方用了类锁,就可能导致锁没有真正生效。

四、synchronized 是可重入锁

synchronized 是可重入的。
也就是说,同一个线程已经拿到一把锁后,可以再次进入同一把锁保护的代码,不会被自己阻塞。
如果锁不可重入,上面代码会出现自己等自己的情况。
可重入的意义是:
同一个线程重复获取同一把锁不会死锁。
锁内部会记录当前持有线程和重入次数。
进入几次,就要退出几次,锁才真正释放。

五、synchronized 的字节码原理

synchronized 修饰代码块时,编译后会出现 monitorentermonitorexit 指令。
示例代码:
对应的字节码结构大致如下:
这段字节码有几个关键点:
monitorenter 表示进入同步块。
monitorexit 表示退出同步块。
正常路径有一次 monitorexit
异常路径也有一次 monitorexit
所以 synchronized 在异常情况下也能自动释放锁。
这就是 synchronized 相比手动锁的一个优势:
不需要显式写 unlock()
JVM 会保证同步块退出时释放锁。

六、synchronized 修饰方法的字节码

如果 synchronized 修饰方法,不会在方法体里直接看到 monitorentermonitorexit
示例:
字节码中方法会带有 ACC_SYNCHRONIZED 标记。
关键点:
同步代码块通过 monitorentermonitorexit 实现。
同步方法通过方法访问标志 ACC_SYNCHRONIZED 实现。
二者底层都和对象监视器有关。

七、Monitor 是什么?

每个 Java 对象都可以作为锁。
当一个线程进入 synchronized(obj) 时,本质上是尝试获取 obj 对应的 Monitor。
可以简单理解为:
对象是锁的载体。
Monitor 是锁的底层同步结构。
线程竞争 synchronized 时,会围绕对象的 Monitor 进行加锁、阻塞、唤醒。
可以用一个简化结构理解 Monitor:
这不是 HotSpot 的完整源码,只是帮助理解的简化模型。
重点是区分两类等待:
竞争锁失败的线程,在等待进入 Monitor。
已经拿到锁后调用 wait() 的线程,会释放锁并进入 WaitSet。
调用 notify() 后,线程不是立刻执行,而是要重新竞争锁。

八、wait/notify 和 synchronized 的关系

wait()notify()notifyAll() 必须在 synchronized 中使用。
原因是它们依赖对象 Monitor。
这里有几个关键点:
wait() 必须先持有对象锁。
wait() 会释放对象锁。
notify() 不会立即释放对象锁。
被唤醒线程要重新竞争锁。
判断条件必须用 while,不能用 if

九、synchronized 保证了什么?

synchronized 不只是互斥。
它同时提供:
原子性。
可见性。
有序性。
看一个例子:
锁规则可以这样理解:
对同一把锁的解锁,happens-before 后续对这把锁的加锁。
所以 synchronized 能保证可见性。

十、synchronized 锁升级的大致过程

synchronized 早期给人的印象是“重量级锁”,但 JVM 做了很多优化。
经典锁状态大致可以理解为:
无锁。
偏向锁。
轻量级锁。
重量级锁。
不过要注意:
不同 JDK 版本里,锁优化策略会有差异。
偏向锁在较新的 JDK 中已经逐渐淡出。
理解锁升级的思路,比死记某个版本的实现细节更重要。

十一、对象头和 Mark Word

Java 对象在内存中会有对象头。
对象头里有一部分叫 Mark Word,用来存储对象运行时信息。
可以简单理解为,Mark Word 里可能存放:
哈希码。
GC 年龄。
锁标志位。
偏向线程 ID。
指向锁记录的指针。
指向重量级 Monitor 的指针。
对象头不是普通 Java 代码能直接访问的,但它和 synchronized 的锁状态密切相关。
为了理解锁升级,可以把对象头想象成这样:
重点不是记住每一位怎么分布,而是理解:
Java 对象本身可以作为锁。
锁状态会记录在对象头或关联的 Monitor 中。
JVM 会根据竞争情况选择不同锁形态。

十二、偏向锁:适合同一线程反复进入

偏向锁的目标是优化这种场景:
一把锁总是被同一个线程反复获取。
几乎没有其他线程竞争。
如果每次进入同步块都做 CAS,就有些浪费。
偏向锁的思路是:让锁“偏向”第一个获取它的线程。
简化理解:
第一次线程 A 获取锁时,锁记录偏向线程 A。
后续线程 A 再进入同步块,只需要检查是否偏向自己。
如果仍然是自己,就可以快速进入。
如果线程 B 也来竞争,就需要撤销偏向,进入后续锁状态。
示例场景:
不过再强调一次:
偏向锁是 JVM 锁优化中的历史重要机制。
在较新 JDK 中,它的默认行为和存在状态已经发生变化。
日常更应该关注锁竞争是否严重、锁粒度是否合理、锁内逻辑是否过重。

十三、轻量级锁:适合短时间少量竞争

轻量级锁适合这种场景:
多个线程可能竞争同一把锁。
但竞争时间很短。
锁很快就会被释放。
轻量级锁一般会通过 CAS 尝试获取锁。
简化理解:
线程进入同步块时,在自己的栈帧中创建锁记录。
尝试用 CAS 把对象头 Mark Word 指向自己的锁记录。
CAS 成功,说明获取锁成功。
CAS 失败,说明存在竞争。
竞争不严重时,可以先自旋等待。
竞争严重时,可能膨胀为重量级锁。
可以用伪代码理解:
这段伪代码不是 Java 标准库源码,只是帮助理解轻量级锁思想。
轻量级锁的核心是:
尽量不让线程进入操作系统阻塞。
先用 CAS 和自旋解决短时间竞争。
如果竞争持续存在,再升级为重量级锁。

十四、重量级锁:竞争激烈时阻塞线程

当竞争比较激烈时,锁可能升级为重量级锁。
重量级锁会关联到 Monitor,竞争失败的线程可能进入阻塞状态,需要操作系统参与挂起和唤醒。
可以简单理解:
轻量级锁主要靠 CAS 和自旋。
重量级锁会让线程阻塞。
阻塞和唤醒涉及系统调度,成本更高。
但竞争激烈时,阻塞比一直自旋浪费 CPU 更合适。
示例:
这个例子里锁持有时间很长,很多线程都在等待同一把锁。
这类场景应该重点优化业务代码,而不是纠结锁本身。

十五、锁升级能不能降级?

通常讲 synchronized 锁升级时,会说:
无锁 → 偏向锁 → 轻量级锁 → 重量级锁
这个方向通常是随竞争增强而升级。
一般理解为:
锁升级是为了适应竞争变强。
重量级锁不会因为某一刻竞争变少,就立刻回到轻量级锁。
JVM 可能在安全点、GC 等特殊阶段做一些状态调整,但业务层面不要依赖所谓“锁降级”。
所以写代码时更重要的是减少不必要竞争。

十六、JVM 的锁优化

除了锁升级,JVM 还会做一些锁优化。
常见包括:
锁消除。
锁粗化。
自适应自旋。

1. 锁消除

如果 JVM 通过逃逸分析发现某个对象不会被其他线程访问,那么这个对象上的锁可能没有必要。
示例:
锁消除的核心是:
对象没有逃逸。
不可能被其他线程共享。
加锁没有意义。
JVM 可以优化掉锁。

2. 锁粗化

如果一段代码中频繁对同一个对象加锁和释放锁,JVM 可能把多个小锁合并成一个大锁。
示例:
锁粗化不是让锁粒度越大越好。
它只是 JVM 在特定场景下为了减少频繁加锁释放做的优化。
人工写代码时仍然应该遵循:
锁范围尽量小。
只保护真正需要同步的共享资源。
不要把慢操作随便放进锁里。

3. 自适应自旋

线程竞争锁失败后,不一定马上阻塞。
如果 JVM 认为锁很快会释放,线程可以先自旋一会儿。
自旋的好处是:
避免线程挂起和唤醒的系统调用成本。
如果锁很快释放,自旋线程可以很快拿到锁。
自旋的坏处是:
自旋期间线程会消耗 CPU。
如果锁长时间不释放,自旋就是浪费。
所以 JVM 会根据历史情况做自适应调整。
可以这样理解:
如果某把锁过去自旋成功率高,就可以多自旋一会儿。
如果过去自旋经常失败,就减少自旋,尽快阻塞。

十七、ReentrantLock 基本用法

ReentrantLock 是 JUC 包下的显式锁。
基本写法如下:
ReentrantLock 最大的使用要求是:
手动加锁。
手动释放。
unlock() 必须放在 finally 中。

十八、ReentrantLock 为什么也叫可重入锁?

synchronized 一样,ReentrantLock 也是可重入锁。
这里有一个非常重要的点:
加锁几次,就必须解锁几次。
如果 methodB() 中少了一次 unlock(),锁就不会真正释放。

十九、ReentrantLock 的核心源码结构

ReentrantLock 底层基于 AQS。
简化结构如下:
核心关系是:
ReentrantLock 是对外 API。
Sync 是内部同步器。
Sync 继承 AQS。
AQS 负责状态、队列、阻塞、唤醒。
ReentrantLockstate 表示重入次数。

二十、非公平锁源码理解

ReentrantLock 默认是非公平锁。
简化源码如下:
nonfairTryAcquire() 的核心逻辑:
这段源码说明:
state = 0,表示锁未被占用。
state > 0,表示锁已被占用。
同一个线程重复加锁,state 递增。
其他线程加锁失败,会进入 AQS 队列等待。

二十一、公平锁源码理解

公平锁会先判断队列里有没有前驱节点。
简化源码如下:
公平锁和非公平锁的核心区别:
非公平锁:先抢一下,抢不到再排队。
公平锁:先看队列前面有没有人,没人再抢。
公平锁更符合先来先服务,但性能通常比非公平锁低。
原因是:
公平锁更容易产生线程切换。
非公平锁允许刚好运行中的线程直接获取锁,减少唤醒和调度成本。
非公平锁吞吐通常更高,但极端情况下可能出现线程饥饿。

二十二、ReentrantLock 释放锁源码

释放锁最终会调用 AQS 的 release(1)
tryRelease() 的核心逻辑如下:
这段源码说明:
unlock() 必须由持锁线程调用。
重入锁要释放到 state = 0 才算真正释放。
锁完全释放后,AQS 才会唤醒后继等待线程。

二十三、tryLock:拿不到锁就不等

ReentrantLock 支持 tryLock()
tryLock() 适合:
允许快速失败的场景。
避免线程长时间阻塞。
做降级、重试、跳过任务。
减少死锁风险。

二十四、tryLock(timeout):等待一段时间

tryLock() 还可以指定等待时间。
这个能力是 synchronized 不直接具备的。
适合:
接口降级。
防止请求堆积。
避免死等锁。
控制最大等待时间。

二十五、lockInterruptibly:等待锁时可中断

lockInterruptibly() 允许线程在等待锁时响应中断。
这个能力适合:
任务可以取消的场景。
等锁时间不可控的场景。
需要响应线程中断的场景。

二十六、Condition:多个条件队列

ReentrantLock 可以配合 Condition 使用。
相比 wait/notifyCondition 的优势是可以有多个条件队列。
下面用 Condition 实现一个简单阻塞队列。
Condition 可以这样理解:
wait/notify 是一个对象对应一个等待集合。
Condition 可以让一把锁对应多个条件队列。
await() 类似 wait(),会释放锁。
signal() 类似 notify(),但可以精准唤醒某个条件队列上的线程。

二十七、synchronized 和 ReentrantLock 的区别

二者共同点:
都是互斥锁。
都是可重入锁。
都能保证临界区线程安全。
都能提供可见性保障。
主要区别:
synchronized 是 JVM 内置锁。
ReentrantLock 是 JUC 提供的显式锁。
synchronized 自动释放锁。
ReentrantLock 必须手动 unlock()
synchronized 使用简单。
ReentrantLock 功能更丰富。
ReentrantLock 支持公平锁。
ReentrantLock 支持 tryLock()
ReentrantLock 支持超时等待。
ReentrantLock 支持可中断加锁。
ReentrantLock 支持多个 Condition 条件队列。
选择建议:
简单同步优先用 synchronized
需要超时、可中断、公平锁、多个条件队列时,用 ReentrantLock
如果必须手动控制锁获取和释放流程,用 ReentrantLock
如果只是保护一个小临界区,synchronized 更简洁。

二十八、什么是死锁?

死锁是指多个线程互相等待对方持有的资源,导致都无法继续执行。
典型例子:
这段代码可能出现:
线程 t1 持有 LOCK_A,等待 LOCK_B
线程 t2 持有 LOCK_B,等待 LOCK_A
两个线程互相等待,谁也无法继续。

二十九、死锁的四个必要条件

死锁通常需要同时满足四个条件:
互斥:资源同一时刻只能被一个线程持有。
占有且等待:线程持有一个资源,同时等待其他资源。
不可抢占:资源不能被强制剥夺,只能由持有者释放。
循环等待:多个线程之间形成环形等待关系。
只要破坏其中一个条件,就可以避免死锁。
实际开发中,最常用的是破坏循环等待。

三十、通过固定加锁顺序避免死锁

如果多个线程都需要获取多把锁,最有效的方法之一是固定加锁顺序。
错误方式是:
线程 A:先锁资源 1,再锁资源 2。
线程 B:先锁资源 2,再锁资源 1。
正确方式是:
所有线程都按同样顺序加锁。
比如永远先锁 ID 小的资源,再锁 ID 大的资源。
示例:
这种方式在转账、库存调拨、资源交换类场景很常见。

三十一、用 tryLock 降低死锁风险

ReentrantLocktryLock(timeout) 可以避免线程无限等待。
这个写法的价值是:
拿不到锁就放弃。
不让线程无限等待。
可以重试、降级或返回失败。
能降低死锁造成的系统卡死风险。

三十二、不要在锁里做慢操作

锁内做慢操作,是线上性能问题的高发原因。
错误示例:
更好的方式是缩小锁范围。
锁内代码应该尽量满足:
执行快。
不做 RPC。
不做慢 SQL。
不做文件 IO。
不调用不可控的外部逻辑。
只保护必要的共享状态。

三十三、线上如何看死锁?

Java 线程死锁可以通过线程栈排查。
常见工具包括:
jstack
jcmd Thread.print
Arthas 的 thread 命令。
监控平台的线程 dump 功能。
如果发生 Java 级别死锁,线程栈中可能看到类似信息:
排查时重点看:
哪些线程互相等待?
等待的是哪几把锁?
锁对象对应哪段业务代码?
是否存在加锁顺序不一致?
是否在锁里做了慢操作?
是否可以用超时锁或拆分锁粒度优化?

三十四、synchronized 和 ReentrantLock 使用建议

1. 优先保证正确性

不要为了追求所谓性能,过早使用复杂锁。
如果只是简单临界区:
synchronized 就足够。
代码短。
自动释放。
可读性好。
出错概率低。

2. 锁对象要私有且不可变

推荐这样写:
不推荐锁字符串常量:
也要谨慎锁公开对象:
更推荐:
使用 private final Object lock = new Object()
不锁字符串常量。
不锁可变对象。
不把锁对象暴露给外部。

3. ReentrantLock 必须在 finally 中释放

错误写法:
正确写法:

4. 尽量降低锁粒度

锁粒度过大,会降低并发度。
如果只有 step2() 需要保护,就不要把所有逻辑都放进锁里。

三十五、总结

synchronized 是 JVM 内置锁。
它的特点是:
使用简单。
自动释放锁。
支持可重入。
可以修饰实例方法、静态方法、代码块。
底层和对象头、Monitor、monitorentermonitorexit 有关。
能保证原子性、可见性和有序性。
ReentrantLock 是 JUC 显式锁。
它的特点是:
基于 AQS 实现。
支持可重入。
需要手动 unlock()
支持公平锁和非公平锁。
支持 tryLock()
支持超时等待。
支持可中断加锁。
支持多个 Condition 条件队列。
二者选择可以这样记:
简单同步优先用 synchronized
需要超时、可中断、公平性、多条件队列,用 ReentrantLock
使用 ReentrantLock 时,unlock() 必须放在 finally 中。
不要在锁里做慢 SQL、RPC、文件 IO。
多把锁时要固定加锁顺序,避免死锁。
死锁的核心是:
多个线程互相等待对方持有的资源,形成循环等待。
避免死锁的常见方式:
固定加锁顺序。
缩小锁粒度。
减少锁持有时间。
使用 tryLock(timeout)
不在锁内做不可控慢操作。
通过线程栈定位线上阻塞和死锁。
理解锁,不只是知道怎么写 synchronizedlock.lock()
更重要的是理解:
锁保护的到底是哪份共享资源?
锁对象是不是同一个?
锁范围是否过大?
锁内是否有慢操作?
多把锁的顺序是否一致?
出现异常时锁能否释放?
线程等待时能否超时、中断或降级?
这些问题想清楚,锁才真正用得稳。
回到首页