

🔥个人主页:代码不加冰(欢迎来访) 🎬作者简介:java后端学习者 ❄️个人专栏:LeetCode刷题日记 , 苍穹外卖日记,SSM框架深入,JavaWeb, ✨命运的结局尽可永在,不屈的挑战却不可须臾或缺!
前言:
大家好,我是代码不加冰,这一篇文章是专门针对锁的知识,根据自己的实际情况,在前面学习项目的时候就对这一块内容理解的不是很深,感觉模模糊糊的,因此专门抽出一个时间来全面的学习进阶一下。
这是一个极其全面的 Java 锁知识体系,从 JVM 底层到分布式,面试能用到的全部覆盖。内容很多,这里会分模块逐步展开。

第一层:锁思想
锁
├── 乐观锁
└── 悲观锁
这是最高层的区别,我们要搞清楚的是这是锁的思想,并不是具体的锁。
乐观锁

我认为冲突少
特点:
不先加锁
更新时检查
代表:
CAS
Version
MVCC
悲观锁

我认为一定会冲突
特点:
先锁后操作
代表:
synchronized
ReentrantLock
for update
Redis锁
第二层:按作用范围分类
锁
│
├── JVM锁
├── 数据库锁
└── 分布式锁
很多人容易产生一个误解:认为 JVM 锁是乐观锁或者悲观锁。
实际上这种说法是不准确的,因为 JVM锁、数据库锁、分布式锁属于作用范围维度的分类;而乐观锁、悲观锁属于并发控制思想维度的分类。
JVM锁并不是一种具体的锁实现,而是指锁的作用范围位于 JVM 内部,例如 synchronized、ReentrantLock 等都属于 JVM 锁。
同理,数据库锁表示锁作用于数据库中的数据记录,例如行锁、表锁、间隙锁等;分布式锁表示锁作用于多个服务节点之间,例如 Redis 锁、ZooKeeper 锁等。
因此,一个具体的锁往往同时具有两个属性:
| 乐观锁 | CAS、Atomic 原子类 | Version 版本号、MVCC | 极少使用 |
| 悲观锁 | synchronized、ReentrantLock | 行锁 for update、表锁、间隙锁 | Redis 锁、Redisson、Zookeeper 锁 |
JVM锁
Java代码里的锁。

synchronized
ReentrantLock
ReadWriteLock
只能锁:
一个JVM
例如:
服务器A
服务器B:
看不到
数据库锁
数据库内部实现。

for update
还有:
行锁
表锁
间隙锁
MVCC
锁的是:
数据库数据
分布式锁
多台服务器共享。

Redis
ZooKeeper
Redisson
锁的是:
整个集群
以上就是锁的总览,下面我们继续深入的了解。
原理深入
一、Java 内存模型(JMM)— 理解锁的基础
在讲任何锁之前,必须先理解 JMM,否则后面的一切都是空中楼阁。
1.1 三大核心问题
多线程出现 bug 的根源只有三个:
可见性:线程 A 修改了变量,线程 B 读不到最新值。原因是 CPU 有本地缓存(L1/L2 Cache),每个线程操作的是自己的缓存副本,不会立即写回主内存。
原子性:一个操作被中途打断。比如 i++ 在字节码层面是三条指令(读、加1、写),这三步之间可以被其他线程插入。
有序性:编译器和 CPU 为了性能会对指令重排序。单线程下没问题,多线程下可能导致另一个线程看到"乱序"的结果。
1.2 happens-before 规则(面试高频)
JMM 用 happens-before 来描述内存可见性。如果操作 A happens-before 操作 B,那么 A 的结果对 B 可见。
八条规则中最重要的四条:
| 程序顺序规则 | 同一线程内,前面的操作 hb 后面的操作 |
| volatile 规则 | volatile 写 hb 后续的 volatile 读 |
| 锁规则 | unlock hb 随后的 lock |
| 传递性 | A hb B,B hb C,则 A hb C |
1.3详解:多线程的三大核心问题
多线程为什么会出问题,这里我们举一个具体的例子来分析理解一下:
理解一下多线程:
Java 的多线程是由 Java 虚拟机(JVM)内置并提供核心支持的。
不过更准确地说,它是 JVM 核心设计 与 底层操作系统(OS) 共同配合的结果。
我们可以从以下三个层面来理解 JVM 是如何内置多线程的:
1. 语法与核心 API 的内置
在 Java 语言中,你不需要引入任何第三方库,就可以直接使用多线程。JVM 在启动时,就已经为你准备好了一切:
-
java.lang.Thread 类: 这是 JVM 内置的核心类,每一个 Thread 类的实例,在 JVM 内部都对应着一个真正的线程。
-
关键字支持(如 synchronized): JVM 在字节码指令集里直接内置了 monitorenter 和 monitorexit 两条指令,专门用来支持多线程加锁。
2. 线程模型的“映射”关系
虽然 JVM 内置了多线程管理,但 JVM 本质上是一个运行在操作系统上的软件。它自己并不能直接控制 CPU 的核心,必须管操作系统要线程。
在目前主流的 JVM(如 HotSpot)中,采用的是 1:1 的线程模型:
-
当你在 Java 里写下 new Thread().start() 时,JVM 内部会调用本地方法(Native Method)。
-
JVM 会向操作系统(Windows、Linux 等)申请创建一个真正的内核级线程(Kernel Thread)。
-
也就是说: Java 的线程在 JVM 内部是一个 Java 对象,但在底层,它直接映射为一个操作系统的原生线程。线程的实际调度(比如哪个线程先运行、运行多久)主要是由操作系统的调度器来决定的。
3. JVM 内部自带的系统级线程
就算你的 Java 代码里只写了一行 System.out.println("Hello World");,没有手动创建任何线程,JVM 启动时也会内置启动好几个后台线程来维持运行。
如果你用工具(如 jstack)去查看,会发现 JVM 内部自带了这些线程:
-
Main 线程: 执行你 main 方法的主线程。
-
Garbage Collector (GC) 线程: JVM 内置的垃圾回收线程,专门在后台清理内存(比如 ZGC、G1 的后台线程)。
-
Compiler 线程: 即时编译器(JIT)线程,负责在后台把热点 Java 代码编译成机器码以提高运行速度。
-
Signal Dispatcher 线程: 负责接收并分发操作系统信号的线程。
总结
Java 的多线程能力是烙印在 JVM 里的。JVM 负责提供面向开发者的 API、内存规范(JMM)以及各种同步机制,而底层的执行和调度则交给了操作系统。两者结合,才让 Java 拥有了如此强大的并发处理能力。
多线程核心问题:
① 可见性(Visibility)
-
通俗解释:信息没有及时同步。
-
奶茶店场景:
总仓库里珍珠还剩 10 箱。
员工 A 看了看,搬走了 9 箱到自己的操作台上用,此时总仓库应该只剩 1 箱。
但是!员工 A 还没来得及在总电脑(主内存)里登记。
这时员工 B 来总仓库看,发现电脑上还显示 10 箱,于是高高兴兴接了个 5 箱珍珠的大单。结果去库房一数,傻眼了。
-
技术本质:线程 A 修改了自己 CPU 缓存里的变量,还没刷回主内存;线程 B 去主内存读了旧数据。
② 原子性(Atomicity)
-
通俗解释:一件事必须“一气呵成”,要么全部做完,要么完全不做,不能中途被人插足。
-
奶茶店场景:
顾客说:“我要一杯奶茶,加珍珠,走冰。”
做奶茶分为三步:1. 拿杯子 2. 加珍珠3. 封口。
员工 A 刚拿到杯子,加了珍珠(做了前两步),突然被店长叫去接个电话。
就在这个空档,员工 B 以为这是个空杯子,顺手拿过去给另一个顾客做了杯“加椰果、去冰”的奶茶。
员工 A 接完电话回来,看都不看,直接把杯子封口打包给了第一个顾客。
顾客拿到手:不知所措
-
技术本质:CPU 可能会切换到别的线程去执行,导致数据被覆盖。
③ 有序性(Ordering)
-
通俗解释:代码的执行顺序被 CPU 或编译器篡改了。
-
奶茶店场景:
你给员工写的标准作业流程(SOP)是:
-
拿杯子;2. 倒奶茶;3. 放吸管。
员工(CPU)是个聪明人,他发现先放吸管、再倒奶茶、最后拿杯子,顺手程度是一样的,甚至更快。为了追求效率,他自作主张调整了顺序(指令重排)。
在单人单干时,这没问题。
但如果是多人工种配合,另一个员工负责在“放吸管”后立刻贴标签,顺序一乱,整个流水线就崩了。
-
技术本质:CPU 为了让执行速度更快,会把没有前后依赖关系的指令颠倒顺序执行。在多线程下,这种颠倒会导致严重的逻辑错误。
JMM 的内存模型( 并发视角)
JMM 划分内存非常简单粗暴,它不管什么堆栈,它只把内存抽象为两块:
-
主内存(Main Memory):所有线程共享的变量都在这里(对应硬件的主内存、或者 JVM 堆里的一部分)。
-
工作内存(Working Memory):每个线程私有的空间(对应硬件的 CPU 缓存、寄存器,或者 JVM 栈里的一部分)。
总结
-
线程安全问题:是多线程并发抢占资源时导致的混乱。
-
synchronized / volatile / Lock:是程序员用来解决线程安全问题的具体工具。
-
happens-before 规则:是这些工具能够起作用的底层数学/逻辑证明。它告诉你,只要你用了这些工具,JMM 就会通过 happens-before 规则确保内存可见性和有序性。
二、synchronized — Java 最基础的锁
2.1 使用方式
// 方式1:修饰实例方法,锁的是 this 对象
public synchronized void method() { … }
// 方式2:修饰静态方法,锁的是 Class 对象(类锁)
public static synchronized void staticMethod() { … }
// 方式3:同步代码块,锁的是括号内的对象
synchronized (obj) { … }
面试陷阱:锁的对象必须是同一个才能互斥。两个线程分别 synchronized(this) 和 synchronized(other),不互斥
2.2 底层实现:Monitor(监视器)
synchronized 在字节码层面会在同步块前后插入 monitorenter / monitorexit 指令,方法则用 ACC_SYNCHRONIZED 标志。
每个 Java 对象都关联一个 Monitor(C++ 实现的 ObjectMonitor),它的结构如下:

2.3 锁升级详细过程
偏向锁:当只有一个线程反复获取同一把锁时,JVM 把线程 ID 记录到对象头的 Mark Word 里。下次这个线程再来加锁,只需要检查 Mark Word 里的 threadId 是否是自己,是的话直接进入,不需要任何 CAS 操作。一旦有第二个线程来竞争,偏向锁撤销,升级为轻量级锁。
轻量级锁:线程在自己的栈帧里创建一个 Lock Record(锁记录),然后 CAS 操作把对象头的 Mark Word 换成指向这个 Lock Record 的指针。CAS 成功则加锁成功;失败说明有竞争,先自旋重试,自旋超过阈值后升级为重量级锁。
重量级锁:操作系统级别的互斥量(Mutex)。竞争失败的线程会从用户态切换到内核态挂起,性能代价大,但不浪费 CPU。
2.4 synchronized vs Lock 核心对比(面试必考)
| 实现层次 | JVM 内置,JIT优化 | JDK 类,纯 Java |
| 锁释放 | 自动(异常也会释放) | 必须手动 unlock()(finally) |
| 可中断 | 不支持 | lockInterruptibly() 支持 |
| 超时获取 | 不支持 | tryLock(time, unit) 支持 |
| 公平锁 | 非公平 | 可选公平/非公平 |
| 条件变量 | 单一 wait/notify | 多个 Condition 对象 |
| 性能 | Java 6+ 基本持平 | 高并发下略好 |
结论:简单场景用 synchronized(写法简单,不会忘记释放);需要高级功能(超时、中断、公平、多条件)才用 ReentrantLock。
补充CAS:
CAS 是 Compare And Swap 的缩写,中文翻译过来叫比较并交换。
CAS 本身不是乐观锁,而是实现乐观锁的一种无锁机制。乐观锁是一种并发控制思想,CAS 通过“比较并交换”的方式,在更新数据时判断数据是否被其他线程修改过,从而实现乐观锁的效果。除了 CAS 之外,数据库中的 Version 版本号机制也是乐观锁的一种常见实现方式。
在多线程编程里,它是为了解决“两个线程同时修改同一个变量,导致数据被覆盖”的问题。
1. 核心思想:不用锁的机制
传统解决并发问题的方法是加锁(synchronized)。加锁就像上公共厕所:线程 A 进去了,把门一锁;线程 B 来了,只能在门外憋着排队(阻塞),等 A 出来才能进。这种方式很安全,但很慢、很重。
而 CAS 走的是另一个极端——完全不加锁,谁也不用排队。它不靠锁来堵人,而是靠“对暗号”来确保安全。
2. 经典生活场景:去银行改密码
假设你的银行卡密码现在是 111,你想把它改成 222。
如果银行系统使用的是 CAS 操作,流程是这样的:
Compare(比较/对暗号):
柜员(CPU)不直接帮你改,他会问你:“你记忆中现在的密码是多少?”
你说:“是 111。”
柜员看了一眼电脑里的实际密码,发现确实是 111。(预期值和你手里的值对上了)。
Swap(交换/改值):
柜员说:“暗号对上了,说明你手里的是最新信息。我现在把你给的新密码 222 写进电脑里。” (修改成功)。
如果有别人同时在改(并发冲突)
在你正准备说出新密码的前一秒,你老婆通过手机银行,偷偷把密码改成了 333。
这时候,你跟柜员的 CAS 操作就会变成这样:
Compare(比较): 柜员问你:“你以为的密码是多少?” 你说:“111。” 柜员看了一眼电脑,发现现在实际是 333。对不上!
Swap(交换): 柜员对你说:“抱歉,你手里的密码版本太旧了(在你操作期间被别人改了),修改失败, 别骗我
在这个过程中,没有任何人排队挂起,全凭“比对数据”来决定是否成功。
3. CAS 的三个核心底层参数
在代码世界里,每次发起 CAS 操作,线程必须带上 3 个参数:
CAS(V, A, B)
-
VValue):变量在内存中的实际值(银行电脑里的真密码)。
-
A(Expected Value):线程以为的预期值(你以为的旧密码)。
-
B(New Value):准备写入的新值(你想改的新密码)。
JMM 的底层执行逻辑:
如果 V == A,说明在我准备修改的这段时间里,没有别的线程动过这个变量。那我就放心大胆地把 V的值改成 B。
如果 V != A,说明有内鬼,在我准备改的期间,别的线程抢先一步把值改了。那我这次操作直接宣告失败,什么都不做。
4. 失败了怎么办—— 自旋(死循环重试)
你可能会想:“如果失败了就什么都不做,那我的数据不就少算了一次吗
问得好!这就是 CAS 的灵魂伴侣:自旋(Spin)。说白了就是死循环重试。
比如多线程同时做 i++(初始 i=10),线程 A 和线程 B 都想把它改成 11:
-
第一轮: 线程 A 运气好,CAS 成功,i变成了 11。线程 B 慢了一步,发现 i 已经是 11 了,不是它预期的 10,线程 B 宣告失败。
-
自旋: 线程 B 发现失败了,它不气馁。立刻启动下一轮循环,重新读取内存里的最新值,发现现在 i=11了。
-
第二轮: 线程 B 把预期值更新为 11,想改成 12。发起第二次 CAS。这次没人抢,成功! i变成了 12。
这种“读取 —> 比较 —> 失败再读取—> 再比较”的死循环,就叫自旋锁(SpinLock)。
5. 为什么说 CAS 特别快
因为 CAS 是直接封装在 CPU 硬件芯片里的指令(在 x86 架构下是一条叫 cmpxchg 的汇编指令)。
它是由硬件从最底层保证“比较并交换”这两个动作一气呵成,绝对不会中途被其他线程打断。它不需要操作系统去切换线程状态,没有高昂的上下文切换成本,因此在竞争不激烈(大家撞车概率低)的场景下,性能恐怖得惊人。
三、volatile — 轻量级同步
3.1 两个核心保证
volatile 只解决两件事,注意它不保证原子性:
可见性:对 volatile 变量的写操作,会强制立即刷新到主内存,并使其他线程的缓存失效,强制从主内存读取。
有序性(禁止指令重排序):通过内存屏障(Memory Barrier)实现。
// volatile写:在写操作前插入 StoreStore 屏障,后插入 StoreLoad 屏障
// volatile读:在读操作后插入 LoadLoad + LoadStore 屏障
3.2 双重检查锁(DCL)单例 —— 经典面试题
public class Singleton {
// 必须加 volatile!!
private volatile static Singleton instance;
public static Singleton getInstance() {
if (instance == null) { // 第一次检查(无锁)
synchronized (Singleton.class) {
if (instance == null) { // 第二次检查(有锁)
instance = new Singleton(); // 这一步不是原子操作!
}
}
}
return instance;
}
}
为什么 new Singleton() 必须用 volatile?因为这行代码对应三条指令:
指令重排后可能变成 1→3→2。线程A执行到第3步(已赋值但未初始化),线程B在第一次检查发现 instance != null 直接返回了一个未初始化的对象,使用时报 NPE。volatile 禁止重排序,彻底解决这个问题。
四、AQS — JUC 锁的核心骨架
AQS(AbstractQueuedSynchronizer)是 Java 并发包里最重要的基础设施,ReentrantLock、Semaphore、CountDownLatch、CyclicBarrier 全都基于它。

4.1 AQS 的核心思想
AQS 用一个 volatile int state 表示同步状态,子类通过重写以下方法自定义语义:
// 独占模式(ReentrantLock用这两个)
protected boolean tryAcquire(int arg) // 尝试获取锁
protected boolean tryRelease(int arg) // 尝试释放锁
// 共享模式(Semaphore、CountDownLatch用这两个)
protected int tryAcquireShared(int arg)
protected boolean tryReleaseShared(int arg)
LockSupport:AQS 底层用 LockSupport.park() 阻塞线程,LockSupport.unpark(thread) 唤醒线程。比 wait/notify 好的地方是:不需要持有锁,且 unpark 可以先于 park 调用(类似令牌机制)。
五、ReentrantLock — 最重要的 JUC 锁
5.1 公平锁 vs 非公平锁
// 非公平锁(默认):新来的线程先插队抢一次
ReentrantLock lock = new ReentrantLock();
// 公平锁:按 CLH 队列顺序排队
ReentrantLock fairLock = new ReentrantLock(true);
非公平锁:新线程来了,先直接 CAS 尝试抢锁(不看队列),抢到就直接执行,不用进队列。性能更好,吞吐量高,但可能导致队列里的线程长时间饥饿。
公平锁:新线程来了,先看 CLH 队列是否有等待者,有的话老老实实排队。延迟高,但绝对公平无饥饿。
源码关键区别(非公平锁多了一次 CAS 尝试):
// 非公平锁 NonfairSync.lock()
final void lock() {
if (compareAndSetState(0, 1)) // 直接尝试CAS,不看队列!
setExclusiveOwnerThread(Thread.currentThread());
else
acquire(1);
}
// 公平锁 FairSync.tryAcquire()
protected final boolean tryAcquire(int acquires) {
// hasQueuedPredecessors() 判断队列里是否有人在等
if (!hasQueuedPredecessors() && compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(Thread.currentThread());
return true;
}
return false;
}
5.2 可重入原理
// 源码:同一线程再次获取锁,只是把 state 加1
final boolean nonfairTryAcquire(int acquires) {
Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
// 无锁,CAS获取
} else if (current == getExclusiveOwnerThread()) {
// 已经是自己持有的锁,重入!state递增
int nextc = c + acquires;
setState(nextc);
return true;
}
return false;
}
释放时每次 unlock() 让 state 减 1,减到 0 才真正释放。所以 lock 和 unlock 必须成对调用。
5.3 Condition 多条件变量
ReentrantLock lock = new ReentrantLock();
Condition notFull = lock.newCondition(); // 条件:队列不满
Condition notEmpty = lock.newCondition(); // 条件:队列不空
// 生产者
lock.lock();
try {
while (queue.isFull())
notFull.await(); // 等待"不满"条件,释放锁并挂起
queue.add(item);
notEmpty.signal(); // 通知消费者"队列不空了"
} finally { lock.unlock(); }
// 消费者
lock.lock();
try {
while (queue.isEmpty())
notEmpty.await();
queue.take();
notFull.signal();
} finally { lock.unlock(); }
synchronized 的 wait/notify 只有一个等待队列,容易误唤醒。Condition 可以精确通知,这就是 ArrayBlockingQueue 内部用 ReentrantLock + 两个 Condition 而不用 synchronized 的原因。
六、读写锁与 StampedLock
6.1 ReadWriteLock(ReentrantReadWriteLock)
读写锁规则:读读共享,读写互斥,写写互斥。适合读多写少场景。
ReadWriteLock rwLock = new ReentrantReadWriteLock();
Lock readLock = rwLock.readLock();
Lock writeLock = rwLock.writeLock();
// 读操作(多线程并发读,性能高)
readLock.lock();
try { return data; } finally { readLock.unlock(); }
// 写操作(独占)
writeLock.lock();
try { data = newData; } finally { writeLock.unlock(); }
底层实现:AQS 的 state(32位)被拆成两半:高16位是读锁计数,低16位是写锁重入次数。
锁降级(面试高频!):持有写锁的线程,可以在不释放写锁的情况下,先获取读锁,然后再释放写锁。这样能保证数据可见性(写完能立即读到自己写的最新值)。锁不能升级(持有读锁时不能获取写锁,会死锁)。
6.2 StampedLock(Java 8+)
ReadWriteLock 的写锁会完全阻塞读锁。StampedLock 引入了乐观读:
StampedLock lock = new StampedLock();
// 乐观读(不加锁,直接读,完事后验证有没有被写过)
long stamp = lock.tryOptimisticRead();
double x = this.x, y = this.y;
if (!lock.validate(stamp)) { // 验证:读的过程中有没有写操作
stamp = lock.readLock(); // 失效了,升级为真正的读锁
try { x = this.x; y = this.y; }
finally { lock.unlockRead(stamp); }
}
// 乐观读成功,直接用 x, y
注意:StampedLock 不支持可重入,不支持 Condition,且没有锁升级机制,用法复杂。
八、ThreadLocal — 线程隔离(非传统锁,但面试必考)
ThreadLocal 不是锁,但它通过让每个线程拥有自己独立的变量副本来避免共享,从根本上避免了并发问题。
private static final ThreadLocal<Connection> connHolder = new ThreadLocal<>();
// 使用
connHolder.set(conn); // 只在当前线程可见
Connection c = connHolder.get();
connHolder.remove(); // 必须在最后remove,防止内存泄漏!
内存泄漏原因(面试必考):
Thread → ThreadLocalMap → Entry(弱引用Key: ThreadLocal, 强引用Value: 数据)
ThreadLocal 对象是弱引用,GC 时会被回收,导致 key 变为 null,但 value 还被强引用,无法回收。线程池中线程长期存活,这些 null-key 的 value 永远回收不了 → 内存泄漏。
解决:使用完必须调用 remove(),尤其是在线程池场景下。
建议
| 非常重要 | synchronized 原理+锁升级+Monitor | ★★★★★ |
| 非常重要 | AQS 原理 + ReentrantLock 公平/非公平/可重入 | ★★★★★ |
| 非常重要 | volatile 的可见性+有序性+不保证原子性 | ★★★★★ |
| 非常重要 | 死锁四条件+预防+排查 | ★★★★★ |
| 必须掌握 | CAS + ABA 问题 + AtomicStampedReference | ★★★★ |
| 必须掌握 | Redis 分布式锁:SETNX+Lua+Redisson+WatchDog | ★★★★ |
| 重要 | 读写锁+StampedLock+锁降级 | ★★★ |
| 重要 | ThreadLocal 内存泄漏 | ★★★ |
| 了解 | ZK 分布式锁+RedLock | ★★ |
结语: 这里也算是比较全面的了解了锁的知识,后续会分专题进行源码深入学习,如果对你有帮助,那我很荣幸!今晚写完结束看世界杯了,开心!




