在 Java 并发编程里,锁是一个永远绕不开的核心话题。无论是日常开发中处理共享资源的竞争,还是面试时被问到的各种并发原理,锁都是决定系统性能和安全性的关键。很多人对锁的理解停留在“synchronized 和 Lock 的区别”这个层面,但真正被问到“Java 中的锁分类”时,往往只能说出个大概,或者把不同维度的分类混在一起讲,导致逻辑混乱。
这篇文章我从实际开发和面试两个角度出发,把 Java 里的锁体系彻底拆开来讲:按线程竞争态度分公平锁与非公平锁,按资源访问方式分乐观锁与悲观锁,按线程间关系分可重入锁与不可重入锁,再深入到 synchronized 的锁升级机制、AQS 队列同步器、读写锁、StampedLock 等具体实现。整个过程会结合源码、使用场景、性能对比和踩坑经验,尽量做到既能让初学者建立完整框架,也能让有经验的开发者收获一些底层细节。
1. 锁的宏观分类:一张图看透 Java 锁的全貌
锁的分类维度非常多,如果只靠死记硬背,很容易被绕晕。我自己的理解方式是:不要把锁看成单一概念,而是从多个维度去观察它,就像描述一个人可以从性别、年龄、职业、性格等维度展开,锁也一样。每个维度回答不同的问题,组合起来才能完整描述一个锁的特性。
1.1 线程竞争态度:公平锁 vs 非公平锁
这是面试里最常被问到的分类之一。公平锁指的是多个线程按照申请锁的顺序来获取锁,先来后到,类似排队买票;非公平锁则是线程直接尝试抢锁,抢不到再排队,类似高峰期挤公交,谁力气大谁先上。
在 Java 里,ReentrantLock默认是非公平锁,但可以通过构造方法传入true来启用公平模式。synchronized本身也是一种非公平锁,它不保证等待时间最长的线程优先获得锁。
为什么默认用非公平锁?因为公平锁需要维护一个有序队列,线程切换和唤醒的开销更大,吞吐量通常会低于非公平锁。非公平锁虽然可能造成“插队”现象,导致某些线程长时间拿不到锁,但它的整体性能更好,因为减少了线程唤醒带来的上下文切换开销。
我在实际项目里通常默认使用非公平锁,只有在业务上严格要求线程执行顺序(比如某些任务必须按提交顺序执行)时才会启用公平锁。
1.2 资源访问方式:乐观锁 vs 悲观锁
这个分类体现了对待并发冲突的两种截然不同的态度。悲观锁假设冲突一定会发生,所以在操作数据之前就加锁,阻塞其他线程的访问。synchronized和Lock接口的实现类都属于悲观锁。
乐观锁假设冲突很少发生,所以不加锁,而是在更新数据时检查数据是否被其他线程修改过。如果没被修改就直接更新,如果被修改了就重试或放弃。Java 中的CAS(Compare And Swap)操作就是乐观锁的典型实现,AtomicInteger等原子类底层用的就是 CAS。
乐观锁适合读多写少的场景,悲观锁适合写多的场景。比如电商的库存扣减,如果用悲观锁,所有请求串行执行,性能会很难看;如果用乐观锁(版本号或 CAS),并发量能提高不少,但需要处理冲突重试和 ABA 问题。
1.3 线程间关系:可重入锁 vs 不可重入锁
可重入锁也叫递归锁,指的是同一个线程在外层方法获得锁之后,内层方法再次获取该锁时不会被阻塞。Java 里synchronized和ReentrantLock都是可重入锁。
举个例子,方法 A 加了锁,然后调用方法 B,方法 B 也加了同一把锁。如果是可重入锁,线程在持有锁的情况下能直接进入方法 B;如果是不可重入锁,线程会被自己阻塞住,形成死锁。
不可重入锁在 Java 原生 API 里很少见,但理解这个概念很重要,因为有些自定义锁或者非 Java 语言的锁实现是不可重入的。我在写基于AbstractQueuedSynchronizer(AQS)的自定义同步组件时,必须自己处理重入逻辑,否则就会出现问题。
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 累加实现可重入计数;获取失败则返回 false,AQS 会把线程挂入等待队列。
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: stack=2, locals=3, args_size=1 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 占用可能很低。
排查死锁的第一手段是jstack:
jstack <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 ConcurrentHashMap<Long, 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。这种现场数据比事后分析出来的结论更有说服力,也能帮你更准确地判断优化方向是否有效。