在 Java 并发编程里锁是一个永远绕不开的核心话题。无论是日常开发中处理共享资源的竞争还是面试时被问到的各种并发原理锁都是决定系统性能和安全性的关键。很多人对锁的理解停留在“synchronized 和 Lock 的区别”这个层面但真正被问到“Java 中的锁分类”时往往只能说出个大概或者把不同维度的分类混在一起讲导致逻辑混乱。这篇文章我从实际开发和面试两个角度出发把 Java 里的锁体系彻底拆开来讲按线程竞争态度分公平锁与非公平锁按资源访问方式分乐观锁与悲观锁按线程间关系分可重入锁与不可重入锁再深入到 synchronized 的锁升级机制、AQS 队列同步器、读写锁、StampedLock 等具体实现。整个过程会结合源码、使用场景、性能对比和踩坑经验尽量做到既能让初学者建立完整框架也能让有经验的开发者收获一些底层细节。1. 锁的宏观分类一张图看透 Java 锁的全貌锁的分类维度非常多如果只靠死记硬背很容易被绕晕。我自己的理解方式是不要把锁看成单一概念而是从多个维度去观察它就像描述一个人可以从性别、年龄、职业、性格等维度展开锁也一样。每个维度回答不同的问题组合起来才能完整描述一个锁的特性。1.1 线程竞争态度公平锁 vs 非公平锁这是面试里最常被问到的分类之一。公平锁指的是多个线程按照申请锁的顺序来获取锁先来后到类似排队买票非公平锁则是线程直接尝试抢锁抢不到再排队类似高峰期挤公交谁力气大谁先上。在 Java 里ReentrantLock默认是非公平锁但可以通过构造方法传入true来启用公平模式。synchronized本身也是一种非公平锁它不保证等待时间最长的线程优先获得锁。为什么默认用非公平锁因为公平锁需要维护一个有序队列线程切换和唤醒的开销更大吞吐量通常会低于非公平锁。非公平锁虽然可能造成“插队”现象导致某些线程长时间拿不到锁但它的整体性能更好因为减少了线程唤醒带来的上下文切换开销。我在实际项目里通常默认使用非公平锁只有在业务上严格要求线程执行顺序比如某些任务必须按提交顺序执行时才会启用公平锁。1.2 资源访问方式乐观锁 vs 悲观锁这个分类体现了对待并发冲突的两种截然不同的态度。悲观锁假设冲突一定会发生所以在操作数据之前就加锁阻塞其他线程的访问。synchronized和Lock接口的实现类都属于悲观锁。乐观锁假设冲突很少发生所以不加锁而是在更新数据时检查数据是否被其他线程修改过。如果没被修改就直接更新如果被修改了就重试或放弃。Java 中的CASCompare And Swap操作就是乐观锁的典型实现AtomicInteger等原子类底层用的就是 CAS。乐观锁适合读多写少的场景悲观锁适合写多的场景。比如电商的库存扣减如果用悲观锁所有请求串行执行性能会很难看如果用乐观锁版本号或 CAS并发量能提高不少但需要处理冲突重试和 ABA 问题。1.3 线程间关系可重入锁 vs 不可重入锁可重入锁也叫递归锁指的是同一个线程在外层方法获得锁之后内层方法再次获取该锁时不会被阻塞。Java 里synchronized和ReentrantLock都是可重入锁。举个例子方法 A 加了锁然后调用方法 B方法 B 也加了同一把锁。如果是可重入锁线程在持有锁的情况下能直接进入方法 B如果是不可重入锁线程会被自己阻塞住形成死锁。不可重入锁在 Java 原生 API 里很少见但理解这个概念很重要因为有些自定义锁或者非 Java 语言的锁实现是不可重入的。我在写基于AbstractQueuedSynchronizerAQS的自定义同步组件时必须自己处理重入逻辑否则就会出现问题。1.4 并发访问范围共享锁 vs 独占锁独占锁也叫排他锁或写锁指的是同一时刻只能有一个线程持有锁共享锁也叫读锁多个线程可以同时持有。Java 中ReentrantReadWriteLock就是一个典型的实现读锁是共享的写锁是独占的。读锁和读锁之间不互斥读锁和写锁之间互斥写锁和写锁之间互斥。这个设计背后的逻辑很朴素多个线程同时读数据不会产生数据不一致的问题所以没必要互斥但只要有一个线程在写其他线程无论读还是写都必须等。1.5 锁的状态升级偏向锁 vs 轻量级锁 vs 重量级锁这是synchronized在 JDK 1.6 之后引入的锁升级机制按照竞争激烈程度划分为无锁、偏向锁、轻量级锁、重量级锁四个级别。这个分类比较特殊它是 JVM 对 synchronized 的优化手段后面我会单独用一整章详细讲。2. 核心锁实现机制深度拆解明白了宏观分类之后还要搞清楚 Java 具体提供了哪些锁工具、它们的底层实现是什么、各自适合什么场景。这一章我重点讲 synchronized 和 Lock 接口体系这两套东西几乎涵盖了 Java 锁的绝大多数应用场景。2.1 synchronized从字节码到监视器synchronized是 Java 语言内置的关键字它加锁的方式有三种修饰实例方法时锁住当前实例对象this修饰静态方法时锁住Class对象修饰代码块时需要显式指定锁对象。// 修饰实例方法锁住当前实例 public synchronized void increment() { count; } // 修饰静态方法锁住 Class 对象 public static synchronized void staticIncrement() { count; } // 修饰代码块锁住指定对象 public void blockIncrement() { synchronized (this) { count; } }从 JVM 字节码来看synchronized代码块对应的是monitorenter和monitorexit两条指令。JVM 规范要求每个对象都有一个监视器Monitor线程进入monitorenter时尝试获取监视器的所有权monitorexit时释放。方法级别的synchronized并不需要显式的指令JVM 通过方法表结构中的ACC_SYNCHRONIZED标志来判断方法是否需要同步。无论哪种形式底层依赖的都是同一个监视器机制。synchronized在 JDK 1.6 之前性能很差很多人把它叫做“重量级锁”因为阻塞和唤醒线程需要操作系统帮忙涉及用户态到内核态的切换。但从 JDK 1.6 开始HotSpot 虚拟机对 synchronized 做了大量优化引入了偏向锁、轻量级锁、自旋锁、锁粗化、锁消除等机制性能已经不输给ReentrantLock了。2.2 Lock 接口与 ReentrantLock显式锁的王者Lock接口是 JDK 1.5 引入的并发工具它把锁从语言层面提升到了 API 层面提供了比synchronized更灵活的操作方式。Lock接口定义了几个核心方法lock()、unlock()、tryLock()、lockInterruptibly()和newCondition()。ReentrantLock是这个接口最常用的实现类它实现了可重入、公平/非公平可选、可中断、可超时、支持多个 Condition 队列等功能。Lock lock new ReentrantLock(); lock.lock(); try { // 临界区代码 } finally { lock.unlock(); }注意在使用ReentrantLock时解锁操作必须放在 finally 块里否则临界区代码抛出异常后锁不会被释放导致其他线程永久阻塞。这是初学并发编程最容易踩的坑也是很多线上故障的根源。ReentrantLock和synchronized的选择问题我的建议是如果只是简单的同步需求优先用 synchronized代码更简洁、不容易出错而且 JVM 会自动释放锁如果需要可中断、可超时、公平性控制、多个条件队列等高级功能再考虑用ReentrantLock。2.3 AQS几乎所有显式锁的基石说到ReentrantLock就不得不提AbstractQueuedSynchronizer也就是常说的 AQS。它是java.util.concurrent包的灵魂组件ReentrantLock、Semaphore、CountDownLatch、ReentrantReadWriteLock等工具的内部实现都依赖 AQS。AQS 的核心设计是用一个volatile int state变量表示同步状态用内置的 FIFO 队列CLH 队列的变体来管理等待线程。通过改变 state 的值来获取和释放锁获取失败时当前线程被封装成节点挂到队尾通过 LockSupport 阻塞自己。// ReentrantLock.Sync 中获取锁的核心逻辑简化 final boolean nonfairTryAcquire(int acquires) { final Thread current Thread.currentThread(); int c getState(); if (c 0) { if (compareAndSetState(0, acquires)) { setExclusiveOwnerThread(current); return true; } } else if (current getExclusiveOwnerThread()) { int nextc c acquires; if (nextc 0) { throw new Error(Maximum lock count exceeded); } setState(nextc); return true; } return false; }这段代码是ReentrantLock非公平获取锁的核心逻辑state 为 0 说明没有线程持有锁通过 CAS 尝试获取如果当前线程已经持有锁state 累加实现可重入计数获取失败则返回 falseAQS 会把线程挂入等待队列。AQS 支持两种模式独占模式exclusive和共享模式shared。独占模式下同一时刻只有一个线程能持有锁共享模式下多个线程可以同时获取。模板方法tryAcquire/tryRelease处理独占模式tryAcquireShared/tryReleaseShared处理共享模式子类只需要实现这些方法即可。2.4 读写锁与邮戳锁读多写少场景的利器ReentrantReadWriteLock在前面的分类中提到过它维护了一对锁读锁共享锁和写锁独占锁。它的设计目标是提升读多写少场景下的并发性能。但ReentrantReadWriteLock有个明显的缺点当写锁被持有时所有读锁都会被阻塞当读锁被大量持有时写锁可能长时间无法获取出现“写饥饿”问题。虽然它支持公平模式来缓解这个问题但读锁和写锁之间反复竞争仍然会带来性能损耗。JDK 8 引入的StampedLock提供了一种更激进的乐观读策略。它有三种模式写锁、悲观读锁和乐观读锁。乐观读锁不真正加锁只是在读取数据之后验证期间是否有写操作如果没有就直接使用数据如果有则升级为悲观读锁重读。StampedLock stampedLock new StampedLock(); long stamp stampedLock.tryOptimisticRead(); // 读取共享数据 if (!stampedLock.validate(stamp)) { // 验证失败说明有写操作发生升级为悲观读锁 stamp stampedLock.readLock(); try { // 重新读取数据 } finally { stampedLock.unlockRead(stamp); } }StampedLock在纯读场景下性能非常好因为它连 CAS 操作都不需要做。但要注意它不可重入而且不支持 Condition 条件变量使用时踩坑概率比较高。如果业务场景是典型的读多写少且对一致性要求不是极端苛刻可以考虑用它换性能否则老老实实用ReentrantReadWriteLock更稳妥。3. 锁升级与性能优化从偏向锁到重量级锁synchronized的锁升级机制是 Java 并发领域最经典的内容之一也是面试中高频出现的考点。理解这一块不仅要记住四个状态更要理解 JVM 为什么这么设计以及每个状态下对象头的变化。3.1 对象头与 Mark Word锁信息存放的地方在 HotSpot 虚拟机中对象在内存中的布局分为三部分对象头、实例数据、对齐填充。对象头又包括 Mark Word 和类型指针。Mark Word 是一块非常灵活的内存区域它根据不同状态存放不同的信息对象哈希码、GC 分代年龄、锁状态标志、线程持有的锁记录指针、偏向线程 ID、偏向时间戳等。由于 Mark Word 的存储空间有限32 位机器上通常是 32 比特64 位机器上是 64 比特JVM 必须复用它来存储不同状态的数据。可以用一个很形象的类比来理解Mark Word 就像一张多功能卡片正面是个人信息翻过来可以变成门禁卡、公交卡、银行卡不同场景下展示不同功能但物理上还是同一张卡。3.2 偏向锁只有一个线程访问时偏向锁的优化思路是如果统计发现一把锁总是由同一个线程获取就让这个线程“偏向”该锁后续获取锁时无需任何同步操作。当锁第一次被线程获取时JVM 会通过 CAS 在对象头的 Mark Word 中记录偏向线程的 ID。之后该线程再次进入同步块时只需要检查 Mark Word 中的偏向线程 ID 是否是自己即可如果是就说明锁仍然偏向自己不需要做任何额外的同步操作。偏向锁的意义在于消除了同一线程反复获取锁时的 CAS 开销。它适用于锁竞争几乎不存在、同一线程多次进入同步块的场景。如果有其他线程尝试获取偏向锁偏向锁就会被撤销升级为轻量级锁。需要注意的是偏向锁的撤销需要等待全局安全点也就是所有工作线程都暂停的时候。这个暂停会带来一定的停顿开销所以 JDK 15 开始默认禁用了偏向锁考虑到现代应用中的锁竞争模式已经变化偏向锁的收益越来越小维护成本显得过高。3.3 轻量级锁多线程交替获取时当第二个线程尝试获取被偏向的锁时说明存在竞争偏向锁会升级为轻量级锁。轻量级锁并不真的阻塞线程而是通过在栈帧中创建锁记录Lock Record用 CAS 尝试将对象头 Mark Word 替换为指向锁记录的指针。如果 CAS 成功说明线程成功获取到轻量级锁如果失败说明锁被其他线程持有此时当前线程会进入自旋等待不断尝试获取锁。自旋的本质是用 CPU 时间换取线程切换的开销。因为阻塞线程需要操作系统介入涉及用户态和内核态的切换代价很高。如果锁能很快被释放自旋等待比重置唤醒线程更划算。JDK 6 之后自旋锁是自适应的JVM 会根据之前自旋成功与否动态调整自旋次数。如果上次自旋成功了说明锁很快会被释放就多自旋几轮如果多次自旋都失败就减少自旋甚至直接阻塞。我在调优时发现自旋次数设置不当是性能问题的常见来源。自旋太多浪费 CPU自旋太少则退化为重量级锁失去优化意义。自适应自旋机制已经让这种调整变得透明但在特别注重延迟的场景下可以借助-XX:PreBlockSpin参数手动干预默认是 10 次。3.4 重量级锁并发竞争激烈时当轻量级锁的 CAS 操作频繁失败或者自旋超过阈值后仍无法获取锁锁就会升级为重量级锁。重量级锁依赖底层操作系统的互斥量Mutex实现未获取到锁的线程会被阻塞从用户态进入内核态等待操作系统唤醒。重量级锁的优点是稳定可靠不存在自旋消耗 CPU 的问题缺点是线程阻塞和唤醒涉及内核态切换开销非常大。所以一旦锁升级到重量级锁吞吐量往往会明显下降。这里的关键结论是锁升级是单向的只能从偏向锁升级为轻量级锁再从轻量级锁升级为重量级锁不能降级。这个设计决定了 synchronized 在低竞争场景下的优秀性能但在高竞争场景下仍不如显式锁灵活。我在排解一个并发场景问题时曾遇到线程大量阻塞在 synchronized 代码块上的情况通过 jstack 确认锁已经升级为重量级锁。最终优化方案是把锁的粒度拆细用多个独立锁分别保护不同的数据分片降低单把锁的竞争度才把吞吐量提上来。4. 多维度锁分类的底层原理源码级解读这一章从字节码和 HotSpot 源码层面来解读锁分类的具体实现对于想要深入理解并发原理的同学会有比较大的帮助。即使不从事底层开发了解 JVM 源码的运行逻辑也能帮你写出更准确的代码判断。4.1 monitorenter 与 monitorexit字节码层的接口约定写一段简单的代码再通过 javap 反编译能看到 synchronized 代码块对应的字节码public class SyncDemo { public void test() { synchronized (this) { System.out.println(hello); } } }反编译结果关键部分public void test(); descriptor: ()V flags: ACC_PUBLIC Code: stack2, locals3, args_size1 0: aload_0 1: dup 2: astore_1 3: monitorenter 4: getstatic #2 7: ldc #3 9: invokevirtual #4 12: aload_1 13: monitorexit 14: goto 22 17: astore_2 18: aload_1 19: monitorexit 20: aload_2 21: athrow 22: return注意字节码中出现了两次monitorexit这是因为编译器会自动生成异常处理逻辑确保即使同步块内抛出异常也能释放锁。goto 22是正常路径athrow是异常路径。在 HotSpot 源码中monitorenter和monitorexit最终对应到ObjectSynchronizer::enter和ObjectSynchronizer::exit方法锁升级的逻辑正是在这里实现的。偏向锁、轻量级锁、重量级锁的判定和切换会发生在 monitorenter 的处理过程中。4.2 ReentrantLock 公平与非公平的实现差异ReentrantLock的公平锁和非公平锁主要体现在获取锁的入口逻辑上。非公平锁一上来就尝试 CAS 获取锁不管队列里有没有线程在等待公平锁则要先检查队列中是否有前驱节点如果有就排队不能插队。// 非公平锁的获取逻辑NonfairSync.lock final void lock() { if (compareAndSetState(0, 1)) { setExclusiveOwnerThread(Thread.currentThread()); } else { acquire(1); } } // 公平锁的获取逻辑FairSync.lock final void lock() { acquire(1); } // AQS.acquire - tryAcquire // FairSync.tryAcquire 中增加了 hasQueuedPredecessors 检查 protected final boolean tryAcquire(int acquires) { final Thread current Thread.currentThread(); int c getState(); if (c 0) { if (!hasQueuedPredecessors() compareAndSetState(0, acquires)) { setExclusiveOwnerThread(current); return true; } } else if (current getExclusiveOwnerThread()) { int nextc c acquires; if (nextc 0) { throw new Error(Maximum lock count exceeded); } setState(nextc); return true; } return false; }公平锁的hasQueuedPredecessors()方法会判断当前线程是否有前驱等待节点。如果返回true说明队列前面还有线程在排队当前线程就不能插队直接返回false表示获取锁失败进入 AQS 等待队列。这个小小的差异导致了两者在性能上的显著不同非公平锁减少了一次队列检查而且允许新线程直接抢占锁避免了线程唤醒的开销。在高并发下非公平锁的吞吐量通常比公平锁高 10%-20%但代价是尾部线程可能出现饥饿等待。4.3 读写锁的 state 拆解一个 int 管理两把锁ReentrantReadWriteLock内部通过 AQS 的state变量同时管理读锁和写锁的状态。具体实现是低 16 位表示写锁的持有次数高 16 位表示读锁的持有线程数。// ReentrantReadWriteLock.Sync 中的部分常量 static final int SHARED_SHIFT 16; static final int SHARED_UNIT (1 SHARED_SHIFT); // 65536 static final int MAX_COUNT (1 SHARED_SHIFT) - 1; // 65535 static final int EXCLUSIVE_MASK (1 SHARED_SHIFT) - 1; // 读锁计数state 无符号右移 16 位 static int sharedCount(int c) { return c SHARED_SHIFT; } // 写锁计数state 和掩码取与 static int exclusiveCount(int c) { return c EXCLUSIVE_MASK; }这种方式虽然节省了一个变量但也带来限制读锁和写锁各自的可重入次数被限制在 65535 以内。这在大多数业务场景下够用了但如果写锁重入次数特别多存在理论上的溢出风险。实际开发中基本遇不到但作为原理理解还是值得知道。读锁获取时的 CAS 操作目标是给 state 加上 65536即 SHARED_UNIT。这个操作在高并发读场景下会非常频繁也是读锁竞争时性能下降的重要原因。JDK 8 后续版本对读锁的 CAS 做了优化引入了类似分段计数的机制来减少竞争但这已经属于 JVM 实现细节了。4.4 CAS 与 volatile并发基础中的基础在分析锁源码时CAS 和volatile出现的频率极高。volatile保证多线程间的可见性写线程对共享变量的修改能立即被读线程看到CAS 则提供原子性的“比较并交换”操作底层由处理器指令如 x86 的cmpxchg直接支持。volatile不保证原子性所以volatile变量只能用于状态标志之类的场景CAS 能保证单个操作的原子性但无法保证复合操作的原子性。这就是为什么count不能直接用volatile修饰来解决并发问题而需要用AtomicInteger的incrementAndGet()。// 典型的自旋 CAS 实现 public final int incrementAndGet() { for (;;) { int current get(); int next current 1; if (compareAndSet(current, next)) { return next; } } }自旋 CAS 在竞争激烈时会导致大量线程空转占用 CPU。JDK 8 之后引入了LongAdder来缓解这个问题它通过分段累加、最后合并的方式把竞争分散到多个 cell 上。在极高并发下LongAdder的吞吐量远高于AtomicLong代价是内存占用更多、读取的最终一致性不如AtomicLong精确。5. 常见问题与排查技巧实录锁相关的问题在实际项目中非常隐蔽有时候线上服务无响应、CPU 飙升、接口超时排查到最后才发现是锁的问题。这一章我把工作中遇到的高频问题和排查思路整理成速查表也分享一些独家避坑技巧。5.1 死锁与活锁的识别与解决死锁是指多个线程互相持有对方需要的锁导致所有线程都无法继续执行。经典场景是线程 A 持有锁 1 等待锁 2线程 B 持有锁 2 等待锁 1。发生死锁时线程不会报错只会永久阻塞接口无响应但 CPU 占用可能很低。排查死锁的第一手段是jstackjstack pid thread_dump.txt在 dump 文件中搜索Found one Java-level deadlock关键字JVM 会直接给出死锁的线程、持有的锁和等待的锁。下文是一个典型的死锁输出片段真实输出格式略有不同Found one Java-level deadlock: Thread-B: waiting to lock monitor 0x00007f8b8c002800 (object 0x000000076b51f490, a java.lang.String) owned by Thread-A解决死锁的原则是有多个锁时所有线程按相同的顺序获取锁。如果业务上无法避免多把锁可以尝试用tryLock加超时机制获取不到锁就释放自己已经持有的锁避免无限等待。活锁是指线程没有阻塞但一直重复做无用操作比如两个线程在检测到冲突后都主动让出资源结果反复互相谦让谁都无法推进。解决活锁通常需要引入随机退避时间让线程在冲突后等待不同时长再重试。5.2 锁竞争导致的性能瓶颈线上遇到最多的性能问题就是锁竞争。表现是接口 RT 升高、吞吐量下降、线程池队列积压但 CPU 不一定升高因为大量线程阻塞在锁上。常见排查步骤用jstack取线程 dump检查是否有大量线程处于BLOCKED状态。找到BLOCKED线程的等待锁地址确认哪把锁竞争最激烈。用jstat -gcutil观察 GC 情况排除 GC 导致的假象。用Async Profiler或Arthas在火焰图中定位锁竞争热点。优化锁竞争最有效的方式是缩小锁粒度。比如把对整个集合的锁改为对每个分段的锁类似于ConcurrentHashMap在 JDK 7 中采用的分段锁设计。JDK 8 的ConcurrentHashMap改为 CAS synchronized 锁链表头节点粒度更细进一步降低了竞争。5.3 锁对象选择不当导致的隐蔽 Bug锁对象选择有讲究比如对字符串做 intern 作为锁对象、对 Integer 缓存对象做锁都是极其危险的。字符串字面量在 JVM 中会被缓存不同地方看似独立的字符串实际上指向同一个对象用它们做锁会导致无关代码被意外串行化。最简单的反例synchronized (String.valueOf(userId).intern()) { // 试图按用户维度加锁 }如果你用valueOf(1).intern()和1分别加锁由于字符串常量池的存在它们实际上锁的是同一个对象直接导致不同业务的锁被串在一起。正确做法是使用专门的锁对象比如ConcurrentHashMap的computeIfAbsent来生成每个 key 对应的锁对象。5.4 volatile 与锁混用时的常见误区很多人会在加锁的代码块里使用volatile变量或者在volatile变量的读写中套锁。混用本身没问题但容易产生两个误区误区一是认为volatile变量在 lock 保护范围内就不需要再加其他同步措施。实际上synchronized已经保证了内存可见性和原子性针对受锁保护的复合操作如果volatile还承担其他路径的读写就要仔细分析内存语义。误区二是习惯在 getter 上加锁但 setter 不加这是典型的“部分加锁”反模式。如果读操作和写操作都有并发需求应该两侧对称加锁。public class Counter { private int count 0; // 错误只加读锁不加写锁 public synchronized int get() { return count; } public void set(int value) { this.count value; } }这种代码在面试中经常作为找 Bug 题出现。修复方式是把 set 方法也加上synchronized或者改用AtomicInteger。5.5 面试高频锁相关八股文的正确打开方式Java 锁分类是面试八股文里的必修课但面试官真正想考察的往往不是背诵能力而是能否把不同维度的分类讲清楚并且结合场景给出合理选择。我建议按照以下框架组织答案先给出分类维度再逐一展开最后落到场景选型。比如提问“Java 中的锁有哪些分类”你可以回答按竞争态度分为公平锁和非公平锁按资源访问方式分为乐观锁和悲观锁按线程间关系分为可重入锁和不可重入锁按并发访问范围分为共享锁和独占锁按锁的状态升级分为偏向锁、轻量级锁和重量级锁。每讲一个分类都要带上具体实现和适用场景比如乐观锁对应 CAS、悲观锁对应 synchronized 和 ReentrantLock、共享锁对应 ReentrantReadWriteLock 的读锁、独占锁对应写锁。面试官如果深入追问 AQS 的原理或者锁升级的触发条件就考察到源码层面了这部分只能靠实际理解和多看源码来积累。6. 工程实战中的锁选型建议框架讲完了原理也梳理了最后落到实际操作上面对一个具体的并发场景到底该怎么选锁。6.1 场景驱动的选型决策表我根据自己的项目和排障经验梳理了一张简化选型表适用大部分后端业务场景场景特征推荐方案原因简单互斥无复杂条件等待synchronized语法简洁自动释放锁性能足够需要超时拿到锁ReentrantLock.tryLock(timeout)避免无限等待导致系统不可用需要可中断获取锁ReentrantLock.lockInterruptibly支持响应中断便于任务取消读多写少读操作远多于写操作ReentrantReadWriteLock 或 StampedLock读读并发显著提升吞吐量计数器、累加器、状态标记AtomicLong / LongAdder / volatile无锁化减少上下文切换共享资源按 key 分散ConcurrentHashMap 细粒度锁降低单点锁竞争跨多个资源的一致性控制显式锁 固定获取顺序 tryLock降低死锁风险便于排查注意这个表是参考不是万能公式。实际项目中往往要结合具体的并发量、临界区大小、资源数量、业务容忍度来综合判断。6.2 高并发库存扣减的锁优化实践电商场景的库存扣减是经典案例。初始版本可能是这样的写法public synchronized boolean reduceStock(long skuId, int num) { Stock stock stockMapper.selectBySkuId(skuId); if (stock.getAvailable() num) { return false; } stock.setAvailable(stock.getAvailable() - num); stockMapper.updateById(stock); return true; }这个版本的问题是所有 SKU 的扣减操作都竞争同一个锁即使不同 SKU 的库存互不影响也会被串行化。优化方向是按 SKU 维度加锁让不同 SKU 的扣减并行执行。private final ConcurrentHashMapLong, Lock lockMap new ConcurrentHashMap(); public boolean reduceStock(long skuId, int num) { Lock lock lockMap.computeIfAbsent(skuId, k - new ReentrantLock()); lock.lock(); try { Stock stock stockMapper.selectBySkuId(skuId); if (stock.getAvailable() num) { return false; } stock.setAvailable(stock.getAvailable() - num); stockMapper.updateById(stock); return true; } finally { lock.unlock(); } }computeIfAbsent能保证同一个 SKU 返回的锁对象是同一个实例不同 SKU 使用不同的锁从而并行执行。但要注意锁对象数量会随着 SKU 数量增长极端情况下内存占用不可忽略。如果库存数据量很大可以考虑用分段锁代替每 key 一锁。更进一步的方案是使用数据库乐观锁在 SQL 层面做原子更新UPDATE stock SET available available - #{num} WHERE sku_id #{skuId} AND available #{num}通过判断UPDATE影响行数是否为 1 来确定扣减是否成功。这种方案完全避免了 JVM 锁并发能力最强但需要配合重试机制处理冲突。6.3 异步任务中的锁使用注意点异步任务和消息消费场景里使用锁要格外小心。一个常见问题是在事务方法里加锁锁的范围覆盖了事务提交之前的所有操作导致锁持有时间过长。我遇到过一个业务逻辑方法加锁后先查数据库、远程调用外部接口、再写库、最后提交事务。每次远程调用耗时 200ms 左右这段时间内所有请求都被阻塞在同一把锁上。优化方向是尽量把锁范围缩小到真正需要互斥的代码段或者把远程调用移到锁外。另一个问题是锁与事务的次序。在一个事务内嵌套了锁释放锁之后事务还没提交其他线程获取锁后读取到的数据可能还是旧值。常见做法是先提交事务再释放锁但 Spring 的事务边界和锁边界往往不好对齐这时候需要用编程式事务手动控制边界。6.4 从面试视角重新审视锁方案如果把锁选型作为面试题我通常会被这样追问“一个秒杀系统扣减库存你会怎么设计”我的思路是分层回答先分析业务特点读多写少、热点集中、要求高吞吐再给出方案演进路径——从简单的 synchronized 到按 SKU 分段加锁再到数据库乐观锁最后到 Redis 分布式锁如果跨多个实例。每个演进阶段都要说明解决了什么问题、引入了什么新问题以及为什么在某个阶段停下来。这样的回答比直接说“用分布式锁”要好得多因为它展示了你对锁分类、锁粒度、锁竞争的完整理解。6.5 分布式场景的锁延伸本文讨论的锁都是 JVM 进程内的锁只能解决单机并发问题。在微服务架构中多个实例部署每个实例有自己的 JVM 堆内存进程内锁无法跨实例互斥此时就需要分布式锁。分布式锁的实现方式有基于数据库唯一索引、基于 Redis 的 SETNX、基于 ZooKeeper 的临时有序节点等。这部分内容虽然超出了 Java 锁分类的范围但作为选型延伸还是值得知道。不过分布式锁的时序问题、可重入问题、锁续期问题比进程内锁复杂得多如果项目里需要用到建议单独花时间去研究。7. 实操验证用一个并发压测 Demo 验证锁分类差异理论说了很多最后带大家做一个简单的压测实验用实际数据来验证不同锁的差异。这个实验不需要复杂的框架一个 Java 类加一个压测工具就能跑。7.1 实验设计我设计了一个模拟抢票的场景一万个线程同时调用reduce方法目标是把库存从一千万扣到零通过统计最终剩余库存和耗时来评估不同锁实现的正确性和性能。锁方案四选一synchronized方法、ReentrantLock、ReentrantReadWriteLock写锁、StampedLock写锁。public class LockDemo { private int inventory 10_000_000; public synchronized void syncReduce(int num) { inventory - num; } private final ReentrantLock lock new ReentrantLock(); public void lockReduce(int num) { lock.lock(); try { inventory - num; } finally { lock.unlock(); } } }压测用CountDownLatch控制线程同时就绪用ExecutorService创建线程池。跑完统计inventory是否为 0以及总耗时。7.2 结果观察与解读在实际压测中synchronized和ReentrantLock的性能差距在不同 JDK 版本上差异很大。JDK 8 上ReentrantLock通常略优JDK 11 之后 synchronized 经过优化也基本持平两者差距已经很小。ReentrantReadWriteLock写锁在纯写场景下没有优势因为写锁本身就是独占的它的价值在于读并发而不是写并发。StampedLock的写锁也类似单纯扣减库存场景并不能体现它的长处它擅长的是“先乐观读、再决定加不加锁”的复合操作。这类实验最核心的收获并不是比较谁的耗时最少而是验证一个结论锁的选择必须与业务读写模型匹配脱离场景谈锁性能没有意义。7.3 踩坑记录测试中的假安全压测过程中我第一次写的代码最终库存不为 0排查发现是操作不是原子的inventory - num在字节码层面是读、减、写三步不是一行代码就能保证原子性。加上 synchronized 后才正确。这个细节提醒我凡是做并发正确性验证一定不能只看代码逻辑要看字节码和内存语义。volatile修饰的整型变量自减也同理不保证原子性。最后再分享一个小技巧并发压测时建议同时用jstack抓几次线程快照观察锁竞争时的线程状态分布。如果大量线程处于WAITING状态说明锁竞争比较激烈如果大量线程RUNNABLE但业务进展缓慢可能是自旋耗尽了 CPU。这种现场数据比事后分析出来的结论更有说服力也能帮你更准确地判断优化方向是否有效。