ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Java锁机制深度解析:从synchronized到AQS的工程实践

Java锁机制深度解析:从synchronized到AQS的工程实践 Java锁机制这四个字几乎是Java面试八股文里绕不开的硬通货。我做面试官这几年见过太多候选人简历里写着熟悉并发编程一开口却只能背出volatile保证可见性、synchronized保证原子性这种零散结论稍微追问一句对象头里锁状态怎么存的就卡壳。反过来凡是能把synchronized的锁升级、ReentrantLock的AQS原理讲成一个完整链条的人线上排查并发问题的时候基本也不会太差。这篇博文不打算给你一份新的背诵手册而是想带着你把Java锁机制从八股还原成工程经验面试官到底在问什么、底层是怎么实现的、实际项目里怎么选锁、出了锁问题怎么排查。适合正在准备Java面试的开发同学也适合已经写了好几年并发代码、但没系统梳理过锁机制的工程师。文章会沿着synchronized和AQS两条主线展开中间穿插真实业务里能用到的锁设计和常见坑。你不需要一字一句背下来只要理解了里面的因果关系面试的时候自然能展开讲。1. 先说结论Java锁机制到底在考什么1.1 为什么面试官揪着锁不放Java锁机制不是一个孤立的语法点它是并发编程的基石。面试官爱问锁是因为一个简单的synchronized就能牵出JMM、对象头、CAS、线程调度、AQS、LockSupport这一整条知识链。这些点单独背都不难但串起来就暴露了真实理解程度。比如问ReentrantLock和synchronized怎么选普通回答是ReentrantLock功能多、synchronized性能好这其实是很多年前的结论并不准确。更准确的回答应该是JDK 6之后synchronized做了大量锁优化简单场景下两者性能差异很小选型主要看需不需要可中断、超时获取、多条件队列这些能力。这个回答背后就需要你真正理解锁的底层实现。另外锁机制和线上故障强相关。我参与过好几次接口超时复盘最后都指向锁竞争某个临界区持锁时间过长几十个线程堵在同一把锁上数据库连接池被打满。面试官在没有高并发项目可问的时候只能用八股来验证基础牢不牢。所以锁机制八股文这一题表面考记忆实际考的是你有没有在代码里思考过锁到底锁住了什么、代价是什么。回答的时候与其机械背结论不如把因果链讲清楚这样才像干过活的人。1.2 一张表理清锁的关键维度为了不让面试时被各种名词绕晕我习惯把Java锁相关概念整理成一张对比表。你不需要死记硬背每个词关键是理解每一行背后的为什么。对比维度synchronizedReentrantLockStampedLock实现层级JVM内置锁JUC类库锁JUC类库锁锁的获取自动加锁/解锁手动lock/unlock手动lock/unlock可重入性可重入可重入读写锁可重入乐观读不可公平性不提供公平模式默认非公平可指定公平非公平响应中断不支持lockInterruptibly支持不支持超时获取不支持tryLock(timeout)支持tryReadLock等支持条件队列每个对象一个可创建多个Condition不支持Condition锁粒度对象/方法/代码块对象粒度可结合Condition读读共享读写互斥这里有个容易混淆的点synchronized的非公平和ReentrantLock的默认非公平并不是一回事。synchronized在没有竞争时根本不会进入等待队列偏向锁和轻量级锁都在想办法让线程不放权只有膨胀到重量级锁后ObjectMonitor的_EntryList才会管理等待线程这时新来的线程也有可能直接抢到锁。而ReentrantLock的非公平更多指新线程在入队前可以先CAS插队。能讲出这层差异面试分会明显高于简单回答一句都是非公平。2. synchronized的底层真相2.1 synchronized到底锁的是什么很多候选人被问到synchronized锁的是什么只会说锁对象但下面三个使用场景必须分清楚修饰实例方法锁的是当前对象this修饰静态方法锁的是当前类的Class对象哪怕new了一堆实例也是同一把锁修饰代码块锁的是括号里指定的那个对象可以是this、某个成员变量也可以是一个独立的锁对象。public class LockDemo { // 锁的是当前实例 public synchronized void instanceMethod() { } // 锁的是LockDemo.class public static synchronized void staticMethod() { } // 锁的是指定对象 public void blockMethod() { synchronized (this) { } } }这个基础不牢固后面很多锁失效问题都解释不了。举例代码里写synchronized (new Object())每次进入代码块都会new一个对象每个线程拿到的锁都不一样等于没锁。反过来如果把一个字符串常量当锁对象由于字符串常量池会复用对象两个完全不相关的业务可能因为用了同一个字符串值而相互锁死。所以在工程里我习惯要求锁对象用private final Object lock new Object()这样对象引用稳定也方便代码审查。2.2 对象头与Mark Word锁状态从哪来JVM并不是为每把锁单独维护一张全局表锁状态直接存在Java对象头里。Java对象在内存中的布局包含对象头、实例数据和填充对齐。对象头里的Mark Word是关键64位虚拟机下它占64 bit设计得非常抠门既存对象哈希码、GC分代年龄也能存偏向线程ID、指向栈帧Lock Record的指针、指向Monitor对象的指针。Mark Word在不同锁状态下存的内容完全不同。锁状态Mark Word关键内容无锁对象哈希码、分代年龄、偏向标志位0偏向锁当前线程ID、epoch、分代年龄、偏向标志位1轻量级锁指向当前线程栈帧中Lock Record的指针重量级锁指向堆中ObjectMonitor对象的指针GC标记相关标记信息这里有个很多人没注意过的细节如果对象已经调用过hashCode()JVM往往无法再把它置为偏向锁因为hashCode占用了Mark Word里原本要存线程ID的位置。这就是为什么你明明开了偏向锁但某些对象一跑起来就直接进入轻量级锁状态。轻量级锁和重量级锁的本质区别在于轻量级锁用CAS加锁线程拿不到锁还在用户态自旋等待重量级锁会把线程挂起依赖操作系统的mutex原语涉及内核态切换。理解了这点锁开销从哪里来就很清楚了。2.3 锁升级路径无锁到重量级锁八股文爱背的升级路径是无锁-偏向锁-轻量级锁-重量级锁但实际执行中并不一定是每把锁都走完全程。偏向锁解决的是这个锁大概率只有一个线程来的场景Mark Word里直接存线程ID之后该线程再来访问不需要任何同步操作。一旦出现第二个线程竞争偏向锁会被撤销如果竞争不激烈JVM会尝试轻量级锁通过CAS把锁记录写入线程栈帧同时自旋等待一小段时间。如果自旋超过阈值仍然拿不到锁或者竞争线程数增多JVM就把锁膨胀为重量级锁没有拿到锁的线程被挂起。面试最好再补充两点。一是自旋是CPU忙等虽然不用挂起线程但会占用CPUJVM默认使用自适应自旋会根据上一次自旋成功与否动态调整自旋次数。二是JVM还有锁消除和锁粗化两个编译期优化。锁消除发生在逃逸分析之后如果JVM确定锁对象不会被其他线程访问会把加锁指令直接去掉锁粗化则是把相邻的多次加锁解锁合并成一次减少重复竞争。提这两个点可以让面试官觉得你不仅看了锁升级还关注了JIT层面的优化。说到重量级锁它依赖的ObjectMonitor内部有_owner、_WaitSet、_EntryList。synchronized的wait/notify就是基于Monitor实现的所以必须先持有锁再调用wait/notify否则会抛IllegalMonitorStateException。这个机制和AQS里的Condition有点像但synchronized每把锁只有一个等待队列而ReentrantLock可以通过多个Condition实现更细致的唤醒分组。2.4 为什么JDK 15以后偏向锁被默认禁用很多老八股还在说JDK 1.6默认开启偏向锁但JDK 15的JEP 374已经把偏向锁改为默认禁用并且计划后续移除。偏向锁原本的收益场景是大量线程中只有一个线程反复进入临界区并且锁的撤销发生在全局安全点成本可控。但随着现代应用普遍使用线程池多个线程交替竞争其实很难通过偏向锁占便宜而且偏向锁撤销要等到安全点这个隐藏成本在JVM里被越放越大。再加上hashCode占用Mark Word位导致偏向锁经常不可用整体收益已经配不上维护成本。面试时如果问到锁升级你可以在最后补一句偏向锁在JDK 15之后默认关闭了因为它的撤销机制和现代高并发场景不再匹配。这就显得你的知识不是停留在几年年前的博客而是跟进了新版JDK的演进。真要测试偏向锁需要配合-XX:UseBiasedLocking和-XX:BiasedLockingStartupDelay0这两个参数后面实验部分会说。3. AQS与ReentrantLock显式锁的进阶玩法3.1 AQS核心state变量与CLH队列ReentrantLock各种高级能力全部依赖AQSAbstractQueuedSynchronizer。AQS的设计可以概括成两块一个volatile int类型的state一个基于双向链表实现的CLH变体等待队列。state在不同同步器里含义不同在ReentrantLock里是重入次数0表示无锁每重入一次加1释放一次减1在Semaphore里是剩余许可数在CountDownLatch里是剩余计数器。所以AQS并不只是为锁服务它是一套通用的同步框架通过模板方法模式让具体的同步器实现tryAcquire、tryRelease等方法。CLH队列本质上是一个FIFO队列每个等待线程被包装成Node节点。获取锁的流程是先尝试通过CAS修改state成功就拿到锁失败则创建节点挂到队尾然后基于前驱节点状态做自旋或park。释放锁时修改state唤醒头节点之后的一个有效节点。入队用compareAndSetTail保证原子性节点状态用volatile保证可见性这套设计等于用无锁的方式实现了锁等待队列。我常用一个银行柜台的类比帮助理解state是当前柜台是否空闲1表示忙碌0表示空闲park就是让后面的人坐下睡觉unpark是柜台叫号CLH队列保证每个人知道现在轮到谁了。类比不完全精确但能把抽象概念落地。AQS还支持共享模式和独占模式ReentrantReadWriteLock里的读锁就是共享模式多个读线程可以同时占用state的一个读计数位。3.2 公平锁与非公平锁的实现差异ReentrantLock构造器能传入booleantrue是公平锁false是非公平锁。很多人知道非公平锁性能更好但不清楚好在哪里。看源码就明白非公平锁在进入AQS队列之前会先直接CAS一次state如果能抢到就直接拿锁抢不到才走acquire流程。公平锁则必须先调用hasQueuedPredecessors()判断队列里有没有排在自己前面的线程如果有就直接入队等待不抢。这段差异带来的效果是非公平锁减少了线程挂起和唤醒的开销因为新来的线程恰好能抢到锁的话就省掉了一次唤醒等待线程重新竞争的过程代价是队列里等了很久的老线程可能被连续饿着。公平锁保证先来后到但吞吐量往往更低。面试回答非公平锁为什么快时提炼成允许新线程插队减少线程切换但换来了不公平就够了。还需要注意一个细节即使是非公平锁一旦尝试失败进入队列之后也不会无限制地反复抢锁队列内的唤醒大体还是按顺序的。所谓非公平主要体现在入队前的那一次插队以及锁释放后新的等待线程和原头节点一起竞争。不要把非公平描述成完全乱抢那样反而显得你没读过源码。3.3 synchronized与ReentrantLock应该怎么选很多刚接触并发编程的同学喜欢一上来就用ReentrantLock理由是功能多。但工程里JVM内置锁经过多年优化很多场景下synchronized已经够用代码也更简洁。我个人的判断标准是有没有以下三个明确需求需要超时获取锁比如tryLock(timeout)防止线程无限阻塞需要响应中断比如线程被取消或服务关闭时能及时退出需要多个Condition队列把等待线程按不同条件分组唤醒。举两个实际例子。消息队列消费者在优雅关闭时如果线程正在lockInterruptibly()等待获取锁调用interrupt()可以让它立刻退出如果用synchronized线程只能等锁自然释放。阻塞队列实现里用notEmpty和notFull两个Condition分别唤醒生产者和消费者效率远高于synchronized的单一WaitSet加notifyAll。读多写少场景下synchronized和ReentrantLock都不是最优解应该考虑ReentrantReadWriteLock或StampedLock。读写锁把读锁和写锁分开读读不互斥读写互斥StampedLock更进一步支持乐观读读操作完全不加锁只在写操作前校验版本戳。但StampedLock不可重入不支持Condition复杂场景要谨慎。结论是锁的选择永远看临界区大小、读写比例、锁持有时间和冲突频率不能只看功能列表。3.4 LockSupport与中断响应AQS的线程阻塞和解阻塞依赖LockSupport的park/unpark。LockSupport和Object.wait/notify完全不同park/unpark不要求持有监视器也不需要先获得锁可以直接对某个线程操作而且unpark可以先于park调用相当于提前发了一个许可后续park时不会永久阻塞。这种设计比wait/notify灵活得多是AQS能实现复杂同步的基础。当线程在AQS队列中被park时如果其他线程调用interrupt()线程会收到中断信号。ReentrantLock的lock()默认不响应中断会继续排队而lockInterruptibly()拿到中断信号后会抛出InterruptedException立即退出等待。为什么要有这两种方法因为业务上有的锁必须等到比如资源初始化有的锁则希望用户取消后立刻停止避免占用线程资源。用ReentrantLock写代码时remember要在finally里unlock同时处理InterruptedException时要保留中断标志位否则上层调用方感知不到中断。4. 高并发场景下的锁设计实操4.1 经典计数器四种方案怎么选我讲并发编程时特别爱用计数器举例因为它简单但能清晰看到锁的取舍。假设一个支付系统需要统计今日成功订单数很多线程会同时执行count。先看三种常见写法// 方案1synchronized private long count; public synchronized void increment() { count; } // 方案2ReentrantLock private long count; private final Lock lock new ReentrantLock(); public void increment() { lock.lock(); try { count; } finally { lock.unlock(); } } // 方案3AtomicLong private AtomicLong count new AtomicLong(); public void increment() { count.incrementAndGet(); }方案1和方案2思路一致都是互斥修改count方案3利用CAS在竞争不激烈时性能很好。但高并发下AtomicLong的CAS会频繁失败导致大量重试甚至成为瓶颈。这时候可以用LongAdder它内部维护一个base和多个Cell单线程时直接在base累加竞争激烈时把计数分散到不同Cell上sum()时才汇总。LongAdder适合高频更新、低频读取的统计场景但sum()不是强一致的快照如果业务要精确读取当前值还是用锁或AtomicLong更安全。这个例子告诉我们并发方案不存在万金油先明确读多还是写多再选型。4.2 双重检查锁定的正确写法单例模式里的双重检查锁定是面试必考题也是实际代码里最容易写错的锁场景。很多同学会写public class Singleton { private static Singleton instance; public static Singleton getInstance() { if (instance null) { synchronized (Singleton.class) { if (instance null) { instance new Singleton(); } } } return instance; } }这段代码最大的问题在于instance没有volatile修饰。new Singleton()在JVM里不是原子操作可以拆成三步分配内存、调用构造器初始化、把引用赋值给instance。如果不加volatile第二步和第三步可能被指令重排另一个线程在第一个if里看到instance不为null直接返回了一个还没初始化完的半成品对象。volatile能禁止这种重排序保证拿到的是构造完整的对象。当然现在更推荐用静态内部类或枚举实现单例它们天然线程安全。但双重检查锁定仍然值得理解因为它能引出一连串底层问题为什么需要volatile、JMM里指令重排的规则、JDK 5之前JMM模型不完善导致DCL失效的历史。能把这条时间线讲清楚的候选人面试官基本不会怀疑他的并发基础。4.3 减少锁粒度分段锁与读写锁synchronized和ReentrantLock默认锁的是整段代码或整个对象如果临界区很大并发度会很低。一种经典优化思路是减小锁粒度。最成功的例子是JDK 7的ConcurrentHashMap内部用Segment数组每个Segment是一把独立锁put时通过哈希定位到某个Segment只锁那一个分段。JDK 8之后虽然放弃了Segment改用synchronized加CAS的细粒度设计但分段锁思想本身仍然值得学。我在业务里用过类似思路处理分片任务。比如一个奖品池有多个库存分区每个分区对应一把锁请求按用户ID哈希到某个分区只在分区内加锁。这样不同分区的请求可以并行理论上最多可以同时跑出分区数倍的吞吐量。但要注意分段数并不是越多越好段数增加会带来更多锁对象和内存开销Hash散列不均衡时还可能产生热点段。读写锁则是另一种减少冲突的思路读多写少时多个读线程不互斥只有写线程需要排他整体吞吐提升非常明显。4.4 避免死锁锁顺序与超时兜底死锁是锁机制最经典的副作用。线程A持有锁1等待锁2线程B持有锁2等待锁1结果互相等。死锁的四个必要条件是互斥、持有并等待、不可抢占、循环等待工程里做预防就是要打破其中一条。最常用的是锁顺序法所有需要多把锁的业务统一按同一个全局顺序加锁比如总是先锁id小的再锁id大的。另一个方案是用ReentrantLock的tryLock带超时超时后主动释放已持有的锁并重试Lock lock1 new ReentrantLock(); Lock lock2 new ReentrantLock(); while (true) { if (lock1.tryLock(100, TimeUnit.MILLISECONDS)) { try { if (lock2.tryLock(100, TimeUnit.MILLISECONDS)) { try { // 业务逻辑 break; } finally { lock2.unlock(); } } } finally { lock1.unlock(); } } Thread.sleep(10); }但要注意这种tryLock重试写法可能引入活锁两个线程不停互相退让。所以工程里优先用固定锁顺序tryLock只作为兜底。排查死锁最直接的办法是jstack抓线程栈JVM检测到死锁会直接打印Found one java-level deadlock告诉你线程A在等哪把锁、那锁又被谁持有。我实际排过的死锁绝大多数都是因为代码里多处加锁顺序不一致或者在一个临界区里调用了另一个加锁方法。写多把锁的代码前先在注释里把锁顺序写清楚能省很多线上事故。5. 锁问题排查与面试追问实录5.1 从线程Dump快速定位锁竞争搞并发编程不会看线程栈等于摸黑走路。线上接口耗时暴增怀疑锁竞争时先执行jstack抓dump。拿到之后重点看两方面线程状态是不是BLOCKED以及monitor信息里有没有大量线程在waiting to lock同一个地址。正常情况锁竞争会造成部分线程BLOCKED但如果几十个线程全部堵在同一把锁上说明临界区太长或者锁粒度太粗要赶紧优化。用Arthas会更方便thread -n 3能列出CPU占用前三的线程thread --state BLOCKED直接列出阻塞线程。我曾经定位过一个批量导出慢接口用Arthas发现大量线程都在等一个数据库连接池的锁最后调整池参数和连接获取逻辑接口耗时从3秒降到200毫秒。另外要区分BLOCKED和WAITINGBLOCKED通常是等监视器锁WAITING可能是Object.wait、Condition.await或LockSupport.park。排查锁竞争时BLOCKED线程数量是最直观的指标。5.2 锁失效的那些坑我以为加了锁其实没锁住是并发Bug的高发区归纳起来有几种典型。第一类是锁对象选错比如synchronized(new Object())每次都是新锁等于没加锁用String常量当锁可能误伤其他用同字符串的业务用Integer当锁更危险因为Integer在[-128,127]之间有缓存锁数字1和锁数字1可能锁到同一个对象造成无关业务串行。第二类是锁对象被修改。如果代码里用非final的lock对象某处运行时执行lock new Object()老锁和新锁就不是同一把并发保护立即失效。所以锁对象必须声明为final。第三类是锁方法类型混用静态synchronized方法锁Class实例synchronized方法锁this两者互不影响如果你期望它们互斥就错了。第四类是锁和事务边界不匹配Spring事务代理在方法返回后才提交但synchronized锁在方法内部就释放了两个事务可能先后读到同一个旧值。解决思路是把锁放到事务方法外面包一层或者在事务内用数据库锁/分布式锁统一控制。5.3 常见八股追问与回答要点我整理了面试里最常出现的几个追问方向每个方向都值得你往深想一层。追问方向回答要点volatile和synchronized区别volatile保证可见性和有序性不保证原子性synchronized三者都保证但开销更大锁升级过程中的状态存哪对象头Mark Word不同状态下存线程ID、Lock Record指针或Monitor指针ReentrantLock默认公平吗默认非公平构造器传true开公平非公平吞吐高但有饥饿风险AQS怎么实现可重入state加1减1exclusiveOwnerThread记录持有线程wait/notify为什么必须在synchronized里基于Monitor实现必须先持有监视器锁避免条件变量状态竞争park/unpark与wait/notify区别park/unpark不要求持有锁unpark可先于parkwait必须先持有锁且只有一个等待集分布式环境下怎么办JVM锁只管单进程多实例要引入分布式锁如Redis SetNX/Redisson、数据库唯一约束等注意最后一条其实是八股文的边界。很多系统设计题会从本地锁一路追到分布式锁如果你能说出JVM锁的作用域只限于当前JVM进程跨进程需要额外方案面试官会看到你对工具边界的理解。分布式锁本身要关注加锁原子性、自动过期、释放时校验owner避免误删别人的锁。5.4 学习工具JOL观察锁升级理论讲再多不如实际看一眼锁状态变化。OpenJDK的JOLJava Object Layout能打印对象内存布局和Mark Word。先引入依赖dependency groupIdorg.openjdk.jol/groupId artifactIdjol-core/artifactId version0.17/version /dependency然后写一个小测试import org.openjdk.jol.info.ClassLayout; public class LockLayoutTest { static class TestObj {} public static void main(String[] args) throws Exception { TestObj obj new TestObj(); System.out.println(ClassLayout.parseInstance(obj).toPrintable()); synchronized (obj) { System.out.println(ClassLayout.parseInstance(obj).toPrintable()); } } }运行前建议加JVM参数-XX:BiasedLockingStartupDelay0否则偏向锁有4秒延迟。你会发现第一次打印是无锁状态第二次持有锁时Mark Word变成了拥有线程ID的偏向锁或者直接变成轻量级锁取决于JDK版本和是否调用过hashCode。如果你用的JDK 15以上默认偏向锁关闭看到的就是轻量级锁或重量级锁。这个实验能帮你把锁升级从抽象概念变成亲眼看到的十六进制理解深度完全不一样。我在实际面试和排查里最大的体会是Java锁机制与其说是八股不如说是一张并发编程的地图。你背下synchronized锁升级、AQS队列、公平锁非公平锁只是拿到了地图上的标记点真正值钱的是把这些标记点串成一条线知道什么场景走哪条路走到岔路口会遇到什么坑。面试时与其机械背诵锁升级有四个阶段不如从对象头说起讲清楚为什么要升级、升级的代价是什么、为什么偏向锁会被默认关闭回答自然就立体了。最后分享一个小技巧学锁机制强烈建议自己动手做一次JOL实验再找一台测试机故意写一个死锁用jstack把线程栈里locked和waiting to lock看熟。这两个动作做完再回头看任何并发面试题你会觉得题目突然变简单了。锁是并发编程的骨架理解了锁很多云里雾里的并发概念都会慢慢清晰。
返回列表