Java锁机制全解:从synchronized到AQS的并发核心原理与实践
2026/9/9 11:25:49 网站建设 项目流程

线上真正出问题的时候,你才会理解Java锁机制到底在解决什么问题。有段时间我在电商团队负责订单系统,上线一个月后促销活动把流量拉起来,监控突然报警:某热门SKU的库存被扣成负数,订单比实际库存还多。查日志发现扣减库存的接口同时被多个线程执行,读库存、判断库存、扣库存这三步中间被穿插,A线程读到库存剩1,B线程也读到剩余1,两边都判断可以下单,最后库存被扣成-1。

这个问题的本质是:多个线程并发访问同一个共享变量,而“读-判断-写”这个操作序列不是原子的,没有正确的互斥保护。当时团队里有人提议直接在方法上加synchronized,有人觉得应该上分布式锁,还有人坚持用数据库乐观锁,讨论到最后才发现,大家连“synchronized到底锁的是什么”都没完全统一。这篇文章我就从这类真实场景出发,把Java锁机制的各个层面串起来讲一遍,适合正在准备Java面试的人,也适合写业务代码时遇到并发问题不知道怎么下手的开发者。看完你至少能回答清楚这几个问题:synchronizedLock分别用在什么场景、锁升级是怎么回事、AQS到底做了什么事、读写锁什么时候该用、线上死锁怎么排查。

1. 并发场景下的锁:它到底锁的是什么

1.1 临界区、竞态条件与共享变量的三角关系

先说三个最基础的概念,这三个概念理解了,后面所有锁的知识都能挂上去:

  • 共享变量:多个线程都能访问到的变量。比如上面的库存字段、static集合、缓存里的对象、数据库里某一行记录映射到Java层的对象。
  • 临界区:访问共享变量的代码片段。比如“判断库存大于0 -> 扣库存”这段代码,就是一个临界区。
  • 竞态条件:多个线程同时进入临界区,执行结果依赖于线程的执行时序,导致结果不正确。

锁的作用,本质上就是让临界区在同一时间点只有一个线程能进去,保证“互斥”。Java锁机制里无论synchronizedReentrantLock还是读写锁,核心目标都是互斥访问,只不过在不同场景下用不同机制去实现互斥。

这里有个常被忽视的点:锁不是锁“变量”,而是锁“代码路径”。同一个变量,如果有一处访问被锁保护,另一处访问没被锁保护,照样会出现竞态条件。这也是很多bug的根源——你以为加了锁,其实只保护了部分代码路径。我在代码评审里见过太多次“局部加锁”问题,建议大家都形成一个习惯:一个共享变量,所有读写路径都必须走同一把锁。

1.2 synchronized三种用法和锁对象判定

大多数Java开发者第一反应是用synchronized,因为它是JVM内置的,语法最简单。但synchronized用起来简单,不代表不会用错。它有三种用法:

  1. 修饰实例方法:锁的是当前实例对象this
  2. 修饰静态方法:锁的是当前类的Class对象。
  3. 修饰代码块:锁的是括号里指定的对象。
public class Counter { private int count = 0; public synchronized void increment() { count++; } public static synchronized void staticIncrement() { // ... } public void incrementBlock() { synchronized (this) { count++; } } }

三种写法的锁对象分别是thisCounter.classthis。如果是同一个锁对象,那多个线程对这些方法就是互斥的;如果锁对象不一样,那就各锁各的,互不影响。

有个非常常见的坑:在Spring默认单例Bean里用synchronized修饰方法,锁对象是this,通常没问题;但如果这个类被new了多个实例,synchronized方法只能锁住同一个实例上的并发访问,跨实例之间是无效的。也就是说,synchronized只能解决JVM进程内、同一个对象上的并发问题,进程间的分布式场景它管不了。这也是为什么后来要引入分布式锁。

顺便提醒一句,synchronized修饰代码块时,锁对象的选择直接决定锁粒度。如果你把一个大业务方法整个包进synchronized(this),那所有线程都串行执行,性能会非常难看。正确做法是尽量缩小临界区范围,只锁真正需要保护的共享变量操作。

1.3 锁同时解决的原子性与可见性问题

很多人以为锁只解决“互斥”,其实它还承担着另一个重要职责:可见性。Java内存模型里,每个线程有自己的工作内存,线程A修改了变量,线程B不一定能立刻看到。如果不用锁,又没有volatile修饰,B可能一直读到旧值。synchronizedLock在底层都会建立内存屏障,保证锁释放时对共享变量的修改,能对后续获取同一把锁的线程可见。

所以你在处理共享变量时,光加锁还不够,还必须让所有读写都在“同一把锁的保护范围内”完成。如果读操作不加锁,靠“运气”去读,倒也不会马上出错,但线上的偶发问题基本都是这么埋下的。等到出了问题,排查链路会非常长。

我在实际项目里遇到过这样一个案例:某个热点配置使用了volatile修饰,大家觉得volatile能保证可见性就够了,但配置更新涉及两个关联字段,需要一次性原子更新。结果并发读的时候,有的线程读到了A字段是新值、B字段是旧值的中间态,逻辑直接错乱了。后来改成用读写锁保护,问题才消失。这说明:volatile只保证可见性,不保证复合操作的原子性;而锁可以同时保证原子性和可见性。

2. 从synchronized到Lock接口:两大派系怎么选

2.1 Lock接口补上了synchronized的哪些短板

synchronized虽然简单,但它在JDK 5之前的能力是有限的,主要体现在:

  • 不能中断一个正在等待锁的线程。线程进入阻塞状态后,外部没法打断它。
  • 不能设置超时时间。如果某把锁一直不释放,其他线程只能一直等下去。
  • 非公平锁是唯一模式,没法主动选择公平性。
  • 没法实现“读读并发、写写互斥”这种细粒度控制,锁的粒度只有“完全互斥”一种。

这些痛点催生了Lock接口:

public interface Lock { void lock(); void lockInterruptibly() throws InterruptedException; boolean tryLock(); boolean tryLock(long time, TimeUnit unit) throws InterruptedException; void unlock(); Condition newCondition(); }

其中tryLocklockInterruptibly是两个非常实用的API。tryLock()可以立刻返回尝试结果,拿不到锁就返回false,业务代码可以选择执行其他逻辑或稍后重试。lockInterruptibly允许线程在等待锁的过程中响应中断,这一点在需要优雅停止任务的场景特别重要。比如你有一个后台线程池任务,如果线程卡在等待锁上,你又希望它能响应关闭信号,用lockInterruptibly就有办法让它退出。

2.2 ReentrantLock的可重入与公平性细节

ReentrantLockLock接口最经典的实现,名字里的“Reentrant”指可重入——同一个线程可以重复获取同一把锁,不会把自己锁死。

ReentrantLock lock = new ReentrantLock(); public void outer() { lock.lock(); try { inner(); } finally { lock.unlock(); } } public void inner() { lock.lock(); try { // ... } finally { lock.unlock(); } }

如果不是可重入锁,outer里调用inner,第二次lock()的时候,线程会发现自己已经持有这把锁,然后陷入死锁。可重入锁内部会为每个线程记录持有次数,每次lock加1,每次unlock减1,减到0才真正释放锁。

ReentrantLock还支持传入fair参数:new ReentrantLock(true)创建公平锁,new ReentrantLock(false)创建非公平锁。公平锁会让等待时间最长的线程优先获得锁,听起来很合理,但实际项目中公平锁的性能会打折,因为它在每次获取时都要维护严格的排队顺序。非公平锁允许新来的线程尝试“插队”,性能通常更好,但极端情况下可能导致某些线程长时间等待。如果你的业务对公平性没有硬性要求,默认用非公平锁就好,不要为了“图心理安慰”选公平锁。

2.3 面试常问的synchronized与Lock对比

这个问题几乎是Java八股文必考,我在面试别人时也经常问。整理成表格方便大家记忆:

对比维度synchronizedReentrantLock
实现层级JVM关键字,C++实现JDK API,Java类实现
锁获取与释放自动获取,异常自动释放手动lock/unlock,需在finally中保证释放
是否可中断等待锁时不可中断lockInterruptibly支持中断
是否可超时tryLock(timeout)支持
公平性只能非公平可选公平/非公平
条件变量通过wait/notify配合Condition,可创建多个条件队列
锁粒度只有互斥互斥/读写分级

JDK 6以后synchronized经过锁升级优化,性能已经和ReentrantLock差距很小了。选型时,能直接写synchronized的简单场景就选它,代码短、不易出错;需要超时、中断、公平性、多条件变量的时候,再考虑ReentrantLock。不要一上来就Lock,也不要死守着Synchronized不放,两者不是对立关系,是互补。

3. 锁升级全路径:无锁、偏向锁、轻量级锁、重量级锁

3.1 HotSpot为什么要设计多级锁

聊到JDK 6就不得不提锁升级。很多人会把“锁升级”和“锁粗化”搞混,其实这是两个完全不同的机制:锁升级是运行时根据竞争程度动态调整锁的状态,锁粗化是编译器层面的优化,把多个相邻的加锁操作合并成一个。

锁升级的设计动机很简单:不是所有锁都存在激烈竞争。大部分情况可能只有一个线程访问,少数情况有几个线程交替访问,极端情况才是多线程同时竞争。如果一律用重量级锁,那每次加锁都要走操作系统内核态,成本很高。所以HotSpot把锁分成了四种状态:无锁、偏向锁、轻量级锁、重量级锁,根据竞争情况从轻到重逐级升级。

3.2 Mark Word里的锁状态变迁

Java对象头里的Mark Word是记录锁状态的关键区域。32位JVM里它只有32位,但既放hashCode,又放GC分代年龄,还要放锁状态,所以设计得非常“挤”。不同锁状态对应的Bit位布局不同,直接说结论:

锁状态Mark Word记录内容触发条件
无锁对象hashCode、分代年龄等信息初始状态
偏向锁持有锁的线程ID同一线程再次进入,直接比对线程ID,无需CAS
轻量级锁指向线程栈中锁记录的指针第二个线程尝试获取偏向锁时,偏向锁撤销升级
重量级锁指向操作系统管程(Monitor)的指针CAS自旋失败,竞争激烈时膨胀

整个过程叫“锁升级”,但注意它只会升级,不会降级。这也是面试里一个容易答错的点:偏向锁撤销后不会恢复成偏向锁,重量级锁即使竞争减小也不会变回轻量级锁。

3.3 锁消除与锁粗化:编译器层面的另外两招

除了运行时锁升级,JIT编译阶段也有两招值得了解:

锁消除:如果JVM通过逃逸分析判定某个对象不会逃逸出当前线程,那对这个对象加锁就没有意义,编译器会直接去掉锁。比如局部变量只在单线程内使用,却用了StringBuffer(它的方法带synchronized),JVM发现锁没作用就消除了。

锁粗化:如果JVM发现相邻的多个同步块使用的是同一把锁,且中间没有其他线程介入,它会把多个小的同步块合并成一个大的同步块,减少反复加锁/解锁的开销。比如循环里反复对同一个锁对象加锁,JIT可能会把整个循环都框进锁里。

这两招说明一个问题:JVM的锁优化不是死的,运行时表现和编译后的代码可能已经和你写的代码不完全一样了。所以性能调优不要靠猜,要以压测和JMH基准测试为准。

3.4 偏向锁的遗留问题与JDK 15之后的默认关闭

偏向锁在JDK 15开始被默认关闭,JDK 18之后已被标记为废弃。为什么?因为偏向锁在某些场景下不但没有提速,反而成为负担。

偏向锁的撤销需要触发安全点STW,即使只有一个线程在竞争,也可能因为撤销逻辑导致短暂的停顿。现代应用普遍使用线程池,线程复用导致锁竞争模式复杂,偏向锁带来的收益变得不明显。加上Stop-The-World对延迟敏感应用影响很大,官方最终选择默认关闭偏向锁。

这个案例告诉我们,JVM的锁优化不是静态的,它也在跟着应用场景演进。面试时问到偏向锁,不要只背状态流转,能说出“为什么JDK 15之后默认关闭”会显得更有深度。

4. AQS与ReentrantLock:几乎所有Lock的底座

4.1 AQS的state、CLH队列和模板方法

AQS(AbstractQueuedSynchronizer)几乎是Java并发包的地基。ReentrantLockSemaphoreCountDownLatchReentrantReadWriteLock底层全都依赖它。

它的核心就三个东西:

  1. state:一个volatile修饰的int变量,代表锁状态。state=0表示没有线程持有锁,state=1表示有一个线程持有,ReentrantLock可重入时会变成2、3……
  2. CLH变体队列:等待获取锁的线程排成的FIFO双向链表。
  3. 模板方法:获取锁和释放锁的骨架逻辑由AQS定义,子类只需要实现tryAcquiretryRelease等方法,决定“什么条件下能拿到锁”。

4.2 ReentrantLock加锁/解锁源码级拆解

以非公平锁的lock()为例,大致流程:

final void lock() { // 1. 先尝试CAS把state从0改成1 if (compareAndSetState(0, 1)) setExclusiveOwnerThread(Thread.currentThread()); else acquire(1); // 2. 失败则进入AQS的acquire流程 }

acquire里会调用tryAcquire,如果再次失败,就把当前线程包装成Node加入等待队列,然后通过LockSupport.park挂起线程。释放锁的时候,state减1,减到0说明锁完全释放,再唤醒队列里的下一个线程。

这个流程有几个点很值得琢磨:

  • 非公平锁为什么“非公平”?因为lock()上来就先做了一次CAS插队,没排队就直接抢,这是第一层“插队”。如果CAS失败进入acquire,里面还有一次tryAcquire机会。所以非公平锁的获取机会比公平锁多,吞吐更高。
  • LockSupport.park/unparkObjectwait/notify不同park不需要在synchronized块里使用,机制上更轻量。
  • 可重入是怎么实现的?tryAcquire里会判断当前线程是不是已经持有锁的线程,是的话state+1,并且只增加持有计数,不改变持有者。

4.3 用AQS手写一个简单的不可重入锁

不用AQS的时候,你很难感受到它的价值。我写一个超简单的不可重入锁,帮助你理解模板方法:

import java.util.concurrent.locks.AbstractQueuedSynchronizer; public class SimpleLock { private static class Sync extends AbstractQueuedSynchronizer { @Override protected boolean tryAcquire(int arg) { if (compareAndSetState(0, 1)) { setExclusiveOwnerThread(Thread.currentThread()); return true; } return false; } @Override protected boolean tryRelease(int arg) { if (getState() == 0) { throw new IllegalMonitorStateException(); } setExclusiveOwnerThread(null); setState(0); return true; } } private final Sync sync = new Sync(); public void lock() { sync.acquire(1); } public void unlock() { sync.release(1); } }

这段代码的tryAcquiretryRelease决定了锁的核心行为,线程排队、阻塞唤醒这些复杂逻辑全部由AQS处理。所以在读并发源码时,只要抓住每个同步器里重写的那几个方法,就能很快读懂全貌。面试时如果被问到“AQS是什么”,不要只说“一个队列同步器”,最好能说出state、CLH队列、模板方法三个关键点,然后举一个ReentrantLock的加锁流程,基本就能过关。

5. 读写锁与StampedLock:读多写少场景的进阶选择

5.1 ReentrantReadWriteLock的使用边界

实际项目中,很多共享数据的操作是“读多写少”,比如配置表、热榜数据、商品详情缓存。如果全部用互斥锁保护,读和读之间也串行,明显浪费性能。读写锁的思路是:读锁和读锁不互斥,读锁和写锁互斥,写锁和写锁互斥。这样多个线程可以同时读,写的时候才独占。

private final ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock(); private final Lock readLock = rwLock.readLock(); private final Lock writeLock = rwLock.writeLock(); private Map<String, Object> cache = new HashMap<>(); public Object get(String key) { readLock.lock(); try { return cache.get(key); } finally { readLock.unlock(); } } public void put(String key, Object value) { writeLock.lock(); try { cache.put(key, value); } finally { writeLock.unlock(); } }

但这里有个隐藏很深的坑:Java里的ReentrantReadWriteLock写锁可以降级为读锁,读锁不能升级为写锁。如果你在持有一个读锁的线程里尝试获取写锁,会一直阻塞,因为写锁需要等待所有读锁释放。经典的死锁案例就是这么来的。另外,读锁虽然允许并发读,但高并发写请求频繁时,写锁可能会被读锁饿死,尽管默认非公平模式下会尽量减少这种情况,但并不能完全禁止。

5.2 StampedLock的乐观读与锁升级陷阱

JDK 8引入了StampedLock,它的核心创新是“乐观读”。乐观读在进行读操作时不加锁,只记录一个stamp版本号,读完之后再校验版本号有没有变化。如果没变化,说明读的过程没有写操作介入,数据有效;如果变了,说明有并发写,这时再升级为读锁重新读一遍。

long stamp = lock.tryOptimisticRead(); Object val = cache.get(key); if (!lock.validate(stamp)) { stamp = lock.readLock(); try { val = cache.get(key); } finally { lock.unlockRead(stamp); } }

这里有几个必须注意的点:

  • 乐观读只在没有写入时高效。如果有写线程经常改数据,它会频繁CAS失败然后升级为真正的读锁,性能反而可能比ReentrantReadWriteLock还差。
  • StampedLock不可重入的,同一个线程不能重复获取同一把锁,这和ReentrantLock完全不同。
  • StampedLock没有实现Condition接口。
  • 它也不支持中断,在获取锁时使用park,如果处理不好可能导致线程难以中断。

所以我个人的建议是:StampedLock是性能优化选项,不是默认选项。绝大多数业务场景,ReentrantReadWriteLock就够用了。想清楚你的数据到底有多高的并发读频率,再去优化这把锁。

6. 死锁排查与锁粒度调优:实战避坑经验

6.1 一次死锁的完整排查链路

某次线上服务出现偶发超时,排查后发现是死锁。具体表现是:线程A持有锁1,等待锁2;线程B持有锁2,等待锁1。两边卡死。

当时我用jstack打印线程堆栈,看到的关键信息是:

"Thread-A" - waiting to lock <0x00000000f1234567> (a java.lang.Object) "Thread-B" - holding <0x00000000f1234567> ... waiting to lock <0x00000000f89abcdef>

看到这种交叉等待,基本就能定位到死锁。解决方式有几个:

  1. 锁顺序全局限定:所有需要获取多把锁的地方,都按固定顺序获取,比如先锁A再锁B,杜绝反向获取。
  2. 使用tryLock(timeout):获取锁失败后不无限等待,超时后执行补偿逻辑或重试。
  3. 使用lockInterruptibly:配合线程中断机制,在外部让线程响应中断。

不想手工分析的话,JDK自带的jcmdjconsoleVisualVM都有死锁检测功能。高并发服务建议在测试环境压测时直接跑一轮死锁检测,尽早暴露问题,不要等线上出了故障再去翻堆栈。

6.2 锁粒度与锁顺序的取舍:一次性能调优实录

锁粒度调优是并发编程里最需要权衡的一环。我举一个实际例子:某个公共缓存对象同时被多个接口读写,最开始用一把大锁把所有操作都保护起来,压测发现吞吐量上不去,GC压力也大。后来我做了三件事:

  • 把锁粒度从“整个缓存对象一把锁”降到“每个缓存key一把锁”。
  • 写操作使用读写锁,避免读读互斥。
  • 热点数据单独拆出来,用无锁的ConcurrentHashMap+volatile原子更新。

这三件事下来,吞吐量提升明显。但要注意,锁粒度越细,代码复杂度越高,加锁顺序的错乱风险也越大。一定要在“性能收益”和“代码可维护性”之间做权衡,不要为了性能把所有地方都搞成细粒度锁,到时候出了并发问题都找不到排查入口。

6.3 根据场景选锁的一页纸总结

最后给你一个选型参考,这也是我在项目里给团队定的一条通用规则:

场景推荐方案原因
简单方法级互斥,低竞争synchronized代码简洁,JVM有锁升级优化,性能足够
需要超时、中断、公平性ReentrantLockAPI丰富,灵活控制等待行为
读多写少,读操作不频繁写ReentrantReadWriteLock读写分离,读读并发
超高频读、数据一致性要求稍宽松StampedLock乐观读无锁读取,性能最高
分布式多节点互斥Redis/ZooKeeper分布式锁跨进程/跨节点需要外部协调
单key热点数据,读多写少volatile+ CAS无互斥等待,性能最优
频繁读写的极热数据ConcurrentHashMap+ Striped锁避免全局争抢

你可能注意到这里没有提到“原子类”。实际上AtomicIntegerLongAdder这些原子类用的就是CAS无锁方案,在单变量计数器、累加器场景下比任何锁都快,但只能保证单个变量的原子性,多变量组合操作没办法靠它实现。

我在实际项目里的体会是,锁方案的选择没有银弹。你不需要把每种锁的原理都背得一字不差,但需要知道每个方案适合什么问题、不适合什么问题,以及线上出了问题怎么排查。Java锁机制这套东西,从底层JVM的锁升级,到JDK并发包的AQS,再到应用层的选型,是一条完整的技术链路,把这条路走通,你写并发代码就踏实了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询