type
Post
status
Published
date
Oct 12, 2025
slug
java5
summary
tags
技术探索
category
icon
password
Java 并发里,锁是绕不开的核心。
只要多个线程会同时访问共享资源,就要考虑一个问题:
谁可以访问?什么时候可以访问?访问期间其他线程怎么办?出现异常后锁能不能释放?等锁的线程会阻塞在哪里?锁竞争激烈时性能会不会急剧下降?线上出现线程卡死时怎么定位?
Java 里最常见的两类锁是:
synchronizedReentrantLock
这篇文章就围绕它们展开,把对象锁、类锁、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 修饰代码块时,编译后会出现 monitorenter 和 monitorexit 指令。示例代码:
对应的字节码结构大致如下:
这段字节码有几个关键点:
monitorenter表示进入同步块。monitorexit表示退出同步块。正常路径有一次monitorexit。异常路径也有一次monitorexit。所以synchronized在异常情况下也能自动释放锁。
这就是
synchronized 相比手动锁的一个优势:不需要显式写unlock()。JVM 会保证同步块退出时释放锁。
六、synchronized 修饰方法的字节码
如果
synchronized 修饰方法,不会在方法体里直接看到 monitorenter 和 monitorexit。示例:
字节码中方法会带有
ACC_SYNCHRONIZED 标记。关键点:
同步代码块通过monitorenter和monitorexit实现。同步方法通过方法访问标志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 负责状态、队列、阻塞、唤醒。ReentrantLock用state表示重入次数。
二十、非公平锁源码理解
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/notify,Condition 的优势是可以有多个条件队列。下面用
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 降低死锁风险
ReentrantLock 的 tryLock(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、monitorenter、monitorexit有关。能保证原子性、可见性和有序性。
ReentrantLock 是 JUC 显式锁。它的特点是:
基于 AQS 实现。支持可重入。需要手动unlock()。支持公平锁和非公平锁。支持tryLock()。支持超时等待。支持可中断加锁。支持多个Condition条件队列。
二者选择可以这样记:
简单同步优先用synchronized。需要超时、可中断、公平性、多条件队列,用ReentrantLock。使用ReentrantLock时,unlock()必须放在finally中。不要在锁里做慢 SQL、RPC、文件 IO。多把锁时要固定加锁顺序,避免死锁。
死锁的核心是:
多个线程互相等待对方持有的资源,形成循环等待。
避免死锁的常见方式:
固定加锁顺序。缩小锁粒度。减少锁持有时间。使用tryLock(timeout)。不在锁内做不可控慢操作。通过线程栈定位线上阻塞和死锁。
理解锁,不只是知道怎么写
synchronized 或 lock.lock()。更重要的是理解:
锁保护的到底是哪份共享资源?锁对象是不是同一个?锁范围是否过大?锁内是否有慢操作?多把锁的顺序是否一致?出现异常时锁能否释放?线程等待时能否超时、中断或降级?
这些问题想清楚,锁才真正用得稳。
分享
