Java并发编程锁与同步深度解析:AQS、volatile与JMM底层原理 读《Java 并发编程的艺术》读到第五辑时明显感觉到门槛上了一个台阶。前四辑还在讲线程基础、volatile、synchronized这些能用“大概懂了”带过去的知识到了这一辑开始频繁出现AQS、Condition、LockSupport、内存屏障、锁升级这些字眼——如果你正在准备Java面试或者维护一个线上高并发服务这些概念迟早会找上门。这一辑书摘我就围绕“锁与同步的底层实现”这条主线展开把书里最有价值的部分拆开揉碎加入我在实际项目中验证过、踩过坑的理解当作本辑的阅读笔记。内容更适合已经写过线程代码、但想往“懂原理”迈一步的读者。1. 为什么所有并发问题都能归结到这三个“性”1.1 原子性i 为什么被打上“非线程安全”的标签书里在讲并发问题根源时开篇就抛出一个论断并发编程的bug追根溯源都能归到原子性、可见性、有序性这三个“性”上。我最初是不太信这句话的觉得太玄了。直到自己排查过一个线上计数错误的问题才发现这三个词不是理论术语而是定位问题的索引目录。先看原子性。很多人知道 i 不是原子操作但不知道为什么不原子。用 javap 反编译一段 i 代码你会看到它被拆成几条字节码指令读 i、把 i 加 1、写回 i。哪怕加锁成本高、性能差也必须加因为你不知道操作系统调度器什么时候会切走线程——可能刚好切走两条指令之间。JMMJava内存模型本身保证了变量读写和锁操作的原子性但对复合操作没有直接保护这就是 i 需要加锁或者改用 AtomicInteger 的根因。书中给了一个特别透彻的类比原子性就像“一步到位”的操作要么全部发生要么什么也没发生。转账场景最典型扣款成功、入账失败两边不一致就是原子性被打破。日常开发里最常见的原子性问题不是复杂事务而是“先查后写”的竞态——两个线程同时查到库存为1同时减一结果库存变成0而不是预想中的“超卖拦截”。1.2 可见性线程A改了值线程B为什么还是看到旧的可见性问题在“多核缓存”的体系里几乎是必然发生的。CPU为了提速每个核心都有自己的缓存层级线程操作变量时先读缓存缓存没有才去主内存。如果线程A在自己的缓存里改了变量没有立刻刷回主内存线程B读到的就是旧值。《Java 并发编程的艺术》在讲JMM时定义了一个关键术语每个线程有自己的工作内存线程不能直接操作主内存只能通过工作内存与主内存交互。这句话翻译成大白话就是一个线程修改后的值什么时候让其他线程可见并不是立刻的而是由内存模型的行为决定的。我真实遇到过的一个坑是一个后台线程跑循环检查服务状态状态变量被另一个管理线程改掉循环线程却一直读旧值导致任务切不过来。当时的第一反应是“加个 volatile 试试”结果真的好了。这就是 volatile 的核心作用让你对变量的写入在写完之后立刻对其他线程可见。注意它解决的是可见性和有序性但不解决复合操作的原子性这是后话。1.3 有序性指令重排带来的“反直觉”隐患有序性这层最反直觉。你写的代码顺序是 a1; b2;CPU或者编译器实际执行时却可能变成 b2; a1。为了流水线效率指令重排是合理的优化但并发环境下重排会导致另一个线程观察到“不符合代码顺序”的结果。书中用的是经典的“双重检查锁单例”问题来说明这一点。如果单例对象的字段先发生写入再发布实例引用另一个线程可能看到“引用非空”但内部字段还是默认值。解决方式就是给 instance 字段加 volatile利用 volatile 的禁止重排语义保证实例构造完成后才允许引用被看到。你应该已经发现这三个“性”并不是各自独立的它们常常叠加在一起。定位并发问题时我会习惯性地把每个现象往这三个框里先放一遍如果是一个变量读写不一致多半是可见性如果是一段代码执行顺序不对多半是有序性如果是复合操作结果错误多半是原子性。定位工具是 jmap、jstack、甚至压测复现但这些定位的前提是心中先有这三个框。2. AQS 才是并发工具类共同的答案2.1 从“面试常问”到“终于看懂”的那个临界点说实话AQSAbstractQueuedSynchronizer这个概念我在面试前背过好几次但每次都是“面试完就忘”。直到这一辑书读进去我才意识到以前没懂是因为不知道它在解决什么问题。AQS其实就是一把“排队锁的通用模板”。它内部维护了两样核心东西一个 volatile int state一个 FIFO 的等待队列CLH队列。线程抢锁本质是在抢 state 的值抢不到就进入队列排队前一个线程释放锁则唤醒队列里下一个线程。书里把AQS的设计思路归结为“模板方法模式”——AQS把“如何管理状态、如何排队、如何唤醒”这些通用逻辑都封装好了使用者只需要实现少量方法比如 tryAcquire、tryRelease告诉AQS“什么样的state算持有锁”“释放时怎么改state”。ReentrantLock、Semaphore、CountDownLatch、ReentrantReadWriteLock全是套着这个模板做的。2.2 通过 ReentrantLock 看懂 acquire 与 release 的调用链我们看 ReentrantLock 的非公平锁实现。调用 lock() 时它先尝试用 CAS 把 state 从 0 改成 1成功就说明拿到锁失败则走进 acquire 流程这个流程包含“再试一次 进入等待队列 循环检查是否轮到自 己 阻塞/挂起”几步。如果你仔细对着 AQS 源码看会发现 acquire 里做了很多细节处理中断响应、状态回溯、超时控制等。release 就相对简单tryRelease 把 state 减减如果为 0说明锁完全释放AQS 唤醒等待队列的头结点线程。这里有个容易忽略的点无论锁重入多少次release 必须与 acquire 配对如果多 release 一次state 会变成负数直接抛 IllegalMonitorStateException。我用 ReentrantLock 时踩过这个坑当时是因为 try-finally 写错finally 里对同一个锁 unlock 了两次。2.3 基于AQS手写一个“限流同步器”胜过背十遍源码光看不练确实容易忘。我照着书里的思路写了个极简的限流工具允许最多 N 个线程同时获取“许可”超过的排队等。核心就是继承 AQS重写 tryAcquire 和 tryReleasepublic class SimpleLimiter extends AbstractQueuedSynchronizer { private final int maxPermits; public SimpleLimiter(int permits) { this.maxPermits permits; setState(permits); } Override protected boolean tryAcquire(int arg) { for (;;) { int state getState(); if (state 0) { return false; // 没有许可了 } if (compareAndSetState(state, state - 1)) { return true; } } } Override protected boolean tryRelease(int arg) { for (;;) { int state getState(); if (state maxPermits) { throw new IllegalStateException(release 超出上限); } if (compareAndSetState(state, state 1)) { return true; } } } }这个工具能跑通说明我对 state 状态机和 CAS 循环已经基本理解了。写完后我再看 Semaphore 的源码几乎就是同一套逻辑acquire 减 staterelease 加 state。这就是 AQS 的魅力——它把并发类工具统一成了“一套状态管理 一套排队策略”。读这一辑时我建议你也按这个路径走先把AQS当黑盒跑通再打开源码一行行对照比直接读源码轻松得多。3. volatile、final 与那六条 happens-before 规则3.1 volatile 的两层语义很多人只记住了第一层volatile 大概是并发编程里被误解最多的关键字。很多人只知道它是“解决可见性”但它真正干了两件事一是写 volatile 变量的操作会直接刷新到主内存读 volatile 变量会从主内存重新读取二是通过“内存屏障”禁止指令重排让 volatile 变量前后的读写顺序不被打破。第二点常常是 bug 的真正来源。我在 1.3 节提到的双重检查锁单例本质就是依赖第二层语义。如果只把 volatile 理解为“读最新值”遇到“发布半初始化的对象”这种 bug你会彻底懵掉。书里对 volatile 的适用边界写得很清楚它适合一个线程写、多个线程读的场景不适合“多个线程同时写一个 volatile 变量”的复合操作场景。如果多个线程对同一个 volatile 变量做自增结果一定错误。因为自增是“读-改-写”三步volatile 管得了“读到的值是最新的”管不了“改和写之间的原子性”。这种情况要么加锁要么换成 AtomicLong 这种带 CAS 的原子类。3.2 final 安全发布Java 给“不可变对象”的暗中保护final 与并发的关系是这一辑让我觉得最惊喜的部分。以前我以为 final 只是“编译期不可修改”没想到它在 JMM 里有专门的语义只要对象构造时没有发生 this 逃逸即构造过程中没有把 this 泄露出去那么其他线程看到该对象的 final 字段时不需要同步手段就能保证看到正确的初始值。而且final 字段在构造后的读写还有一些重排限制JMM 对 final 的具体规则比普通字段严格得多。这套设计的目的是为了让不可变对象天然线程安全。像 String、Integer 这些类它们的字段是 final 的所以才能被多个线程无锁共享。造不可变对象时的关键约束是不要在构造函数里把这个对象发布出去比如在构造函数里启动线程、把 this 存到某个全局集合都会让“安全发布”失效。3.3 happens-before 才是连接所有关键字的暗线书里有一个贯穿全章的规则集——happens-before。它定义了一组“前一个操作的结果对后一个操作可见”的规则。前一张操作要先于后一张操作发生后一张操作才能看到前一张操作的效果。这套规则不需要你背但要会用。书里列出的六条规则我用表格整理过Markdown 存下来当工具卡规则简单理解程序次序规则同一个线程内代码顺序在前写的对顺序在后的可见管程锁定规则synchronized 加锁解锁先加锁的线程操作对后加锁的线程可见volatile 变量规则先写 volatile 的线程它对后读的线程可见线程启动规则Thread.start() 之前的操作对线程内的代码可见线程终止规则线程内所有操作对 calling join() 返回后的线程可见传递性A happens-before BB happens-before C则 A happens-before C这六条规则看起来抽象实际是排查并发问题的“推理引擎”。比如“线程A先start线程B再执行某些校验”你能用规则推出A的修改对B一定可见就可以减少不必要加锁。同样“调用了 join 之后”你才能确定线程的执行结果被主线程看到否则就不能指望。满足 happens-before 关系的条件本质上就是“足够的同步手段”。所以读这一辑时别把这些关键字当孤立的点它们共同构成了 JMM 对“什么情况下一定能看到什么”的承诺。搞懂这套承诺你的并发编码就能从“碰运气”过渡到“有依据”。4. synchronized 锁升级从偏向锁到重量级锁的完整链条4.1 偏向锁到重量级锁一套比其他语言更聪明的锁策略Java 的 synchronized 在 JDK 6 之后做过一波大优化引入了锁升级机制无锁态 → 偏向锁 → 轻量级锁 → 重量级锁。它背后的动机很实际大部分锁在运行时只有单一线程反复获取如果每次获取都走真正的 OS 互斥锁代价太高。于是 JVM 采取保守策略逐步“升级”锁的开销。这一辑书里给每个锁状态都写了触发条件偏向锁只有一个线程访问同步块时锁偏向于该线程无需每次做 CAS轻量级锁出现“第二个线程来竞争”时偏向锁撤销变成自旋等待的轻量级锁重量级锁竞争再进一步激烈、自旋也不能快速获得锁时线程挂起进入 OS 管程等待队列。注意两个细节。第一偏向锁有撤销成本撤销时会触发 safe point所以高并发场景下偏向锁非但没有帮助反而带来额外开销JVM 提供了-XX:-UseBiasedLocking关闭参数很多高并发系统是直接关掉的。第二轻量级锁的自旋是消费 CPU 的如果临界区代码执行时间很长自旋等于空转。4.2 锁状态在对象头里的记录方式为什么“锁升级”这个机制听起来像魔法因为它其实是写在对象头里的。Java 对象在内存中除了实例字段还有对象头信息其中 Mark Word 记录锁状态、线程ID、锁记录指针等信息。每次锁状态变化Mark Word 都会修改对应位。设计成“把锁信息放进对象头”而不是“全局一张锁表”是为了避免额外占用空间、减少访问开销。从这个角度看synchronized 的“锁”并不是独立存在于堆外的一个管程对象而是与 Java 对象深度融合的一种状态。这也就解释了为什么 synchronized 用的锁必须是“某个对象”的引用——它的底层信息就存放在对象头里。了解这点后再看 synchronized 锁类对象锁和锁实例对象锁的差异心里就有数了。4.3 日常编码中该选 synchronized 还是 Lock很多人纠结 synchronized 和 Lock 怎么选。我的经验是功能简单、临界区短、性能要求不极端时直接用 synchronized。为什么因为 synchronized 不需要手动释放锁永远不会有“忘解锁”的风险而且 JVM 对它的优化一直在推进锁升级机制对单线程重复获取非常友好。而 Lock 的优势场景在书中也讲得很清楚需要可中断的锁等待、需要超时获取锁、需要公平锁队列、需要多个 Condition 条件队列时。比如业务里一个线程等待“消息到来”“队列非空”“连接可用”等多个条件用 Condition 比 synchronized 的 wait/notify 灵活太多。还有一个实操细节使用 Lock 时锁释放放在 finally 块里并且加锁和解锁必须严格配套这是一条“必须用肌肉记忆绑定”的规范。5. 第五辑合上书之后我留在代码里的三条改动5.1 用 CountDownLatch 统一控制多线程“同时起跑”读完 AQS 和并发工具类那一章后我回到项目里最明显的一个改动是用 CountDownLatch 代替“循环 sleep 标志位”的并发测试启动方式。以前想压一个线程池经常会写出“启动 N 个线程sleep 1 秒让它们都起来然后一起跑”这种糊涂逻辑。有了 CountDownLatch思路就清晰了主线程 await工作线程 countDown 表示“我已就绪”等 N 个线程全部就绪后主线程放行所有任务真正同一瞬间开始。这类工具的底层都是 AQS 的共享锁状态管理所以知道 AQS 之后你理解 CountDownLatch 的实现就是一句话的事await 是等待 state 归零countDown 是让 state 减一。另一个容易踩的坑是“忘记 countDown”导致 await 永远阻塞。保险做法是加超时参数await(30, TimeUnit.SECONDS)以及把 countDown 放进 finally。书里虽没直接这么说但这正是把原理落到工程里时要补的生命周期管理意识。5.2 高并发下的计数优化从 AtomicLong 到 LongAdder这一辑在讲原子类时用专门篇幅解释了 LongAdder 与 AtomicLong 的区别。AtomicLong 靠 CAS 并发控制线程多、竞争激烈时CAS 失败概率大会导致大量重试LongAdder 则把单一热点拆成多个 Cell每个线程落到不同的 Cell 上累计最后汇总用空间换时间。我的直观感受是用 LongAdder 统计单次请求耗时分布、接口调用量在压测场景下吞吐提升非常明显。但它也有“最后汇总慢”的特点不适合需要“立即精确值”的场景。两者怎么选书里给了公式我的理解是并发低、需要实时一致值用 AtomicLong并发高、只在乎最终统计用 LongAdder。5.3 读写锁场景把 ReentrantReadWriteLock 换成读写分离策略最后一个改动有点意思。项目里有份缓存配置读多写极少。最开始用的是 ReentrantLock读完这一辑后我认真分析了这个场景改成 ReentrantReadWriteLock读操作之间无互斥写操作独占。理论上性能应该提高但实测小数据量下效果不明显原因在于临界区非常短锁竞争成本本身很低读写锁反而要维护读锁计数、写锁等待等更多状态。后来我把这个缓存彻底改成“CopyOnWrite volatile 引用”的方案写时复制新对象整体替换 volatile 引用读时直接取值无锁。这其实是对 volatile 安全发布、final 不可变思想的综合应用。这里想说一个从书中提炼的工程观点锁不是越多越好选锁的首要因素是临界区大小和读写比例嵌套锁和激进优化往往比简单粗暴的互斥锁更容易出错。回看第五辑最值钱的不是某一个类的源码而是它把我脑子里那些零散的并发关键词串成了一条线JMM 定义了“什么可见”synchronized 和 Lock 提供了“怎么互斥”AQS 是这些同步工具的共同骨架并发工具类则是这条骨架上的成品。读完之后再写并发代码我至少知道每一行依赖的是什么承诺、风险在哪里了。