个人主页代码不加冰欢迎来访作者简介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 BB hb C则 A hb C1.3详解多线程的三大核心问题多线程为什么会出问题这里我们举一个具体的例子来分析理解一下理解一下多线程Java 的多线程是由 Java 虚拟机JVM内置并提供核心支持的。不过更准确地说它是JVM 核心设计与底层操作系统OS共同配合的结果。我们可以从以下三个层面来理解 JVM 是如何内置多线程的1. 语法与核心 API 的内置在 Java 语言中你不需要引入任何第三方库就可以直接使用多线程。JVM 在启动时就已经为你准备好了一切java.lang.Thread类这是 JVM 内置的核心类每一个Thread类的实例在 JVM 内部都对应着一个真正的线程。关键字支持如synchronizedJVM 在字节码指令集里直接内置了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 对象都关联一个 MonitorC 实现的ObjectMonitor它的结构如下2.3 锁升级详细过程偏向锁当只有一个线程反复获取同一把锁时JVM 把线程 ID 记录到对象头的 Mark Word 里。下次这个线程再来加锁只需要检查 Mark Word 里的 threadId 是否是自己是的话直接进入不需要任何 CAS 操作。一旦有第二个线程来竞争偏向锁撤销升级为轻量级锁。轻量级锁线程在自己的栈帧里创建一个 Lock Record锁记录然后 CAS 操作把对象头的 Mark Word 换成指向这个 Lock Record 的指针。CAS 成功则加锁成功失败说明有竞争先自旋重试自旋超过阈值后升级为重量级锁。重量级锁操作系统级别的互斥量Mutex。竞争失败的线程会从用户态切换到内核态挂起性能代价大但不浪费 CPU。2.4synchronizedvsLock核心对比面试必考特性synchronizedReentrantLock实现层次JVM 内置JIT优化JDK 类纯 Java锁释放自动异常也会释放必须手动unlock()finally可中断不支持lockInterruptibly()支持超时获取不支持tryLock(time, unit)支持公平锁非公平可选公平/非公平条件变量单一wait/notify多个Condition对象性能Java 6 基本持平高并发下略好结论简单场景用synchronized写法简单不会忘记释放需要高级功能超时、中断、公平、多条件才用ReentrantLock。补充CASCAS是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变量在内存中的实际值银行电脑里的真密码。AExpected Value线程以为的预期值你以为的旧密码。BNew Value准备写入的新值你想改的新密码。JMM 的底层执行逻辑如果 V A说明在我准备修改的这段时间里没有别的线程动过这个变量。那我就放心大胆地把 V的值改成 B。如果 V A说明有内鬼在我准备改的期间别的线程抢先一步把值改了。那我这次操作直接宣告失败什么都不做。4. 失败了怎么办—— 自旋死循环重试你可能会想“如果失败了就什么都不做那我的数据不就少算了一次吗问得好这就是 CAS 的灵魂伴侣自旋Spin。说白了就是死循环重试。比如多线程同时做i初始 i10线程 A 和线程 B 都想把它改成11第一轮线程 A 运气好CAS 成功i变成了11。线程 B 慢了一步发现 i 已经是11了不是它预期的10线程 B 宣告失败。自旋线程 B 发现失败了它不气馁。立刻启动下一轮循环重新读取内存里的最新值发现现在 i11了。第二轮线程 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 锁的核心骨架AQSAbstractQueuedSynchronizer是 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)LockSupportAQS 底层用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的原因。六、读写锁与 StampedLock6.1ReadWriteLockReentrantReadWriteLock读写锁规则读读共享读写互斥写写互斥。适合读多写少场景。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 的 state32位被拆成两半高16位是读锁计数低16位是写锁重入次数。锁降级面试高频持有写锁的线程可以在不释放写锁的情况下先获取读锁然后再释放写锁。这样能保证数据可见性写完能立即读到自己写的最新值。锁不能升级持有读锁时不能获取写锁会死锁。6.2 StampedLockJava 8ReadWriteLock的写锁会完全阻塞读锁。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 ThreadLocalConnection 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 分布式锁SETNXLuaRedissonWatchDog★★★★重要读写锁StampedLock锁降级★★★重要ThreadLocal 内存泄漏★★★了解ZK 分布式锁RedLock★★结语这里也算是比较全面的了解了锁的知识后续会分专题进行源码深入学习如果对你有帮助那我很荣幸今晚写完结束看世界杯了开心