欢迎光临
我们一直在努力

深度进阶:全网最全 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代码里的锁。

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 核心对比(面试必考)

特性synchronizedReentrantLock
实现层次 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.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 ★★

    结语: 这里也算是比较全面的了解了锁的知识,后续会分专题进行源码深入学习,如果对你有帮助,那我很荣幸!今晚写完结束看世界杯了,开心!

    赞(0)
    未经允许不得转载:171主机测评 » 深度进阶:全网最全 Java 锁知识体系大通关
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址