Java锁机制从底层到面试:synchronized、AQS与锁升级全解析
开头就先说结论Java锁机制是并发编程里最绕、也最容易被“背串”的一块。面试问synchronized、问ReentrantLock、问AQS问题形式五花八门但底层考的无非是三件事——锁的底层实现是什么、锁之间怎么取舍、锁用不好会出什么问题。这篇文章不打算把八股文重新抄一遍而是把锁机制从底层原理到面试回答思路完整串一遍适合正在准备Java面试的人也适合那些synchronized用了好几年、但一直没搞清楚“偏向锁到底偏向谁”的同学。文章里我会把对象头的布局、锁升级的过程、AQS的设计逻辑、死锁排查这些实操细节都摊开讲尽量让每个概念都能在面试时讲出“为什么”。1. 锁机制全景一次看清Java并发锁的完整脉络1.1 两个维度的分类悲观与乐观、内置与显式Java里的锁机制看起来一堆名词其实梳理下来就两张表。第一张是按“是否认为会发生冲突”分悲观锁和乐观锁。悲观锁默认并发一定出问题所以先锁住再操作代表性的是synchronized和JUC的Lock乐观锁默认冲突是小概率事件先直接操作失败了再补偿代表性的是CASCompare And Swap各种Atomic类、LongAdder底层都是它。第二张是按“锁的控制方式”分内置锁和显式锁。内置锁就是synchronized它由JVM托管进入方法或代码块时自动加锁异常退出也会自动释放这是在Java诞生时就有的机制。显式锁是JDK 5开始引入的java.util.concurrent.locks包以Lock接口为核心加锁和释放都需要程序员在代码里显式控制通常配合try-finally确保释放。记住这两张表后面所有细节都有地方挂靠。面试官问“Java有哪些锁”你先把这两条线铺开再往细节里钻这个回答框架本身就已经比大多数人强了。1.2 锁机制解决的核心问题原子性、可见性与有序性很多人背锁的API背得滚瓜烂熟但被问句“锁到底是干嘛的”就卡住了。其实Java内存模型JMM里有三个核心特性锁的存在就是为了一次性解决这三件事。第一是原子性。多线程并发修改同一个变量时“读取-修改-写入”不是一个原子操作可能被拆成三步并穿插执行。锁通过互斥保证同一时刻只有一个线程能进入临界区一个线程的“读改写”完整执行完其他线程才能碰。第二是可见性。线程对共享变量的修改不是立刻写回主内存的如果没有同步机制另一个线程可能一直读到自己工作内存里的旧值。加锁的底层有一条内存屏障规则释放锁时强制把修改刷新到主内存获取锁时强制让工作内存失效、重新从主内存读。第三是有序性。编译器和CPU为了性能会乱序执行指令单线程没影响但多线程下顺序就乱了。锁通过acquire和release语义限制了乱序范围保证了临界区内的关键步骤不会跑到锁外面去。用一个生活化类比锁就像会议室的预约。你要改一份共享文件得先预订会议室进入后把门锁上改完出门门口贴个通知“这里的文件已经是最新版”。其他人再进来只能看到最新版。这个类比能帮你把三个特性一次性讲清楚面试时很有用。2. synchronized从字节码到锁升级的完整链路2.1 三种使用形态与底层字节码synchronized在代码层面有三种用法修饰实例方法、修饰静态方法、修饰代码块。很多人知道用法但不知道它在字节码层面的差别。修饰代码块时字节码里会出现monitorenter和monitorexit两条指令分别在加锁位置和退出位置。注意编译器通常会在正常退出和异常退出路径上各放一条monitorexit来保证锁一定被释放所以你在字节码里可能会看到两条monitorexit。修饰方法时则看不到这两条指令而是由方法的访问标志ACC_SYNCHRONIZED来表示JVM根据这个标志来隐式执行monitor加锁逻辑本质和monitorenter是一样的。面试时能答出“monitorenter/monitorexit”和“ACC_SYNCHRONIZED”说明你至少看过字节码层面这已经比背概念的回答高出半档。如果再补一句“synchronized的锁对象是对象头里的Mark Word”面试官基本就会往锁升级的方向追问了。2.2 对象头、Mark Word与锁升级过程这是synchronized相关八股里最核心的一段。HotSpot虚拟机里每个对象的对象头都有一块叫Mark Word的区域在64位JVM下占64位8字节里面存的东西会随着锁状态变化而改变。JDK 1.6之前synchronized只有“重量级”一条路直接依赖操作系统mutex线程一竞争就挂起唤醒代价很高。1.6之后引入了偏向锁、轻量级锁锁才变成了一条升级之路。锁升级的完整路径是无锁 - 偏向锁 - 轻量级锁 - 重量级锁。偏向锁的逻辑是如果只有一个线程反复进入临界区那就没必要真正加锁。第一次获取时在Mark Word里记录这个线程的ID之后这个线程再来就什么都不用做直接进入。只有当其他线程来竞争偏向锁才会被撤销升级为轻量级锁。轻量级锁的逻辑是线程不阻塞而是在自己的栈帧里创建锁记录空间用CAS把Mark Word复制并替换为指向锁记录的指针。如果CAS失败说明有真竞争就开始短暂自旋提升不到就膨胀成重量级锁。重量级锁才走操作系统底层的monitor机制没有拿到锁的线程直接阻塞涉及用户态与内核态切换开销最大。关于偏向锁有一个JVM参数细节HotSpot默认有偏向锁启动延迟虚拟机启动后大约4秒才偏向锁才生效-XX:BiasedLockingStartupDelay0可以关掉这个延迟用于测试。JDK 15之后JVM默认禁用了偏向锁这点面试时如果聊到版本演进会是加分项。2.3 用JOL工具实际观察锁升级原理讲再多不如亲眼看看Mark Word怎么变。推荐用JOLJava Object Layout这个工具只用几行代码就能打印对象头。先用maven引依赖org.openjdk.jol:jol-core然后写一个最简单的类用ClassLayout.parseInstance(obj).toPrintable()打印布局。分别跑三种情况正常加锁、加锁后用-XX:UseBiasedLocking -XX:BiasedLockingStartupDelay0开启偏向并观察偏向后的内容、用-XX:-UseBiasedLocking禁用偏向锁观察轻量级锁状态。我在本地跑出的规律是偏向锁状态下Mark Word末尾的bit pattern是101线程ID会被写进去轻量级锁状态下是00Mark Word变成指向栈中锁记录的指针重量级锁状态下是10Mark Word指向monitor对象。亲眼看过这些变化之后你对锁升级的记忆就再也不会混淆了。这是网上那些纯文字八股给不了你的经验。3. JUC显式锁与AQS核心设计3.1 ReentrantLock和synchronized怎么选显式锁里最常用的是ReentrantLock很多人知道它比synchronized“功能更多”但具体多在哪回答得往往不够全。我整理一张对比表面试时就按这张表往外讲对比维度synchronizedReentrantLock底层实现JVM层面字节码指令JDK层面基于AQS锁释放自动异常也释放必须显式unlock配合finally可中断不支持支持lockInterruptibly超时获取不支持支持tryLock(timeout)公平性非公平支持公平/非公平构造参数控制条件变量只有wait/notify支持多个Condition性能JDK 1.6后与Lock差距很小高竞争下表现稳定选择建议也很简单能用synchronized就用synchronized它代码简洁、不易出错功能也够用只有在需要可中断、超时、公平锁、多条件队列这些特性时才换ReentrantLock。面试如果被问“为什么有了synchronized还要Lock”答案不是“性能更好”而是“synchronized在Java语言层面提供的并发原语不完整Lock补齐了中断、超时、条件等能力”。3.2 AQS的state与CLH队列模型ReentrantLock只是外壳真正的设计核心是AbstractQueuedSynchronizer也就是AQS。理解AQS只需要抓住两个东西一个volatile修饰的int类型state一个双向链表结构的等待队列。state的含义由具体子类定义。ReentrantLock里state表示“当前线程重入了几次”没有线程持锁时是0第一次加锁变1同一个线程再重入就加1释放一次减1减到0才真正释放锁。Semaphore里state表示剩余许可数CountDownLatch里表示还需要倒数的计数。AQS用state这一个变量配合模板方法设计就撑起了JUC半壁江山。再看队列模型。当某个线程拿不到锁时AQS会把它包装成一个Node节点塞入一个FIFO双向队列并调用LockSupport.park挂起。当前线程释放锁时会从队列头部挑一个等待者调用LockSupport.unpark唤醒它去抢锁。这个设计等于把“谁接下来获得锁”交给了队列排队逻辑公平锁就是严格按队列顺序来非公平锁则是新来的线程先try一把失败才入队。AQS提供的模板方法中子类要实现的其实就那几个tryAcquire、tryRelease、isHeldExclusively。其余的入队、出队、挂起、唤醒、中断处理全部由模板方法完成。如果你要看源码打开AbstractQueuedSynchronizer类重点看acquire和acquireQueued这两个方法整条链路就通了。3.3 读写锁与StampedLock的适用场景读多写少的场景里用统一的互斥锁会浪费并发能力。ReentrantReadWriteLock把锁拆成读锁和写锁多个线程可以同时持有读锁但写锁是独占的写锁持有期间读锁和其他写锁都不能进入。它的典型应用是缓存场景比如一份配置数据读操作频繁、写操作极少用读写锁可以把读吞吐量提上来。读写锁还有一个值得记的细节是锁降级。线程持有写锁时可以再获取读锁然后释放写锁锁等级从“写的独占”降为“读的共享”。这个操作的意义在于保证读到的数据一定是自己刚才写的那份不会被其他线程穿插修改。锁升级持有读锁再拿写锁则是不被允许的很容易死锁。StampedLock是JDK 8加入的进阶工具它引入了乐观读。乐观读不真正加锁而是先拿到一个stamp凭证读完之后校验这个stamp是否有效有效就说明期间没人写读操作合法无效就升级为真正的读锁重读一次。它比ReentrantReadWriteLock更激进适合读多写少且对一致性能短暂容忍的场景。用它的代价是不支持重入也无法和Condition配合使用对API熟练度要求高。面试被问到锁的中间态演进时把ReentrantReadWriteLock和StampedLock讲清楚基本就是标准答案的顶配了。4. 锁的进阶话题自旋、死锁与锁粒度4.1 自旋锁与适应性自旋的实现逻辑锁竞争时等待线程有两种策略阻塞和自旋。阻塞会切换线程开销很大但等待线程不占CPU自旋则让线程反复尝试获取锁不释放CPU适合临界区极短、锁只会被占用一小会儿的场景。synchronized在轻量级锁阶段就会先自旋默认自旋次数是10次可以用-XX:PreBlockSpin指定。JDK 1.6之后引入适应性自旋JVM会根据上一次自旋的成功率动态调整次数不再是一个固定值。这是很重要的一个演进说明JVM在持续优化锁的等待策略。Java层面你也可以实现一个简单的自旋锁最常见的就是CAS循环用AtomicReference持有当前锁的持有者compareAndSet(null, thread)成功就拿到锁失败就空转。但这玩意有个性能隐患——如果临界区时间稍长所有等待线程都在空转消耗CPU实际效果可能比synchronized差得多。真实开发里手写自旋锁的场景非常少绝大多数情况下直接用JUC的Lock就够了但面试问“自旋锁的优缺点”时你要能说出“省去了线程切换但会空耗CPU适用于临界区极短的场景”这句关键结论。4.2 死锁的经典场景与jstack排查实录死锁是锁相关八股里最常考实操的知识点。经典的死锁长这样线程A持有锁1等待锁2线程B持有锁2等待锁1。两边都不放手程序就永远卡住了。我经历过一次线上死锁排查这里把完整过程记一下。第一步用jps找到目标进程PID第二步执行jstack PID把线程快照导出来第三步在输出里搜Deadlock关键字会看到一段专门的死锁信息标明“Thread-0”和“Thread-1”分别持有哪把锁、在哪个代码行等待哪把锁。我那次的问题就是两个方法分别以不同顺序对同一对资源加锁一个方法先锁A再锁B另一个先锁B再锁A并发量一上来就撞上了。修复死锁的常见思路有几个一是统一加锁顺序所有人按同一个顺序获取锁从根上消除循环等待二是把多把锁合成一把更大的锁用牺牲并发换来安全三是用tryLock加超时抢不到就放弃并释放已持有的锁避免无限等待。面试时能把jstack排查步骤讲出来再带上“线程转储文件里Deadlock部分会有明确提示”这个细节这道题基本就过了。顺带记住死锁的四个必要条件——互斥、持有并等待、不可剥夺、循环等待破坏任何一个就能破除死锁。4.3 锁粒度设计的实战经验锁粒度是“会写锁”和“写好锁”的分水岭。最粗的方式是给整个方法加锁比如getAndIncrement直接把方法标成synchronized简单但把无关的业务操作也锁进去了吞吐量直线下降。粒度适中的做法是只锁共享变量那几行代码临界区越小并发度越高。举个例子一个统计访问量的计数器如果直接给update方法加锁这个类里其他不相关的读操作也会被挡住。改成把计数逻辑抽出来只对这个变量加锁或者直接用AtomicLong效果就完全不同。再比如读多写少的Map不要整体加锁要么用ConcurrentHashMap分段处理要么用读写锁把读并行度拉满。还有一个常被忽略的设计问题是锁对象的绑定。锁对象必须和被你保护的共享资源一一对应如果两个不同的资源共用同一把锁或者一个公共变量被改成了锁对象都会引发连锁问题。我有一个很深的教训早期项目里用字符串常量当锁对象结果两个线程拿着内容相同的字符串命中了同一个锁两个本来互不相关的代码块变成了互斥接口性能直接崩掉。后来我统一改成private static final Object LOCK new Object()就再没出过这种问题。这就是典型的锁粒度与锁对象设计失误比锁本身的性能优化更值得注意。5. 面试高频追问与避坑指南5.1 高频问题的参考回答框架锁相关的面试题翻来覆去就是那几道但每道都有相对标准的回答路径。我把自己的回答框架列在这里面试时照着这个节奏走基本不会乱。被问synchronized和ReentrantLock的区别时按四个维度答释放方式、功能丰富度、底层实现、公平性。先讲synchronized由JVM自动加解锁Lock需要手动unlock再讲Lock支持中断、超时、公平锁、多个Condition最后落到“底层是AQS”和“性能差距已经很小”上。这个回答既有广度也有深度。被问锁升级过程时直接按“无锁到偏向锁到轻量级锁再到重量级锁”这条线走每级都要说清楚两个点触发条件和Mark Word变化。偏向锁是单线程反复进入Mark Word记录线程ID轻量级锁是出现竞争但竞争不激烈CAS复制Mark Word到栈帧并用自旋等待重量级锁是自旋失败或等待线程太多走OS monitor阻塞。最后补一句JDK 15默认禁用偏向锁面试官一般会认为你对版本演进有跟踪。被问AQS原理时抓住state和等待队列两个关键词。先说volatile int state具体含义由子类定义再说拿不到锁的线程入队、LockSupport挂起释放时从队头唤醒。最后补一句“ReentrantLock的公平性就是取决于入队前是否允许插队”整个答案闭环了。5.2 实战中常见的四个误区锁相关的坑我踩过不少也帮别人排过不少下面这四个是出现频率最高的。第一个误区是把可变的公共对象当锁对象。有人为了省事直接把某个业务对象拿来当锁结果对象字段一变锁身份也跟着变了并发控制直接失效。锁对象必须是一个引用不变的、独立的专用对象。第二个误区是把String或Integer当锁对象。字符串常量池导致内容相同的字符串共享同一个引用Integer在-128到127之间有缓存这些都会让两个毫不相干的代码块意外互斥。上面说的线上卡顿事故就是这个原因。第三个误区是以为volatile能取代锁。volatile保证可见性和有序性但不保证原子性。很多人拿volatile修饰一个int然后做i并发一高立刻丢更新。i是“读-改-写”三步volatile管不住这三步之间的穿插。第四个误区是锁的加解锁没配对。用Lock时忘记在finally里unlock一旦异常抛出锁可能永远不会被释放后续所有线程全部堵死。用Lock就记住一个铁律lock之后下一行写tryfinally里unlock中间绝不return。5.3 一条低成本的锁机制学习路径如果目标是面试我的建议是别急着啃太多源码先把一条主线走通先看synchronized的锁升级配合JOL工具观察对象头变化让原理落在眼睛能看见的地方再看ReentrantLock的源码重点看lock和unlock这两个方法它们最终都会走进AQS的acquire与release接着单独读AbstractQueuedSynchronizer把state、enq、park/unpark这些关键点串起来AQS理解之后锁机制就算真正通了。读完源码之后建议动手写两个demo巩固。一个是多线程计数器分别用synchronized、AtomicLong、LongAdder实现观察高并发下的性能差异另一个是手动制造死锁用jstack抓一次线程转储。这两个实验我建议每个Java开发者都亲手做一遍它们花不了几小时但收益比翻十篇博客都大。最后说点个人体会锁机制这门课真正让人产生顿悟的瞬间往往不是背下某个概念而是亲眼看到了问题发生的全过程。我有一次在本地压测时发现接口偶发性超时一开始怀疑是数据库慢查询排查到最后才发现是锁的竞争问题——一个高频访问路径和一个低频重操作共用了同一把锁低频操作把锁占住了高频请求全在排队。这件事给我的触动很大从那之后我写并发代码都会先问三个问题锁对象是什么、临界区在哪、是否真的需要锁。这三个问题也送给正在看这篇文章的你比背下再多“八股答案”都实在。