
做后端开发这些年面试永远绕不开一个主题——并发编程。而并发编程里最常被问、也最考验基本功的就是JUC包下的同步机制。毫不夸张地说JUCJava Util Concurrent就是Java并发世界的半壁江山synchronized、Lock、AQS、CountDownLatch、Semaphore、CyclicBarrier每一个拎出来都能让面试官问上十分钟。这篇文章我不想写成枯燥的源码翻译而是站在一个“被JUC虐过很多遍也用它解决过不少线上问题”的从业者角度把同步机制这条线从头到尾捋一遍。你会看到锁到底锁的是什么、AQS为什么是核心、工具类该怎么用才不出错以及面试里那些高频对比题背后的真正答案。内容适合正在准备面试的Java开发也适合写了好几年业务代码但没系统梳理过并发知识的同学。我先把话放这儿同步机制学得好不好不是看你背了多少锁的名字而是看你能不能讲清楚“线程为什么需要同步”“锁底层在做什么”“多个锁之间怎么选”。带着这三个问题往下看比单纯记八股文有用得多。1. 同步机制全家桶先搞清我们到底在聊什么1.1 一个库存扣减引发的血案为什么需要同步我先抛一个最朴素的场景。假设你维护一个电商系统的库存服务库存表里有个字段叫stock初始值是10。现在有两个线程同时执行“判断stock 0然后stock--”这段逻辑if (stock 0) { stock--; System.out.println(扣减成功剩余 stock); }从代码上看好像没问题但真实运行时会出大事。两个线程可能同时读到stock1分别执行了“0判断”然后各自做了减法。按理说只能有一个线程成功结果却可能两个都打印“扣减成功”库存变成了负数。这不是理论推演这是真实线上事故的复现。原因就是CPU执行指令时存在“读-改-写”三步竞态两个线程的读操作交错在一起后面的写覆盖了前面的结果。同步机制存在的意义说直白点就是给这种“竞争操作”装上红绿灯保证同一时刻只有一个线程能进入关键路段。1.2 同步要解决的三个底层问题原子性、可见性、有序性聊同步机制之前得先把三个底层问题说透因为所有锁的设计都在解决这三件事原子性一个操作要么全部执行完要么不执行不允许执行到一半被插队。典型的代表就是“读-改-写”和“check-then-act”。库存扣减就是原子性被破坏的案例。可见性线程A修改了变量线程B能不能立刻看到新值。因为每个线程都有自己的工作内存寄存器/缓存如果变量不是volatile或者没有加锁保护线程B可能一直读到一个旧值这个叫“脏读”本质是缓存一致性没做到位。有序性编译器为了性能会做指令重排我们的代码顺序和CPU实际执行顺序可能不一样。在多线程环境下重排可能导致一个线程看到的执行顺序和另一个线程完全不一致。JMMJava内存模型的设计目标就是通过happens-before规则来保证这三点。synchronized保证的是“解锁前的操作happens-before后续加锁线程的操作”volatile保证的是“写操作happens-before后续读操作”。理解了这一层再去用锁思路就清晰得多。1.3 JUC家族成员与各自分工很多人把JUC理解为一堆工具类的集合其实应该把它理解成一套“同步工具箱”按场景分工成员核心作用典型场景synchronizedJVM内置监视器锁最基础方法级/代码块的互斥volatile可见性与有序性保证不保证原子性状态开关、DCL单例ReentrantLock基于AQS的可重入互斥锁需要可中断、超时、公平锁的互斥场景ReentrantReadWriteLock读写分离的锁读多写少的缓存场景Semaphore令牌信号量允许多个线程同时进入限流、连接池CountDownLatch倒计数门闩等待若干线程完成任务CyclicBarrier屏障等待线程到齐后一起放行并行分阶段计算Exchanger线程间数据交换两个线程交换缓冲区这几样东西底层会汇合到两个核心机制一个是JVM层面的Monitorsynchronized一个是JUC层面的AQSAbstractQueuedSynchronizer。下面几节我就沿着这两条主线展开。2. 从volatile到synchronizedJVM层的同步底牌2.1 volatile轻量级的可见性保证volatile常常被低估因为语法简单写起来就一个关键字但它背后对应的是内存屏障和缓存一致性的完整机制。volatile做了两件事保证可见性和禁止指令重排。它的原理是每次写volatile变量JVM会插入一个StoreStore屏障 StoreLoad屏障强制把工作内存中修改的值刷新到主内存每次读volatile变量会插入LoadLoad屏障 LoadStore屏障强制从主内存重新读取。对应的CPU层面靠的是lock前缀指令或MESI缓存一致性协议来完成。一个典型的误区是volatile不能保证原子性。比如下面这段volatile int count 0; // 多个线程同时执行 count;这个操作依然会出错因为count实际上是“读-加-写”三步volatile只保证了读到的永远是最新的、写出去别人能立刻看到但它拦不住三步之间被其他线程插队。volatile真正的用武之地是“一个线程写多个线程读”的场景。比如定义一个volatile boolean running true作为线程停止开关或者经典的DCL单例模式中用volatile修饰instance防止“半初始化对象被别的线程看到”。我在实际项目中用它最多的地方就是状态机切换一个后台线程维护状态N个前端线程读状态展示用volatile足够不需要加重量级锁。2.2 synchronizedMonitor对象与锁升级的完整链路synchronized是JVM原生支持的锁。它的同步语义建立在Java对象的Monitor上每个对象关联一个Monitor监视器锁只有拿到Monitor的线程才能进入同步代码块否则就阻塞等待。锁升级这条链路是面试必考点我建议你把它当故事一样记——锁不是一上来就很重无锁状态对象刚创建出来没有任何线程竞争Mark Word记录的是对象哈希码等普通信息。偏向锁第一个线程访问同步块时JVM会把Mark Word中记录该线程ID之后这个线程再次进入不需要CAS直接判断线程ID一致就能通过这是为了优化“单线程反复加锁同一个对象”的场景。轻量级锁一旦第二个线程来竞争偏向锁立刻撤销升级为轻量级锁。轻量级锁的加锁方式是CAS自旋把Mark Word替换成指向线程栈中锁记录的指针。如果自旋一会儿就抢到了那很好如果迟迟拿不到说明竞争激烈。重量级锁自旋超过阈值默认开启自适应自旋仍然失败就膨胀为重量级锁线程进入阻塞和唤醒队列此时才会涉及操作系统层面的线程挂起代价最大。注意JDK 15起偏向锁默认启用JDK 18起JEP 374将偏向锁默认禁用并标记废弃。原因是偏向锁在竞争频繁的应用中反而增加了撤销成本。如果你用的JDK版本较新直接忽略偏向锁知道有这个东西就行。2.3 synchronized的实战三个细节锁对象、锁粒度、异常释放讲完底层说点实战里的东西。用synchronized有三个细节决定生死第一锁对象必须选对。synchronized修饰普通方法锁的是this修饰静态方法锁的是Class对象修饰代码块锁的是括号里的实例。锁对象选错了就会“各锁各的”根本起不到互斥效果。最常见的错误是把一个局部创建的Object当作锁对象使用。第二锁粒度要尽量小。我见过很多代码把整个方法加上synchronized如果方法内有IO操作或远程调用锁被持有的时间会很长后面的线程全部排队系统的并发能力直线下降。更好的做法是只给需要保护的临界区加锁或者拆成细粒度的分段锁。第三synchronized不需要显式释放锁这个既是优点也是隐患。优点是就算代码抛异常JVM也会在异常退出时通过monitorexit指令释放锁。隐患是很多人不重视锁保护区域内的异常处理一旦业务逻辑异常导致提前return会把临界区搞得很难追溯。我的习惯是临界区内做“最小必要操作”异常状态单独记录。3. Lock体系线程协作的进阶齿轮3.1 ReentrantLock的三个独门绝技可中断、可超时、公平锁synchronized好用但没有“反悔”的能力。比如线程A阻塞在锁上想让它主动放弃等待synchronized做不到想设置一个最长等待时间超过就放弃synchronized也做不到。这些高级控制能力正是ReentrantLock存在的意义。ReentrantLock是基于AQS实现的互斥锁它有三个独门绝技可中断获取锁lockInterruptibly()可以在等待锁的过程中响应中断。这在处理高并发下的优雅停机、任务取消时很有用。可超时获取锁tryLock(3, TimeUnit.SECONDS)等待3秒拿不到锁就返回false业务可以做降级处理避免无限阻塞。公平锁构造时传new ReentrantLock(true)启用公平策略线程按先来后到顺序获取锁避免“抢锁饿死”问题。但公平锁有性能代价非必要不建议开启。另外ReentrantLock是可重入的一个线程已经拿到锁之后再次获取同一个锁不会死锁因为AQS内部维护了exclusiveOwnerThread和state计数器每次重入state1每次释放state-1减到0才真正释放。3.2 ReentrantReadWriteLock读多写少场景的利器很多业务场景是“读多写少”比如配置中心、本地缓存。如果一律加互斥锁读和读之间互相排队白白浪费了并发能力。ReentrantReadWriteLock解决的就是这个问题它把锁分成读锁和写锁读锁是共享的多个线程可以同时持有读锁写锁是独占的写锁和任何锁都互斥线程持有读锁时不能升级为写锁防止写锁被饥饿持有写锁时可以降级为读锁。WriteLock的获取条件是“没有任何线程持有读锁”这一点在源码里非常明确写锁获取前会检查readLockCount 0。我的实践是本地缓存的刷新场景读取走读锁全量刷新走写锁极端情况下这个方案能把读性能提升好几倍。注意读写锁在写频繁的场景下性能可能反而更差因为写锁会阻塞所有读者。判断是否适合就看读:写比例一般10:1以上才值得用。3.3 Lock和synchronized到底怎么选我这里不会给“用Lock就好”这种一句话答案。我的选型逻辑是这样的如果只是普通互斥临界区简单优先synchronized。它的代码简洁、自动释放、JVM做了大量优化配合锁升级策略在现代JDK里性能并不比Lock差。如果需要锁超时、可中断、公平性、多个Condition条件队列、读写分离选Lock体系。这些都是synchronized给不了的硬能力。如果临界区特别长比如包含数据库操作或RPC调用优先用tryLock超时模式而不是裸Lock接口。因为锁持有时间长一旦线程卡死其他线程全部跟着卡死超时锁至少能兜底降级。4. AQS藏在所有Lock背后的灵魂4.1 AQS的设计骨架int state CLH变体队列为什么ReentrantLock、Semaphore、CountDownLatch这些看起来完全不同的工具底层逻辑却惊人地一致因为它们全部建立在同一个框架上——AQS。AQS的核心里有两样东西volatile int state一个代表同步状态的原子变量。在不同工具里state的含义不同。ReentrantLock里state代表“锁被重入了几次”Semaphore里state代表“还剩几个许可”CountDownLatch里state代表“还剩几个计数器”这就解释了为什么AQS能一套代码支撑各种同步器。CLH变体队列一个FIFO的等待队列用于存放“获取锁失败的线程”。这个队列不是简单的链表每个节点有prev、next指针和waitStatus状态字段线程通过前驱节点的状态判断自己是否该继续等待。你可以把AQS的队列想象成银行柜台前的排队线柜员锁持有者在办业务新的客户线程来了发现柜员忙就站到队伍末尾前一个人办完了才会通知下一个人。更妙的是这个“通知”不是靠轮询而是靠LockSupport.park()挂起线程、LockSupport.unpark()精准唤醒省CPU。4.2 独占模式加锁的源码走读以ReentrantLock非公平锁的lock()为例走一遍AQS的加锁流程。这也是面试官最喜欢让你讲的部分lock()调用内部sync.lock()第一步先尝试compareAndSetState(0, 1)——用CAS把state从0改成1如果成功说明锁没被占用当前线程直接拿到锁设置exclusiveOwnerThread currentThread。如果CAS失败进入acquire(1)走tryAcquire(arg)。非公平锁的tryAcquire会再判断一次state0如果此时锁刚好被释放就再CAS抢一次——这就是“非公平”的体现新来的线程可以插队。tryAcquire返回false说明锁真的拿不到。此时调用addWaiter(Node.EXCLUSIVE)把当前线程包装成Node节点拼到等待队列尾部。CPU敏感的读者会注意到这里用了CAS自旋来保证入队线程安全。入队后进入acquireQueued方法。线程会尝试再次获取锁如果还是不行就检查前驱节点的状态前驱节点正常的话执行parkAndCheckInterrupt()将自己挂起。直到前驱节点释放锁时通过unparkSuccessor唤醒后继节点被唤醒的线程重新尝试获取锁抢到了就出队跳出循环。整条链路的关键词就是先CAS抢、抢不到就排队、排队后挂起、被唤醒再抢。这套逻辑的可复用性极强锁、信号量、闭锁全是同一个模式换皮。4.3 共享模式Semaphore与CountDownLatch的底座独占模式讲完了共享模式的AQS也不复杂核心区别在tryAcquireShared和doReleaseShared。共享模式的特征是一个“许可”可以被多个线程共用直到用完为止。拿Semaphore举例构造时传入permits作为state的初始值。线程每次acquire()调用tryAcquireShared内部执行CAS(state 0 ? state - 1 : 失败)成功就进去失败就排队。而release()则执行state 1并通过doReleaseShared唤醒后续等待线程。CountDownLatch是另一个极端state初始化为N线程每次调用countDown()就让state减一直到state变成0时唤醒队列里所有等待线程。注意这里唤醒的是“所有”所以CountDownLatch有个天然属性——它是一次性的用完之后state就是0后续await()会直接通过不可重置。理解了AQS这两种模式你在面试里讲任何同步工具都能从底层往上讲故事而不是死记API。5. JUC同步工具类从Demo到实战的最后一公里5.1 CountDownLatch一次性闸门的正确打开方式CountDownLatch的用法可以用一句话概括主线程等着所有子线程干完活然后一起放行。最常见的是压测场景启动N个线程并发请求接口主线程用await()等待全部完成后再统计耗时。但有几个实践细节我必须提醒countDown()必须放在finally里执行否则线程抛异常导致计数不减主线程会永久阻塞。线上这种事故非常多等你有天发现服务“卡死”拉线程栈一搜大概率是CountDownLatch的坑。await(10, TimeUnit.SECONDS)比裸用await()更安全凡是等待外部资源完成的场景一定要加超时兜底。CountDownLatch是一次性的不能重复使用。如果需要“反复等待多轮任务”请用CyclicBarrier。5.2 CyclicBarrier可循环使用的屏障CyclicBarrier翻译过来是“循环屏障”它等的是“每一轮的所有线程到齐后同时放行”。和CountDownLatch的“主从等待”不同CyclicBarrier是“所有线程互相等待”。一个典型的例子是分段数据并行处理有4个线程分别处理4个分片的数据每处理完一个阶段必须等4个线程都完成这个阶段再进入下一阶段。这时候CyclicBarrier就是为它而生的。需要注意CyclicBarrier有一个构造函数参数Runnable barrierAction当所有线程到达屏障时会执行这个动作。我常用它来做“每轮汇总”。另外一个容易踩的坑如果某个线程在等待屏障时被中断或超时CyclicBarrier会进入“broken”状态其他所有等待线程都会立刻抛出BrokenBarrierException。所以使用时要对异常做区分处理不要笼统地捕获成一样。5.3 Semaphore信号量与限流实践Semaphore的思路特别贴合现实世界——停车场的空位显示牌。空位数就是许可数每一个进入停车场的人消耗一个许可离开后归还一个许可没位置了就在门口排队。语义理清后真实场景就好用了限流控制同一时间最多N个线程执行某个操作。比如限制数据库连接池的并发申请数防止连接被打爆。资源池管理一组有限的资源比如MaxN个外部HTTP连接用Semaphore控制获取和归还。公平模式构造Semaphore时传true保证排队线程按顺序获取许可适合生产环境用户请求这类需要公平性的场景。我还想提醒一个细节Semaphore的acquire()可以一次拿多个许可但release时也要归还相同数量否则许可数会错乱导致后续线程获取失败。项目里我通常封装成try-finally的模板方法降低出错概率。5.4 Exchanger两个线程间的数据交换Exchanger是JUC里存在感最低的一个同步工具因为它的应用场景非常窄——两个线程在某个汇合点交换数据然后继续执行。经典用法是两个线程各持一个缓冲区一个负责填饱数据一个负责清空数据。每一轮填完后两个线程通过exchange()交换缓冲区下一轮填充方用对方清空的缓冲区继续操作。这种模式在图形渲染、流处理里能省掉反复新建缓冲区的开销。它的API只有一个exchange(V x)会阻塞等待另一个线程也到达交换点。实际生产中我用的不多但如果面试被问到你能讲出它的设计意图和交换语义也是加分项。至少要知道它和CountDownLatch、CyclicBarrier的定位完全不同那个是“等齐”这个是“交换”。6. 高频对比与实战避坑锁竞争下的一线经验6.1 高频对比题synchronized vs ReentrantLock面试必考我帮你把对比维度整理成表照着记理解比背诵更重要对比维度synchronizedReentrantLock实现层级JVM原生MonitorJDK基于AQS锁获取方式自动手动lock/unlock释放锁自动异常也释放必须finally中unlock可中断不支持支持lockInterruptibly超时获取不支持支持tryLock(timeout)公平性非公平支持公平/非公平条件等待单个wait/notify集合多个Condition精确唤醒读写分离不支持ReentrantReadWriteLock锁升级偏向锁到重量级锁无锁升级直接CAS排队一个容易被忽视的点是synchronized的锁信息存在对象头Mark Word里而ReentrantLock的锁状态存在AQS的state变量里。这决定了它们获取、释放、排队的内存操作路径完全不同也解释了为什么synchronized能和对象生命周期绑定而Lock必须显式管理。至于性能JDK 6之后synchronized引入了锁升级和自适应自旋二者在低竞争场景下差距微乎其微不要迷信“Lock一定更快”。真正的性能瓶颈通常不在锁本身而在锁的粒度和持有时间。6.2 死锁场景的三板斧与规避说到同步机制不聊死锁是不完整的。经典死锁条件有四条互斥、持有并等待、不可剥夺、循环等待。只要破坏其中一条死锁就不会发生。实际项目中我用的是三板斧规避方式锁排序多把锁时规定全局统一的获取顺序。比如先获取锁A再获取锁B所有线程都按这个顺序来循环等待就被拆掉了。超时释放能用tryLock就尽量用tryLock等待超时后主动放弃、回滚重试而不是无限阻塞。锁粒度拆分把一把大锁拆成多把细粒度锁降低锁之间的交叉持有概率。排查死锁时优先用jstack抓线程快照看线程栈里的“waiting for monitor entry”和“parking to wait for”关键字。也可以直接用jconsole或Arthas的thread命令一键定位死锁环。6.3 锁粒度的艺术从热点账户性能瓶颈说起最后聊一个我在电商项目里真实遇到的性能问题用来收束整篇文章。当时有一个热点账户的余额更新接口所有交易都集中在同一个账户上整个服务用了一把synchronized锁保护余额变更逻辑。结果压测时TPS直接到地板所有线程都在排队等锁。后来怎么解决的我们把“余额变更”拆成“余额快照 变更流水”两个步骤余额快照可以有很小的延迟所以不加锁用volatile读真正的余额计算在“变更流水”落库后通过乐观锁CAS在校验版本号时完成。这样一来锁竞争从“秒级排队”降低到“毫秒级重试”整个接口的吞吐量提升了将近一个数量级。这个案例给我的体感是同步机制用得好不好本质是看你对锁模型的理解程度。synchronized、ReentrantLock、CAS、AQS每一层都是不同粒度的解决方案没有哪个是银弹结合业务特点做取舍才能真正把并发编程玩明白。如果你在准备面试建议不要只背API动手把ReentrantLock的lock方法栈追踪一遍再把Semaphore的许可获取流程图画一遍这样所有工具类的底层故事就全通了。