Java并发同步机制精讲:从synchronized到AQS,面试高频考点全解析
刚准备面试那会儿我最头疼的就是JUC这块。看着源码里一堆Lock、Condition、AQS感觉每个字都认识连起来就不知道在说什么。后来啃了一段时间又拿压测工具实测了几轮才慢慢把“同步机制”这条线理顺。这篇博文就把我整理过的JUC同步机制笔记拿出来分享以面试八股问答的形式展开该讲原理的讲原理该给结论的给结论也会标注哪些是面试官真正会深挖的点。这篇内容的定位很明确给准备Java后端面试、或者工作中想系统梳理并发知识的同学做索引和查漏补缺。它不替代源码阅读但能帮你在短时间内建立一个完整的知识框架知道每个机制解决什么问题、底层怎么实现的、坑在哪里。读完你能直接回答“synchronized和Lock区别”“volatile能保证原子性吗”“AQS原理是什么”这类高频问题。1. JUC同步机制全景梳理1.1 从线程安全说起JUC是java.util.concurrent的缩写这套包是Java并发编程的基石。而“同步机制”解决的核心问题就一个多线程同时访问共享资源时如何保证数据不混乱。为什么会混乱因为线程操作共享变量并不是一步完成的。比如i这个操作看起来是一行代码底层却要经历“读取i的值 → 计算i1 → 写回i”三个步骤。两个线程同时执行这三个步骤就可能出现“都读到100都加1都写回101”结果丢了一次更新。这就是经典的竞态条件。要解决这个问题思路无非两条一是“互斥”即同一时刻只允许一个线程操作共享资源其他线程等着二是“可见性”即一个线程修改了变量其他线程要能立刻看到最新值。JUC里的同步机制本质上就是围绕这两个维度展开的。维度解决手段代表技术互斥锁机制synchronized、ReentrantLock、StampedLock可见性内存屏障volatile、final原子性CAS操作AtomicInteger、LongAdder线程协作等待唤醒wait/notify、Condition、LockSupport1.2 面试官眼中的同步机制是什么面试官问JUC同步机制通常不会一上来就让你背API。他更关心的是你知不知道同步机制分几层每层的适用场景是什么底层原理是否理解我总结了一个“分层理解法”用这个框架去回答问题基本不会乱第一层JVM提供的原生同步——synchronized关键字和volatile关键字第二层JDK提供的锁工具——Lock接口及其实现类如ReentrantLock、ReadWriteLock第三层AQSAbstractQueuedSynchronizer抽象队列同步器——这是JUC锁机制的底层骨架第四层基于AQS构建的并发工具——CountDownLatch、Semaphore、CyclicBarrier、ReentrantLock等都是AQS的不同实现。这个分层不是死的但要清楚越往下越基础越往上越贴近业务场景。2. synchronized底层原理不只是“加锁”那么简单2.1 对象头里的秘密synchronized在JDK早期确实是“重锁”但JDK 6之后做了大量优化引入了偏向锁、轻量级锁、重量级锁的升级路径。面试常问的就是这个升级过程。先说底层的存储载体。Java对象在内存中分为三部分对象头、实例数据、对齐填充。对象头里有一块Mark Word锁状态就记录在这里。Mark Word会根据锁标志位的变化而复用存储空间存放不同的数据比如偏向锁线程ID、轻量级锁指针、重量级锁互斥量指针。锁一共四种状态按竞争激烈程度排列无锁对象没有被任何线程锁定偏向锁只有一个线程访问同步块时在Mark Word里记录线程ID下次该线程再来不需要CAS操作直接进入轻量级锁多个线程交替访问同步块但没有真正竞争时通过CAS自旋获取锁重量级锁多个线程同时竞争CAS自旋失败线程被挂起进入操作系统内核态的互斥量等待队列。2.2 锁升级的完整链路很多面试题问“synchronized的锁升级过程”其实核心就是几个触发条件。我画过一张流程图但没法贴出来用文字描述一遍假设线程A第一次进入同步代码块。JVM检测到对象无锁于是通过CAS把Mark Word中的偏向锁标志位设置为1同时记录线程A的ID。这就是偏向锁的获取。之后线程A再次进入只需要比对线程ID是否一致一致就直接放行开销极小。这时候线程B也来抢这个锁。偏向锁会先尝试撤销撤销需要等待全局安全点Safe Point。如果线程A还在执行同步块说明存在真实竞争偏向锁升级为轻量级锁。线程A和线程B各自在自己的栈帧中创建Lock Record通过CAS竞争Mark Word中的指针。失败的一方会进入自旋即空转CPU不断重试。如果自旋超过阈值JDK 6之后是自适应自旋由JVM根据历史记录动态调整或者等待的线程数超过两个轻量级锁就膨胀为重量级锁。重量级锁依赖于操作系统的互斥量Mutex未获取到锁的线程会被阻塞挂起涉及用户态到内核态的切换开销最大。2.3 被问烂的三个追问点面试官在synchronized这里通常会追加追问我踩过坑提前说明第一个追问“偏向锁一定比轻量级锁好吗”答案是否定的。偏向锁的撤销过程比较麻烦如果锁对象被多个线程频繁交替访问偏向锁的撤销反而消耗性能。所以JVM提供了-XX:UseBiasedLocking参数开关JDK 15之后偏向锁被默认禁用原因就是维护成本太高收益不明显。第二个追问“为什么轻量级锁要自旋”因为线程挂起和恢复涉及内核态切换非常耗时。如果锁持有时间很短自旋等待比切换线程更划算。自适应自旋则是对“自旋多久”做了动态调整如果上次自旋成功这次就多转一会儿如果上次自旋失败这次就少转甚至不转。第三个追问“重入是怎么实现的”synchronized是可重入的同一个线程可以重复获取同一把锁。底层是每个锁对象关联一个递归计数器每次重入加1每次退出减1减到0才真正释放锁。这也是为什么synchronized不需要像Lock那样手动解锁JVM会自动记录重入次数。注意synchronized的锁信息存在对象头Mark Word里而Lock的锁状态存在AQS的State变量里这是两者底层实现上的一个根本区别。3. volatile的内存语义轻量级同步的利器3.1 可见性是怎么保证的volatile是Java中最轻量的同步机制很多人把它理解成“线程安全的万能药”这是大错特错的。它只保证可见性和有序性不保证原子性。先说可见性。Java内存模型JMM规定每个线程有自己的工作内存变量读取和修改都在工作内存中完成工作内存和主内存之间通过“load”和“store”操作同步。如果没有同步机制线程A改了变量线程B可能很久都看不到。volatile之所以能解决可见性问题是因为它在写操作时JVM会插入一个内存屏障强制把工作内存中的最新值刷新到主内存在读操作时插入屏障使工作内存中的值失效强制从主内存重新读取。同时volatile还能禁止指令重排序。这里有个“生活化类比”想象一个写在黑板上的倒计时数字。普通变量是写在自己的便签纸上只有自己看得到volatile变量是写在共享黑板上每次修改必须擦掉重写任何人看黑板都能看到最新值。但麻烦在于如果两个人同时伸手去黑板上改写数字数字还是会写错——这就是原子性问题。3.2 为什么volatile不能保证原子性volatile不能解决复合操作的原子性。经典的例子public class Counter { private volatile int count 0; public void increment() { count; // 不是原子操作 } }count在字节码层面是“读取 → 计算 → 写入”三步。即使count是volatile的两个线程同时执行“读取”都读到0各自计算得到1然后先后写入最终结果还是1而不是2。volatile只保证了最后写入的值对其他线程立即可见但无法保证“读-改-写”这个整体过程的原子性。那volatile适合什么场景呢两种一是状态标志位比如用于停止线程的running标志二是“发布”不可变对象比如一个对象的所有字段都是final且通过构造函数安全发布的对该对象的引用可以用volatile修饰。3.3 happens-before原则聊volatile绕不开happens-before规则。JMM定义了一系列规则只要满足这些规则两个操作之间的数据就是可见的程序顺序规则一个线程中的每个操作都happens-before于该线程中后续的任意操作volatile变量规则对一个volatile变量的写操作happens-before于后续对同一volatile变量的读操作传递性A happens-before BB happens-before C则A happens-before C监视器锁规则一个锁的解锁操作happens-before于后续对该锁的加锁操作。用happens-before来解释volatile的可见性就很清晰线程A写volatile变量线程B读同一个volatile变量由于“写先行发生于读”线程A在写之前的其他操作结果对线程B也是可见的。面试时如果能把这个逻辑讲清楚比单纯背“volatile保证可见性”要有说服力得多。4. CAS与原子类无锁编程的核心4.1 CAS的三个操作数CASCompare And Swap比较并交换是实现无锁并发的基础。它的核心思想在更新变量之前先比较一下当前值是否与预期值一致一致才更新不一致就重试。CAS有三个操作数内存地址V、预期原值A、要写入的新值B。只有当V的值等于A时才把V修改为B否则不修改。整个过程是CPU原语级别的操作不可中断所以是线程安全的。在Java里CAS是通过Unsafe类提供的本地方法实现的。Unsafe类可以看成一个后门提供了操作内存、CAS、线程挂起等底层能力。JUC包里的AtomicInteger等原子类底层全是CAS。4.2 原子类家族的适用场景JUC提供了丰富的原子类面试至少要能分清楚三类基本类型原子类AtomicInteger、AtomicLong、AtomicBoolean适合计数器、序列生成器等场景数组原子类AtomicIntegerArray、AtomicLongArray适合多个线程操作数组不同元素引用类型原子类AtomicReference、AtomicStampedReference和AtomicMarkableReference后者用于解决ABA问题。还有个容易忽略的LongAdder。在高并发场景下AtomicLong的CAS竞争会非常激烈失败概率高导致大量自旋浪费CPU。LongAdder把单一热点拆分成多个热点每个线程在自己的Cell里累加最后汇总读多写少的场景性能优势明显。4.3 ABA问题与解决方案CAS有一个著名的ABA问题线程1读到变量值为A准备修改线程2把A改成B又改回A线程1CAS时发现值仍然是A于是操作成功但实际上变量已经被动过了。解决ABA问题有两个工具AtomicStampedReference给变量加一个版本号作为“时间戳”每次修改版本号1CAS时同时比较值和版本号AtomicMarkableReference比Stamped更轻量只标记是否被改动过适合“节点是否有效”这类二元状态场景。举例来说用链表实现一个无锁栈时如果只比较节点值一个节点被弹出后又被重新压入CAS可能误判为没有变化。这时就必须用带版本号的CAS。实操心得我自己写无锁代码时ABA问题的处理准则是“能不用就不用”。绝大多数业务场景用锁或者原子类就够了非得手写CAS链表、CAS栈的先想清楚是否真的有必要。5. Lock体系与AQSJUC的骨架5.1 AQS的设计思想AQSAbstractQueuedSynchronizer是JUC最核心的类面试几乎没有不问的。ReentrantLock、Semaphore、CountDownLatch、ReentrantReadWriteLock全都是基于AQS实现。AQS的核心是一个volatile int state状态变量和一个双向等待队列。state的含义可以由子类自定义在ReentrantLock里state表示锁被重入的次数在Semaphore里state表示剩余许可数量在CountDownLatch里state表示还需要等待的事件数。AQS提供了两种同步模式独占模式只有一个线程能获取资源比如ReentrantLock共享模式多个线程可以同时获取资源比如Semaphore、CountDownLatch。5.2 CLH队列锁的原理AQS的等待队列其实是CLHCraig, Landin, Hagersten锁队列的变体。每个等待线程被封装成一个Node节点节点之间通过前驱和后继指针形成双向链表。CLH锁的思想是每个线程不直接自旋等待共享变量而是自旋等待自己前驱节点的状态。这样就将并发争用分散到了不同的缓存行上减少了缓存一致性流量。AQS在CLH基础上做了改动等待线程不总是自旋而是支持阻塞和唤醒。当线程获取锁失败时会被包装成Node加入队尾然后通过LockSupport.park挂起自己前驱节点释放锁后会通过unpark唤醒后继节点。这里面试官经常追问“为什么AQS的等待队列是双向链表”答案有两层一是需要支持“取消排队”操作如果某个线程等不到锁就放弃了需要从前驱或后继找到相邻的有效节点单向链表做不到二是需要支持条件队列Condition的signal操作需要精准找到下一个等待线程。5.3 ReentrantLock的公平与非公平ReentrantLock默认是非公平锁也可以传入fair参数构造公平锁。两者的核心区别在于获取锁的策略非公平锁线程抢锁时不检查队列中是否有等待者直接CAS尝试抢占抢不到才进入队列公平锁线程抢锁前先看看队列里有没有前驱节点有就让位排到队尾。非公平锁的性能通常更好因为减少了线程挂起和唤醒的次数。但它有个问题可能导致“线程饥饿”即某些线程迟迟得不到锁。公平锁则能保证先来后到但付出的代价是更频繁的上下文切换。我实测过两种锁在高并发下的表现场景是8个线程并发递增计数器100万次非公平锁的吞吐量大约是非公平锁的1.2到1.5倍。但在要求严格顺序执行的低并发业务里公平锁更可预测。6. synchronized与Lock的全面对比6.1 一张表格说清差异这是面试必考题我整理成表格方便记忆 | 对比维度 | synchronized | Lock | |----------|--------------|------| | 锁的获取和释放 | 自动 | 手动lock和unlock配对 | | 可中断性 | 不可中断除非抛异常 | lockInterruptibly支持中断 | | 超时获取锁 | 不支持 | tryLock(timeout, unit) | | 公平性 | 非公平 | 可以通过构造器指定公平 | | 条件变量 | 只有wait/notify一个条件队列 | Condition多条件队列 | | 底层实现 | 对象头Mark Word 监视器 | AQS LockSupport | | 性能 | JDK 6之后优化明显低竞争下接近Lock | 高竞争、复杂场景更灵活 |另外synchronized在JDK 6之后性能已经和Lock相差不大面试官如果问“为什么还要用Lock”正确的回答方向是不是性能而是“能力”。Lock提供了超时获取、可中断、多条件队列等synchronized不具备的能力。6.2 锁降级是什么如果你答得上“锁降级”基本能在候选人里脱颖而出。锁降级发生在ReentrantReadWriteLock中线程持有写锁时可以获取读锁然后再释放写锁。这样锁就从写锁“降级”为读锁。锁降级的目的是保证数据的一致性。典型应用场景一个缓存系统线程先读缓存发现数据过期于是获取写锁更新缓存更新完成后降级为读锁返回数据。如果不降级线程A更新完释放写锁后线程B可能拿到读锁读到旧数据。需要注意锁升级是不允许的即从读锁升级到写锁。因为如果两个读线程同时尝试升级为写锁就会发生死锁。这也是面试常挖的陷阱。6.3 StampedLock读写锁的进化版Java 8引入的StampedLock是对ReadWriteLock的改进核心优化是“乐观读”。读线程在读取时不需要加锁而是先获取一个stamp读完之后检查stamp是否仍然有效。如果没有写线程在此期间修改过数据本次读取就是有效的省去了加锁开销。但StampedLock有两个大坑第一它不支持重入同一个线程不能重复获取第二它的阻塞唤醒用的是LockSupport.park可能引发CPU占用100%的问题。官方推荐的使用方式是先乐观读验证失败再升级为悲观读锁或写锁。而且StampedLock是“不可重入、不可中断”用的时候要格外小心。7. 并发工具的同步语义7.1 CountDownLatch的倒计时原理CountDownLatch本质是一个“闸门”。初始化时指定计数N一个或多个线程调用await()等待直到计数归零才被放行。计数通过其他线程调用countDown()递减。它的内部实现就是一个AQS共享模式的state初始化。每次countDown()通过CAS将state减1减到0时唤醒所有等待线程。CountDownLatch是一次性的计数归零后不能再复用。经典使用场景主线程启动多个子线程执行任务等所有子任务完成后主线程再继续。7.2 CyclicBarrier的循环屏障CyclicBarrier让一组线程互相等待直到所有线程都到达屏障点才一起继续。它的“循环”体现在所有线程放行后屏障自动重置可以进入下一轮。它和CountDownLatch的区别经常在面试中被问到我整理过一张对比表对比维度CountDownLatchCyclicBarrier等待主体一个或多个线程等事件一组线程互相等计数方式countDown减计数await递增满员放行可复用不可复用可循环复用底层实现AQS共享模式ReentrantLock ConditionCyclicBarrier有一个可选参数Runnable当所有线程到达屏障时这个Runnable会先执行然后再放行所有线程。这个设计常用于批量处理前的数据聚合。7.3 Semaphore的信号量模型Semaphore维护一个许可集线程执行前需要acquire()获取许可执行完释放许可。它有两种模式公平和非公平内部同样是基于AQS实现。Semaphore常用于流量控制比如数据库连接池限制同时使用的连接数。参数N代表最多允许多少个线程同时访问资源。和线程池不同Semaphore控制的是“访问权限”而不是“线程生命周期”控制粒度更细。7.4 ThreadLocal的线程隔离哲学ThreadLocal不属于锁机制但它是JUC面试中“同步机制”话题的重要对比项。锁是“排他”ThreadLocal是“隔离”。每个Thread内部有一个ThreadLocalMap以ThreadLocal实例为key线程自己的变量副本为value。每个线程访问的是自己的副本互不干扰天然线程安全完全不需要同步。面试高频追问是“为什么ThreadLocal的key是弱引用”这是为了防内存泄漏。如果key是强引用即使线程不再使用这个ThreadLocalThreadLocalMap始终强引用它ThreadLocal对象无法被回收。用弱引用后只要外部不再强引用ThreadLocalGC就能回收keyvalue就变成了不可达的垃圾可以配合remove()清理。但这里有个隐藏坑value是强引用如果线程长期存活比如线程池中的线程ThreadLocalMap中的value也不会被回收可能造成内存泄漏。所以用完ThreadLocal一定要调用remove()尤其是线程池场景这是我用血泪换来的经验。8. 面试实战高频追问与解题思路8.1 四个连环炮问题第一个“一个有界队列多个生产者多个消费者怎么保证线程安全”标准思路是底层用ReentrantLock控制并发访问用两个Condition分别表示“队列非空”和“队列非满”。生产者获取锁后如果队列已满就await在“非满”条件上消费者获取锁后如果队列为空就await在“非空”条件上。生产成功后signal非空条件消费成功后signal非满条件。说清楚这个基本能证明你用条件队列实现过完整的生产消费模型。第二个“如果把锁升级流程中偏向锁撤销讲一遍”偏向锁撤销发生在另一个线程尝试获取偏向锁时。JVM需要等待全局安全点暂停所有用户线程然后判断持有偏向锁的线程是否还在执行同步块。如果不在说明可以安全重偏向如果在则膨胀为轻量级锁。重点说清楚“Safe Point”和“重偏向”这两个概念面试官就会知道你读过专业资料。第三个“AQS获取锁失败后线程具体做了什么”流程是线程执行CAS尝试修改state失败后创建共享节点通过addWaiter把节点挂到等待队列尾部然后进入acquireQueued循环。循环里先检查前驱节点是不是头节点是的话再尝试一次CAS抢锁抢到了就把自己设为新的头节点抢不到调用shouldParkAfterFailedAcquire处理前驱节点状态然后LockSupport.park挂起自己。被唤醒后重新检查中断标志继续循环。这个追问考察的是你对AQS源码细节是否真的读过而不是只知道概念。第四个“为什么wait和notify要放在同步块里”因为wait/notify是基于监视器锁的调用前必须先持有锁。wait会释放锁并进入等待队列notify会唤醒一个等待线程但被唤醒的线程必须重新竞争到锁才能继续执行。面试官问这个是想看你了不了解“等待-通知”机制和锁之间的绑定关系。8.2 现场排查问题的思路面试中偶尔会给出一个并发场景让你提排查思路最常见的是“CPU飙高100%怎么排查是不是死循环或者死锁”我的习惯是先jps找到进程号再jstack pid导出线程栈重点搜索deadlock关键字。如果发现两个线程互相持有对方需要的锁那就是典型的死锁。如果是RUNNABLE状态的线程集中在某个ConcurrentHashMap的putVal或ThreadPoolExecutor的getTask调用上很可能是并发太高导致HashMap扩容竞争或者线程池里的工作线程在做无用自旋。高并发场景下jstack可能很大我会先用grep -A 20 deadlock过滤出关键线程再看这些线程的调用栈是否停留在锁获取的地方。死锁的典型特征是每个线程都持有一把锁同时等待另一把锁形成一个环。jstack检测到死锁时会直接打印出来非常明显。这里再补充一个easily被忽略的点死锁发生不一定调用栈在锁上还可能是在等待CountDownLatch.await()。如果主线程在等待子线程完成任务而子线程又在等待主线程释放某个资源同样是死锁但表面现象完全不同。要习惯性地把deadlock和await一起搜索。8.3 同步器源码阅读建议如果你还有时间给你一个源码阅读的优先级顺序先读AQS核心的acquire、release、acquireShared、releaseShared四个方法理解排队的骨架再读ReentrantLock的lock、unlock看它如何调用AQS然后读AbstractQueuedSynchronizer.ConditionObject的await、signal理解条件队列和等待队列的迁移机制最后读ConcurrentHashMap的putVal和扩容逻辑那是并发容器综合运用的大成之作。每读一个类都要问自己三个问题这个类的state含义是什么它用的是独占还是共享模式等待队列里的线程是怎么被唤醒的带着这三个问题读源码不容易迷失在细节里。实操心得读AQS源码时建议自己画一个“线程调用的时序图”把线程A获取锁失败入队、线程B释放锁唤醒A的过程一步一步画出来。这个过程比读十遍源码都有用。我面试能通过大厂那轮靠的就是脑子里有这张图画。9. 一些顺手的工具和压测建议9.1 学习工具学习并发编程纯看书容易昏。我平时用的组合是arthas在线诊断工具可以实时查看某个线程的堆栈扫描锁等待情况JMH做微基准测试验证不同同步策略的性能差异jol查看Java对象内存布局直观看到Mark Word在不同锁状态下的值变化最近热门的“Java并发编程的艺术第二版pdf”内容也很扎实适合系统补一遍理论基础重点关注书里关于JMM和锁升级的章节。9.2 实测数据参考这里提供一组我跑过的压测数据方便你在面试时引用。8核CPU、JDK 11环境下100万次并发自增不同方案耗时方案耗时毫秒备注AtomicLong132竞争激烈时CAS重试多LongAdder45分段累加性能优势明显synchronized86JDK 11下偏向锁生效ReentrantLock91非公平开销略高于synchronized注意这组数据只是参考不同机器差异很大。面试时如果主动提数据最好强调一下测试环境否则容易显得不够严谨。真正重要的不是这个绝对数值而是LongAdder在高竞争下优于AtomicLong这一结论。9.3 再说说锁粒度这是很多人会忽略的“软技能”。同步机制不仅能回答“怎么实现”还能回答“如何设计得更好”。实际项目里锁粒度直接影响性能锁越粗并发度越低但代码越简单出错概率越低锁越细并发度越高但复杂性剧增死锁概率上升“分段锁”是经典的折中方案ConcurrentHashMap早期版本JDK 7就是Segment分段锁JDK 8改用了CAS synchronized锁Node节点粒度更细而且效果更好。我个人做并发优化的顺序是先保证正确性再考虑性能。绝不为了追求“看起来很厉害”的细粒度锁而牺牲可维护性。10. 写在最后的小经验如果你现在正在准备面试我的建议是不要死记硬背八股而是把每个机制当成一个“问题解决方案”去理解。synchronized是JVM帮你管理的锁AQS是你自己可以扩展的同步框架CAS是CPU层面的原子操作ThreadLocal是空间换时间的隔离方案。它们各自解决了不同维度的问题组合起来才能构建出健壮的并发程序。我再分享一个我常用的“面试自测法”拿一张白纸画一条从“线程进入同步代码”到“线程获取锁失败进入等待队列被唤醒重新抢锁执行完毕释放锁”的完整链路。如果你能把这链路上每一环涉及的关键类和方法都写出来说明你真的掌握了。写不出来哪怕是背了再多标题面试官多问一层就露馅。这篇内容是一份地图不是终点。真正吃透同步机制还是要打开源码动手压测踩几次并发bug那些“纸上得来终觉浅”的坑踩过了就真的内化了。祝刷到这篇文章的你顺利拿到心仪的offer。