做后端这些年只要一聊到并发锁就是绕不过去的坎。不管面试还是线上排查总有人把 synchronized 和 Lock 摆在桌面上但真到讲原理时很多人只能背出“CAS 是比较并交换”这一句再往下就卡壳了。这篇文章想做的就是把 Java 锁机制的整张地图铺开CAS、synchronized、Lock 各自怎么解决并发问题、底层是怎么做的、什么场景该用哪一个。无论你是准备 Java 面试、在业务代码里被锁搞得焦头烂额还是打算把并发这桶水重新拎一遍照着下面的思路消化应该能少走不少弯路。1. 锁机制全景三种手段到底在解决什么问题1.1 并发 bug 的三个根源原子性、可见性、有序性先别急着背锁的 API。锁这个东西之所以存在是因为并发环境下数据会被搞坏。搞坏的根源可以归纳成三个原子性、可见性、有序性。原子性指的是一个操作要么全部执行成功要么完全不执行不能被线程调度打断。典型代表是count这行代码看起来是一句但底层分成了“读取 count、加 1、写回 count”三步。两个线程同时执行时可能都读到 1然后都写回 2丢掉一次更新。互斥锁就是用来保证原子性的让同一时刻只有一个线程进入临界区。可见性是说线程对共享变量的修改能不能被其他线程立刻看到。CPU 有三级缓存线程有自己的工作内存变量改了之后如果没刷回主内存别人读到的就是旧值。volatile能解决可见性但解决不了原子性。synchronized和 Lock 在加锁时会让线程从主内存重新读取数据解锁时把修改刷回主内存所以它们也能保证可见性。有序性是指编译器、处理器为了性能可能对指令重排单线程下结果没问题但多线程下两个线程互相依赖执行顺序时就会出问题。锁通过内存屏障来限制重排保证临界区内的操作对外表现为有序。理解了这三个根源你就明白为什么后来出了这么多“无锁”工具还替代不了锁CAS 能解决原子性但解决不了所有互斥场景volatile能解决可见性但解决不了复合操作的原子性而synchronized和 Lock 是三者通吃的重量级方案。选择什么工具本质是在性能和安全之间做权衡。1.2 CAS、synchronized、Lock 各自的定位三条路线解决的是同一个问题多线程争抢共享资源时如何保证数据一致。CAS 走的是“无锁路线”。它不阻塞线程而是让线程先尝试修改修改失败就重试。这个思路有点像考试抢答谁先举手谁回答如果同时有人举手就重新举手。优点是没有线程切换开销缺点是会让 CPU 空转而且只能保护单个变量。synchronized 是 JVM 内置的“悲观锁路线”。它默认认为数据会被多个线程竞争所以直接给对象加锁其他线程想进来就得阻塞等待。JDK 1.6 之后引入了偏向锁、轻量级锁等优化所以它并不是“老古董”在低竞争场景下性能反而非常好。Lock 是 JDK 提供的一套 API 级别的锁底层依赖 AQSAbstractQueuedSynchronizer。它更像“手动路线”可以尝试获取锁、可以设置超时时间、可以响应中断、可以区分公平与非公平远比 synchronized 灵活。缺点是必须手动在 finally 里释放锁用不好容易死锁。这三者的关系不是“谁替代谁”而是“在不同场景下谁更合适”。能把这三条路线的原理串起来面试官问 Java 并发基本就难不倒你了。2. CAS无锁并发的基石2.1 比较并交换的工作原理CAS 全称 Compare And Swap核心是三个值内存地址 V、期望值 A、新值 B。执行时先比较内存地址 V 上的值是否等于期望值 A如果相等就把 V 上的值更新为 B如果不相等说明有其他线程抢先改过了更新失败。整个过程是硬件层面的一个原子指令通常是cmpxchg所以不会被线程调度打断。CAS 的这个行为很有意思它不是在“锁住”资源而是在“检查-更新”。检查通过就更新检查不通过就重来。这种乐观策略非常适合竞争不激烈的场景。你可以把它理解成我们提交代码时的“乐观锁”提交前先对比远端版本号一样才允许 push不一样就得先 pull 再 merge。一条直接的经验写 CAS 逻辑时循环重试是标配。因为 CAS 失败后不会自动做什么你得自己写一个 while 循环直到成功为止。否则一次失败就直接退出业务就错了。2.2 AtomicInteger 里的 CAS 实践JDK 里用得最广的 CAS 实现是java.util.concurrent.atomic包下的原子类。AtomicInteger的incrementAndGet()内部就是一个典型的 CAS 循环public class AtomicCounter { private AtomicInteger count new AtomicInteger(0); public void increment() { count.incrementAndGet(); } public int get() { return count.get(); } }用AtomicInteger替代int再用incrementAndGet()替代旧代码里的原子性丢失问题就解决了。原理上incrementAndGet()会调用unsafe.getAndAddInt内部不断尝试compareAndSwapInt直到成功。这里有个面试高频点为什么AtomicInteger能保证可见性因为它内部保存值的字段声明了volatileCAS 失败后重新读取主内存的值再试。所以原子类 CAS volatile缺一不可。实操心得如果只是计数、累加这种简单场景优先用原子类别上锁。它的吞吐量在低竞争下比 synchronized 高几个量级。但如果你要同时原子更新两个变量AtomicInteger就无能为力了得用AtomicReference包装一个对象或者直接用锁。2.3 ABA 问题与 AtomicStampedReferenceCAS 有个经典漏洞叫 ABA 问题一个线程读到值是 A另一个线程把值改成了 B再改回 A此时第一个线程再去 CAS发现值还是 A就认为没人动过。这种“中间有人改过但最终又变回来”的假象在一些场景下会酿成事故。举个实际例子你在用 CAS 更新一个链表头节点线程 A 读到头节点是 node1准备替换成 node2。期间线程 B 把 node1 弹出再插回一个内容被改过的 node1线程 A 的 CAS 成功了但链表状态已经不对了。解决方法是用带版本号的原子类AtomicStampedReference。它内部保存一个对象引用和一个整型版本号每次更新必须同时比对引用和版本号。也就是说即使值变回 A版本号不会变回旧值CAS 就能识别出“被改过”。写代码时要注意不要只调compareAndSet(reference, newReference, stamp, newStamp)就完事版本号必须由你自己手动维护每次修改都递增。很多人以为这个类会自动帮你加版本号其实不会。2.4 自旋锁的代价与取舍CAS 失败后的重试通常伴随着自旋也就是线程一直占用 CPU 空转等着其他线程释放资源。自旋的代价非常直接竞争激烈时多个线程都在那里空转CPU 被白白烧掉甚至导致综合性能还不如阻塞锁。所以 JDK 里的锁并不是一味死循环。以synchronized的轻量级锁为例早期实现会在自旋一定次数后膨胀为重量级锁挂起线程让出 CPU。后来的版本引入了适应性自旋JVM 会根据上一次同一个锁的自旋结果动态调整自旋次数。上次自旋成功这次就多转几圈上次失败这次就少转甚至不自旋。这个设计给我们的启示是无锁不是银弹自旋也不是银弹。用原子类时如果发现线上 CPU 使用率飙升、线程数不高但 CPU 被打满先检查是不是多个线程疯狂 CAS 同一个字段。如果是考虑改用锁或者分片处理。3. synchronized锁升级才是性能关键3.1 三种加锁方式与锁对象synchronized 有三种用法它们锁的对象各不相同理解错了就会出现“锁了个寂寞”的尴尬局面。第一种是修饰实例方法锁的是当前实例thispublic synchronized void add() { ... }这种写法只对“同一个对象上的同步方法调用”互斥。如果你有两个对象各自调自己的 add线程不会互相等待。第二种是修饰静态方法锁的是当前类的 Class 对象public static synchronized void addStatic() { ... }Class 对象全局只有一份所以所有实例的对象调用静态同步方法时都会串行。很多人面试时被问“实例同步方法和静态同步方法有什么不同”核心就在锁对象不同。第三种是同步代码块可以自己指定锁对象synchronized (lock) { ... }锁对象可以是任意对象但推荐用独立的专用锁对象不要直接用 String 或 Integer。原因是字符串常量池和 Integer 缓存会让不同的“逻辑锁”意外共享同一个底层对象导致本不想互斥的代码块被无意义串行化。3.2 对象头上的 Mark Word 与锁状态记录synchronized 的锁信息不是单独存结构而是存在 Java 对象头的 Mark Word 里。Mark Word 是一块长度不确定的数据长度取决于 32/64 位 JVM里面记录 hashCode、分代年龄、锁状态标志位。锁状态可以从 Mark Word 的位模式区分无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态。JVM 为什么要设计得这么复杂核心原因是锁竞争的激烈程度是分层的。大部分对象生命周期内可能只有一个线程访问那就没必要一上来就搞操作系统级别的互斥成本太高。Mark Word 里还记录了指向锁记录的指针、指向 monitor 对象的指针等。当我们说“锁升级”的时候本质上就是 Mark Word 的状态位发生切换。这也是 JVM 判断当前锁属于哪一级的关键依据。值得注意偏向锁在 JDK 15 里被标记为废弃后续版本默认关闭。原因是偏向锁的撤销需要安全点反而带来较大开销。所以在新版本 JDK 上网上那些“偏向锁、轻量级锁、重量级锁”背得再熟也要知道可能已经不适用了。面试时最好加上一句“JDK 15 之后默认废弃了偏向锁”这个细节会让面试官觉得你关注版本演进。3.3 偏向锁、轻量级锁、重量级锁的升级过程先说结论synchronized在 JDK 1.6 之后不是一把死锁它的锁状态会根据竞争程度逐级“膨胀”。偏向锁是整个升级链的第一层思想是“锁偏向第一个获得它的线程”。如果一个 synchronized 代码块始终只有一个线程进入就让这个线程持有偏向锁避免每次加锁都走 CAS。Mark Word 里记录线程 ID之后这个线程再进入时只需检查 Mark Word 是否指向自己成本极低。一旦出现第二个线程竞争偏向锁就会被撤销膨胀为轻量级锁。轻量级锁的加锁过程是线程先在栈帧里创建一个锁记录空间然后通过 CAS 把对象头 Mark Word 复制替换成指向锁记录的指针。如果 CAS 成功说明拿到锁如果失败说明有人在竞争线程会自旋一小段时间等待。如果自旋超过阈值或者竞争线程数太多轻量级锁就会膨胀为重量级锁。重量级锁依赖操作系统的 mutex 互斥量线程会进入阻塞状态由用户态切换到内核态。这个切换的开销极大所以重量级锁被戏称为“兜底方案”。这里要明确一点锁升级通常被认为是不可逆的。一旦升到重量级之后的竞争即使变少也不会自动降回来。所以高并发短临界区的代码最怕一开始就发生锁竞争导致直接膨胀到重量级。3.4 锁消除、锁粗化与 JIT 优化JIT 编译器也不是光看着锁状态升级它还会做两个很有意思的优化。锁消除是指编译器发现某个锁对象根本不会被其他线程共享那么这个锁就是多余的直接去掉。典型例子是在方法内部创建一个局部对象再用 synchronized 锁它比如某些StringBuffer在单线程局部变量场景下的 append。JIT 通过逃逸分析发现该对象没有逃出方法作用域就会把锁消除。锁粗化则相反是指 JVM 把多个相邻的、对同一个对象的加锁/解锁操作合并成一个大范围的锁。比如循环里每次加锁与其反复加锁解锁不如扩大成整个循环体一次加锁。这是“负负得正”的优化思路。理解这两个优化对写代码有指导意义不要刻意去写锁消除依赖代码因为逃逸分析结果随 JVM 版本和运行数据变化。但可以留心同步代码块要尽量精简锁粒度别搞得太大否则 JIT 想帮你优化都无从下手。4. Lock 体系接口之下的 AQS 骨架4.1 Lock 与 synchronized 的对比Lock 是 Java 并发包里的接口常见实现是ReentrantLock。和 synchronized 相比它有四个明显优势可超时、可中断、可公平、支持多个 Condition。可超时用tryLock(long time, TimeUnit unit)拿不到锁不会无限等待适合需要兜底的业务。可中断用lockInterruptibly()线程在等待锁期间可以被中断及时响应取消操作。可公平构造ReentrantLock(true)可以保证线程按先后顺序获取锁而 synchronized 是非公平的。多 Conditionsynchronized 只有一个等待队列一个wait/notify通道Lock 可以newCondition()多个条件队列精细控制唤醒。当然也有代价Lock 必须手动释放而且最好用try/finally包住。如果忘记unlock()锁永远不释放线程直接卡死。这比 synchronized 的“自动释放”坑得多尤其是出现异常时finally 里的 unlock 是你最后的安全网。我见过不少线上问题就是开发把lock.lock()放在 try 里面然后 try 里抛异常后续代码没走lock 虽然拿到了但 finally 没有兜底直接导致所有线程阻塞在那个锁上。所以正确姿势是lock.lock()单独放try 里写业务finally 里unlock()。4.2 AQS 怎么实现阻塞与唤醒Lock 的核心是 AQSAbstractQueuedSynchronizer它是整个java.util.concurrent的骨架。AQS 维护一个volatile int state和一个同步等待队列。state 表示资源状态比如ReentrantLock里 state 为 0 表示没有线程持有锁大于 0 表示锁被持有并且还能记录重入次数。AQS 的同步队列是一个双向链表每个节点对应一个等待线程。当线程获取锁失败时会包装成 Node 节点通过 CAS 挂到队列尾部然后调用LockSupport.park()进入等待。当持有锁的线程释放锁时AQS 会唤醒队首节点让它在合适时机去抢锁。这里有个常见面试点AQS 的队列是 CLH 锁的一个变体。原版 CLH 是自旋等待AQS 则改成了阻塞唤醒队列里的节点还有前驱、后继指针能够处理取消状态。所以别背成“AQS 用的是 CLH 队列”就完事要补充说明是变体。从使用角度看你一般不会直接写 AQS而是继承它实现自定义同步器重写tryAcquire/tryRelease或者tryAcquireShared/tryReleaseShared。JUC 里的CountDownLatch、Semaphore、ReentrantLock都是这么构建出来的。一旦理解了 AQS再去看这些工具源码会轻松很多。4.3 ReentrantLock 公平锁与非公平锁ReentrantLock默认是非公平锁。非公平的含义是新线程在调用lock()时会先尝试一次 CAS 抢锁如果刚好抢到就直接获取不需要排队抢不到才进入等待队列。而公平锁会先检查队列里有没有比自己更早的等待者有的话就乖乖入队。为什么默认非公平因为公平锁的严格排队会牺牲吞吐量。想象一个场景线程 A 释放锁的瞬间线程 B 刚好被唤醒但线程 B 从内核态切换回来需要时间。这期间新来的线程 C 直接 CAS 抢锁成功线程 B 白白被唤醒了一次。非公平锁让新线程有机会插队降低了“唤醒线程但抢不到锁”的概率整体吞吐量更高。但公平锁的好处在于“避免饥饿”。长时间竞争下非公平锁可能出现某些线程一直抢不到锁的情况。如果业务对获取锁的先后顺序敏感比如按照请求顺序处理交易就得上公平锁。实际使用中我一般不会无脑选公平锁因为消费者端常常并不需要严格顺序。只有日志、票据号等强顺序要求才用ReentrantLock(true)。保持默认非公平然后在代码注释里写明为什么不需要公平比“好像公平一点更安全”的直觉要靠谱。4.4 Condition 条件队列Condition 是 Lock 体系里的wait/notify替代品。它把一个显示锁划分成多个等待队列线程可以针对不同条件分别等待和唤醒。一个典型的例子是生产者-消费者模型。如果用 synchronized你只能有一个等待队列满了等空了等。生产者需要通知消费者“有货了”消费者需要通知生产者“有空位了”光靠一个队列容易产生“通知错对象”的问题。用 Condition 则清晰得多ReentrantLock lock new ReentrantLock(); Condition notEmpty lock.newCondition(); Condition notFull lock.newCondition(); // 生产者 lock.lock(); try { while (队列满) { notFull.await(); } 入队(); notEmpty.signal(); } finally { lock.unlock(); } // 消费者 lock.lock(); try { while (队列空) { notEmpty.await(); } 出队(); notFull.signal(); } finally { lock.unlock(); }这里有个非常重要的细节await()会释放锁signal()并不会立刻释放当前线程的锁而是等待当前线程执行完unlock()之后被唤醒的线程才能真正获取锁。很多人第一次写 Condition 时以为signal()就像解锁一样结果下一个线程仍然进不来实际上是await()之后还在unlock()前。有人问为什么await()一定要放在 while 循环里而不是 if因为线程被唤醒后条件可能已经被其他线程改变。比如两个消费者同时被唤醒但队列里只有一个元素其中一个消费者会把空队列也消费掉。用 while 是为了在醒来后再次检查条件这是并发编程里的“虚假唤醒”防护也是标准写法。4.5 读写锁与 StampedLockReentrantReadWriteLock把锁拆成读锁和写锁读锁是共享锁写锁是排他锁。多个线程可以同时持有读锁但写锁一旦被持有读操作也要等待。它用读写锁的机制来解决“读多写少”场景下的性能瓶颈。实现上ReentrantReadWriteLock用一个 32 位 state 同时记录读锁和写锁数量高 16 位表示读锁数量低 16 位表示写锁的重入次数。读锁是共享的每个线程持有读锁时高 16 位加 1写锁是独占的只有拿到写锁的线程能修改低 16 位。ReentrantReadWriteLock有个地方比较坑锁降级。写线程可以降级为读锁但读线程不能升级为写锁因为两个读线程都持有读锁时升级会导致互相等待形成死锁。后来 Java 8 引入了StampedLock它支持乐观读。乐观读不真正加锁只是获取一个版本戳然后去做读操作操作完检查版本戳有没有变化没变化说明没有写操作发生读有效有变化再升级成一个真正的读锁或重新读。StampedLock在读多写少且读操作耗时的场景下能提升吞吐量但它是基于饥饿问题的且不支持重入使用时需要格外小心。我建议大多数业务场景优先从ReentrantReadWriteLock用起压测确认不够了再考虑StampedLock。5. 锁选型与实战避坑5.1 选锁的四个判断维度很多人习惯“一把锁走天下”不管什么场景不是 synchronized 就是 ReentrantLock。其实选锁没有绝对标准核心看四个维度竞争激烈程度、临界区耗时、是否要求公平、是否需要中断/超时。我的习惯是这样判断场景推荐方案原因单线程或低竞争、短临界区synchronizedJVM 内置锁升级成本低代码简单不会手动释放出错竞争中等、临界区较长ReentrantLock可超时、可中断能避免无限期等待强顺序要求ReentrantLock(true) 公平锁避免饥饿保证请求顺序读多写少ReentrantReadWriteLock 或 StampedLock读共享提升读吞吐简单计数、状态标记AtomicInteger / AtomicBoolean无锁并发开销最小再补一句synchronized在现代 JDK 上性能并不差它和ReentrantLock的差距在大多数业务场景里可以忽略。真正决定性能的通常不是锁本身而是锁的粒度和持锁时间。能把临界区缩短到几行比换一个锁实现更有效。5.2 死锁案例与 jstack 排查死锁是锁机制最典型的坑。看下面这个最经典的死锁场景Object a new Object(); Object b new Object(); // 线程1 synchronized (a) { Thread.sleep(100); synchronized (b) { } } // 线程2 synchronized (b) { Thread.sleep(100); synchronized (a) { } }线程 1 拿到 a 等待 b线程 2 拿到 b 等待 a两边都在等对方释放锁谁也等不到。这种死锁用眼睛不一定看得出来尤其是嵌套锁跨了好几个方法。排查时最好的工具是先jps拿到进程 PID然后执行jstack -l pid看输出的最后一段。JVM 会直接打印出Found one Java-level deadlock并且指出两条线程各自的堆栈和等待的锁对象。如果是容器环境jstack不方便可以用jcmd pid Thread.print效果类似。更高阶的操作是用 Arthas 连上去执行thread -b一键找出阻塞其他线程的“罪魁祸首”。我自己的习惯是先看 CPU 占用是否异常再用jstack抓三份线程栈快照对比有没有线程状态卡在blocked且一直不变。排查出死锁后修复方向一般是两个一是让多个线程以固定的全局顺序获取锁比如总是先拿 a 再拿 b破坏“循环等待”二是用tryLock加超时拿不到就回滚释放避免无限期等下去。第二招在业务代码里更实用因为它不要求理清所有嵌套锁的顺序。5.3 性能压测synchronized 与 ReentrantLock 的实测数据聊锁避不开性能我拿一个简单压测场景说下实测体感8 个线程并发执行 1000 万次递增操作每次操作只做一个count然后直接返回。分别用synchronized和ReentrantLock跑出来的结果是JDK 8 下synchronized的吞吐量其实和ReentrantLock非常接近甚至在某些短临界区场景还略有优势。这个结论和十年前的“synchronized 性能差”完全不同因为 JVM 已经把偏向锁、轻量级锁、锁消除都叠上去了。但一旦将临界区变长比如在锁内执行一段 50 微秒的模拟 IO两者的差距就开始显现ReentrantLock在高竞争下能配合LockSupport做更精细的线程调度synchronized膨胀到重量级锁后系统调用开销占比大。所以网上很多“ReentrantLock 性能一定更好”的结论都有前置条件。做压测时还要注意结果会受 JVM 版本、CPU 核数、操作系统的线程调度影响。别拿 JDK 8 的结果去套 JDK 17也别把单机结果推到集群。最终判断还是得看自己业务的采样 Profile。5.4 锁使用的十个注意点最后把我踩过和见过别人踩的坑集中列一下每条都值得记下来锁对象不能为 null。synchronized (null)直接抛 NullPointerException代码里连编译都不报。不要锁 String 字面量或 Integer 缓存值。字符串常量池会让两个不同逻辑模块意外共享同一把锁。Lock 的unlock()永远放 finally。只有这样才能保证异常路径下锁被释放。lock.lock()不要放进 try 里。如果放在 try 里获取锁失败后 finally 里的unlock()会因为没有锁而抛出 IllegalMonitorStateException。缩小锁粒度。锁一个局部变量而不是锁整个方法一个对象一个锁别多个资源共用一把“大锁”。不要锁this和Class对象的同时还依赖其他锁。多个锁嵌套是死锁温床。volatile不保证原子性。它只解决可见性不能替代AtomicInteger或锁。读多写少不要放心用写锁。写锁一旦拿住读锁全被挡在外面吞吐量瞬间下降。Condition.await()必须放在 while 循环里防止虚假唤醒后条件不满足。尽量不要用锁去“保护”外部调用比如锁内做 RPC、数据库查询。锁的持锁时间越长竞争放大越明显系统越容易雪崩。这些点看起来零碎但线上问题十有八九就出在其中某一条。尤其第 3、4 条我见过一个支付系统因为lock.lock()放进了 try 里异常后锁没释放一堆线程卡死最后还是靠重启才恢复。把锁当成稀缺资源来管理出错率会低很多。最后再分享一个我个人的处理习惯每次写完一段同步代码都会主动问一遍“这个锁有没有可能被反着拿”“如果这个锁拿不到会怎样”。多问这两句能提前发现一大堆死锁和无效等待隐患。Java 锁机制并不复杂复杂的是你在真实业务里如何克制使用它。