深度进阶:全网最全 Java 锁知识体系大通关
🔥个人主页:代码不加冰(欢迎来访)
🎬作者简介:java后端学习者
❄️个人专栏:LeetCode刷题日记 , 苍穹外卖日记,SSM框架深入,JavaWeb,
✨命运的结局尽可永在,不屈的挑战却不可须臾或缺!
前言:
大家好,我是代码不加冰,这一篇文章是专门针对锁的知识,根据自己的实际情况,在前面学习项目的时候就对这一块内容理解的不是很深,感觉模模糊糊的,因此专门抽出一个时间来全面的学习进阶一下。
这是一个极其全面的 Java 锁知识体系,从 JVM 底层到分布式,面试能用到的全部覆盖。内容很多,这里会分模块逐步展开。
第一层:锁思想
锁 ├── 乐观锁 └── 悲观锁这是最高层的区别,我们要搞清楚的是这是锁的思想,并不是具体的锁。
乐观锁
我认为冲突少特点:
不先加锁 更新时检查代表:
CAS Version MVCC悲观锁
我认为一定会冲突特点:
先锁后操作代表:
synchronized ReentrantLock for update Redis锁第二层:按作用范围分类
锁 │ ├── JVM锁 ├── 数据库锁 └── 分布式锁很多人容易产生一个误解:认为 JVM 锁是乐观锁或者悲观锁。
实际上这种说法是不准确的,因为JVM锁、数据库锁、分布式锁属于作用范围维度的分类;而乐观锁、悲观锁属于并发控制思想维度的分类。
JVM锁并不是一种具体的锁实现,而是指锁的作用范围位于 JVM 内部,例如 synchronized、ReentrantLock 等都属于 JVM 锁。
同理,数据库锁表示锁作用于数据库中的数据记录,例如行锁、表锁、间隙锁等;分布式锁表示锁作用于多个服务节点之间,例如 Redis 锁、ZooKeeper 锁等。
因此,一个具体的锁往往同时具有两个属性:
| 锁类型 \ 作用范围 | JVM 单机线程 | MySQL 数据库 | 分布式多机集群 |
|---|---|---|---|
| 乐观锁 | CAS、Atomic 原子类 | Version 版本号、MVCC | 极少使用 |
| 悲观锁 | synchronized、ReentrantLock | 行锁 for update、表锁、间隙锁 | Redis 锁、Redisson、Zookeeper 锁 |
JVM锁
Java代码里的锁。
synchronizedReentrantLockReadWriteLock只能锁:
一个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.4synchronizedvsLock核心对比(面试必考)
| 特性 | synchronized | ReentrantLock |
|---|---|---|
| 实现层次 | 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?因为这行代码对应三条指令:
- 分配内存
- 调用构造方法初始化对象
- 把引用赋给
instance
指令重排后可能变成 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.1ReadWriteLock(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 | ★★ |
结语:
这里也算是比较全面的了解了锁的知识,后续会分专题进行源码深入学习,如果对你有帮助,那我很荣幸!今晚写完结束看世界杯了,开心!
