所有 Java 并发工具,本质上都在解决有限计算资源在多执行主体之间的分配与协调问题。
竞态源于三条件并存——状态被共享、状态可变、访问无序并发。消解任一条即安全:不共享、不可变、或给并发访问强加秩序(协调)。
该问题空间按来源分为三层,层次越靠上越接近并发的本体:
| 层次 | 问题域 | 核心命题 |
|---|---|---|
| 本体问题(任何并发模型固有) | 竞争安全 | 共享状态的变更必须不可分割地完成;本质冲突:写-写、读-写 |
| 本体问题(任何并发模型固有) | 协作同步 | 多个线程需要在时间或阶段上达成一致——不是竞争,而是协作 |
| 基底问题(共享内存模型引入) | 可见性与有序性 | 一个线程的修改,何时、以何种顺序被其他线程观察到;源于 JMM,消息传递模型中由通信本身承载 |
| 工程派生(资源有限性) | 准入控制 | 控制同时访问者数量;互斥(N=1)与限流(N>1)是同一问题的两个参数点 |
| 工程派生(线程模型 + 隐式参数) | 上下文隔离与传播 | 并发执行下,如何安全地保存、传递线程上下文 |
| 工程派生(任务生命周期) | 取消与关闭 | 执行中的任务如何可被安全地停止,而非强制终结 |
本文档讨论的同步工具,均可映射回上述问题域之一或其组合;其构建呈四层结构:
并发问题空间(三层六域,见上节)
↓
并发抽象模型 ─┬─ 准入控制器:互斥锁(N=1)、信号量(N>1)
├─ 协作同步器:门闩、栅栏、会合点
└─ 上下文容器:ThreadLocal 系(落地为线程内私有 Map,不经由以下两层)
↓(准入与协作两支)
构建路径 ─┬─ 直接扩展 AQS:ReentrantLock / Semaphore / CountDownLatch
├─ 组合 AQS 产物:CyclicBarrier(Lock + Condition + 分代)、ArrayBlockingQueue
└─ 绕开框架:StampedLock / Phaser / Exchanger
↓
公共原语层:CAS + LockSupport.park/unpark
两个横切域内嵌于所有工具:
| 层 | 稳定性 | 原因 |
|---|---|---|
| 问题空间 | 极稳定 | 并发的本体与基底问题,跨语言不变 |
| 抽象模型 | 稳定 | 准入 / 协作 / 上下文是跨平台通用抽象 |
| 构建路径 | 最易变 | JDK 内部实现:AQS 随版本多次调整(Unsafe→VarHandle、节点结构重写),工具仍在增补(StampedLock 为 JDK 8 新增) |
| 公共原语 | 极稳定 | 直接映射硬件原子指令与 OS 调度原语,数十年不变 |
问题驱动:每个阻塞型同步器都需要同一套基础设施——原子状态、等待队列、阻塞/唤醒,而无锁等待队列的实现难度远高于工具自身的语义。AQS 把这套共性下沉为统一框架,JUC 主要同步工具因此共享同一个“内核”。
问题域覆盖:state 的原子变更求解竞争安全;准入判定与排队求解准入控制;共享模式与条件队列承载协作同步;中断契约横切取消与关闭——三域一横切,对应构建分层图的"直接扩展"路径。
AQS(AbstractQueuedSynchronizer)并不是“锁”,而是:
同步器不变部分的统一框架:状态托管、排队、阻塞/唤醒由框架固化,准入语义由子类填入
两条原则构成其哲学:
state(volatile int)→ 资源状态机:准入判定的依据,语义由子类定义
同步队列(CLH 变体) → 安置竞争失败线程:排队、阻塞(park)、按序唤醒(unpark)
条件队列 → 安置协作型等待:await 入队,signal 转移至同步队列重新竞争
同一骨架按唤醒分发分两种模式,排队与阻塞完全复用:
| 模式 | 准入判定 | 唤醒分发 | 典型工具 |
|---|---|---|---|
| 独占 | 成 / 败 | 单点唤醒 | ReentrantLock、写锁 |
| 共享 | 返回剩余量 | 按余量传播唤醒 | 读锁、Semaphore、CountDownLatch |
state 只是一个数字,语义由子类解释(重入计数 / 许可数 / 倒数计数)——这是众多工具能共享同一框架的直接原因。
剥离 Java 语境,任何阻塞型同步设施(OS 等待队列、Linux futex、分布式锁)都必须回答三个问题:
| 必答问题 | 设计空间 | AQS 的取舍 | 取舍理由 |
|---|---|---|---|
| 谁能通过(准入判定) | 状态如何表达、如何原子变更 | volatile state + CAS,判定规则下放子类 | 框架不预设资源语义,换取通用性 |
| 没通过的怎么办(等待安置) | 自旋(耗 CPU、零唤醒延迟)↔ 阻塞(让出 CPU、有唤醒延迟) | 短暂自旋后 park 的混合策略 | 临界区时长未知,取两端折中 |
| 等待者何时再试(唤醒分发) | 广播惊群 ↔ 精准定点 | FIFO 队列 + 前驱唤醒后继 | 惊群浪费调度;排队天然回答"下一个是谁" |
这组取舍与 Linux futex 同构:快路径用原子指令,慢路径才进等待队列。AQS 可视为该通用同步架构在用户态、库层面的实例化。
AQS 面临一个根本约束:锁的实现内部不能再用锁(否则无限递归),等待队列本身必须是无锁结构(CAS + volatile):
AQS 的抽象范式是 acquire/release——一切能表达为"获取 / 释放某种数量的资源"的同步语义。工具与该范式的匹配度,决定了它的构建层次:
| 构建层次 | 方式 | 代表 | 因果 |
|---|---|---|---|
| 直接扩展 | 内部 Sync 类继承 AQS | ReentrantLock、ReadWriteLock、Semaphore、CountDownLatch | 语义可归约为 acquire/release |
| 组合复用 | 用 AQS 的产物拼装 | CyclicBarrier(= Lock + Condition + 分代)、ArrayBlockingQueue | 需要的是"锁内协作",不必发明新同步器 |
| 绕开框架 | 直接基于 CAS + park | StampedLock(乐观读验证)、Phaser(动态阶段推进)、Exchanger(成对会合) | 语义无法映射到 acquire/release 排队范式 |
归约一:所有同步器都是 acquire/release 双操作的具名化。 JUC 刻意不定义统一接口,名字服务于领域语义,契约完全同源:
| 工具 | acquire 形态 | release 形态 |
|---|---|---|
| Lock | lock() | unlock() |
| Semaphore | acquire() | release() |
| CountDownLatch | await() | countDown() |
| FutureTask | get() | 任务完成 |
归约二:每个阻塞型 acquire 都遵循同一组变体契约——本质是把失败处理的预算交给调用方:
| 变体 | 语义 | 调用方的预算 |
|---|---|---|
| acquire(阻塞) | 等到为止 | 无预算约束 |
| tryAcquire | 立即返回成败 | 快速失败 / 降级路径 |
| tryAcquire(timeout) | 有界等待 | 明确的延迟预算 |
| acquireInterruptibly | 等待中可被取消 | 外部取消权 |
工具 API 中超出该契约的方法,才是它真正的增量语义(如 StampedLock 的 validate、Phaser 的 register)
问题域定位:互斥锁是竞争安全的排他解,同时是准入控制的 N=1 特例。Java 提供同一抽象的两个实现:JVM 内建的 synchronized 与 JDK 库层的 ReentrantLock。
互斥锁的抽象骨架与实现基底无关:持有者 + 重入计数 + 等待队列。两个实现是该骨架在不同层的实例:
| 骨架要素 | synchronized(ObjectMonitor,JVM/C++) | ReentrantLock(AQS,JDK/Java) |
|---|---|---|
| 持有者 + 重入计数 | owner + recursions | exclusiveOwnerThread + state |
| 同步队列 | EntryList | CLH 队列 |
| 条件队列 | WaitSet(单个) | ConditionObject(可多个) |
骨架之外,设计通则同样跨基底成立:
同一骨架与同一组通则在 JVM 层与 JDK 层各实现一次:骨架与通则属于概念稳定层,基底只是工程选址——再次印证构建分层的 U 形结论。
演进注脚:偏向锁曾为“单线程反复加锁”场景连 CAS 也省去,JDK 15 起废弃——多核竞争成为常态后,偏向撤销的成本反超收益。优化的生命周期取决于其环境假设的存续。
两个实现的全部 API 差异,都是一个根本选择的投影——锁是语法块,还是普通对象:
| 维度 | synchronized(语言结构) | ReentrantLock(库对象) |
|---|---|---|
| 锁释放 | 块退出自动释放(含异常路径) | 手动,依赖 try/finally 纪律 |
| acquire 变体 | 仅无限阻塞一种形态 | 完整四变体(阻塞 / try / 限时 / 可中断) |
| 条件等待集 | 单等待集(wait/notify) | 多 Condition |
| 公平性 | 不可配置(非公平) | 可配置 |
| 可优化性 | 锁边界对编译器可见:锁消除、锁粗化 | JIT 视角是普通方法调用,无语义级优化 |
因果链:语法块 → 锁边界对编译器与 JVM 可见 → 释放可自动、优化可施加,但表达力被语法封顶(无法跨作用域、无法定制策略);普通对象 → 表达力完整(变体、多条件队列、跨方法持锁、策略可配),但正确性退回为调用方纪律。
这组"安全换表达力"的交换在工程中反复出现:GC vs 手动内存管理、声明式事务 vs 编程式事务——语言/框架接管得越多,越安全也越不自由。
synchronized 无公平选项(固定非公平),公平策略是 ReentrantLock 在骨架之上唯一的语义增量;机制上只是准入判定时是否检查队列前驱,一行判断分出两种价值取向:
| 策略 | 价值取向 | 代价 |
|---|---|---|
| 公平锁 | 顺序正义 | 吞吐下降 |
| 非公平锁 | 系统效率 | 局部饥饿 |
公平性不是技术问题,而是系统价值判断。
非公平锁吞吐更高的机制因果:锁释放瞬间,新到线程可直接 CAS 抢锁,省去"唤醒队首 → 调度延迟 → 锁空闲窗口"的代价。公平性买的是顺序,付出的是上下文切换;故 ReentrantLock 默认非公平,公平模式只用于顺序敏感场景(如连接池分配)。
能用 synchronized,就不要用 Lock。
优先 synchronized 的理由源于"锁是语法块"一侧的收益:自动释放消灭一类泄漏错误、JVM 可持续优化、不依赖手动纪律。升级到 Lock 的触发条件:需要 acquire 变体(试、限时、可中断)、多条件等待集、公平性,或非块结构持锁(跨方法 / 跨作用域)——任一命中即为"不能用 synchronized"。
问题域定位:仍属竞争安全 + 准入控制,但准入规则从计数细化为按操作类型的兼容性矩阵。问题空间已指明本质冲突只有写-写、读-写——读-读本无冲突,互斥锁的 N=1 准入把它也串行化了;读写分离即把准入规则对齐到真实冲突结构:
| 兼容性 | 读 | 写 |
|---|---|---|
| 读 | 共享 | 互斥 |
| 写 | 互斥 | 互斥 |
这是从“锁竞争”向“冲突检测”的范式迁移,与数据库乐观并发控制(version 字段提交校验)、CAS 重试同构:版本号 + 事后校验替代进入时排他。
| 场景 | 选择 | 原因 |
|---|---|---|
| 需要重入或条件等待 | ReentrantReadWriteLock | StampedLock 缺这两项语义 |
| 读临界区短、状态可拷贝为局部变量 | StampedLock 乐观读 | 读零持有,写不被阻塞 |
| 写占比高 | 退回互斥锁 | 兼容性矩阵的收益与读占比成正比,写多时分离开销倒挂 |
问题域定位:本章工具求解协作同步域——多个线程在时间或阶段上达成一致。它们与准入域工具共享 acquire/release 契约,分野在 state 的语义:不再表示资源量,而表示进度——await 不消耗配额,条件满足时全体放行(共享模式的"按余量传播"在此退化为广播)。等待的是事件,不是资源。
竞争域的失败是隔离的——一个线程获取锁失败不影响他人;协作域的失败是连带的——互等结构中个体缺席必然波及集体。两个工具代表两种处理策略:
| 策略 | 工具 | 行为 | 工程含义 |
|---|---|---|---|
| 不检测,责任交使用方 | CountDownLatch | 计数缺位则等待者悬挂 | 必须 finally 中 countDown,或 await 带超时兜底 |
| 检测并传播 | CyclicBarrier | 一人中断/超时即 broken,全组抛 BrokenBarrierException | 框架接管失败一致性,使用方处理集体重试 |
| 维度 | CountDownLatch | CyclicBarrier |
|---|---|---|
| 对称性 | 等待者与计数者分离 | 参与者互等 |
| 可复用 | 否(state 单调至终态) | 是(分代重置) |
| 失败语义 | 无内建检测 | broken 传播全组 |
| 构建路径 | 直接扩展 AQS(共享模式) | 组合复用(Lock + Condition + 分代) |
| 硬限制 | 受限工具 | Phaser 的解除方式 |
|---|---|---|
| 一次性 | CountDownLatch | 多阶段推进:phase 递增,天然循环 |
| 参与方固定 | CyclicBarrier | register / arriveAndDeregister 动态注册 |
代价:语义无法映射到 acquire/release 排队范式,只能绕开 AQS 自建(见能力边界)——灵活性的获得以失去框架托管为交换。
选型判据:等待者是否参与计数——外部观察者等内部事件完成 → CountDownLatch;对等参与者互等 → CyclicBarrier;多轮迭代 → CyclicBarrier;参与方动态增减 → Phaser。协作三件套的第三件"会合点"(Exchanger,成对数据交换)见后文。
问题域定位:互斥锁是准入控制的 N=1 特例,Semaphore 是其一般形式——以 N 份许可控制同时访问者数量。构建上经 AQS 共享模式直接扩展:state 即许可数,acquire 减、release 加,按余量传播唤醒。
锁与信号量的机制分界在所有权:
| 维度 | 锁 | Semaphore |
|---|---|---|
| 持有者 | 有(owner 记录) | 无(只有计数) |
| 重入 | 可定义(身份绑定计数) | 无此概念 |
| 释放者约束 | 仅持有者可释放 | 任意线程可释放,甚至先释放后获取 |
边界警告:问题层面"互斥 = 准入 N=1",但工具层面 Semaphore(1) ≠ 互斥锁——差异即所有权。无所有权是双刃:跨线程释放成为可能(A 线程获取、B 线程归还,异步与管道场景的刚需),误释放也无任何防护。问题层等价、工具层不等价,再次印证"问题 ≠ 解法"。
增量语义(超出 acquire/release 契约的部分):批量许可 acquire(n) / release(n)、跨线程释放、许可数动态调整(reducePermits)。公平性选项与互斥锁同框架,但价值取向不同——限流场景下公平性防的是饥饿而非顺序。
失败模式:忘记 release 导致许可泄漏,比锁泄漏更隐蔽——锁泄漏立即死锁,是响亮失败;许可泄漏是池子慢性萎缩,无声衰减。无所有权使框架无法代为检查,release 必须落在 finally。
问题域定位:协作同步域的第三件工具——两方对齐 + 双向数据交换,会合与通信合一,即 rendezvous 语义。
与栅栏的区分:CyclicBarrier 是 N 方对齐、不带数据;Exchanger 是 2 方会合、各自携带数据并交换。协作章的对称性框架延续:双方互为计数者与等待者。
横向同构:Go 无缓冲 channel 的收发、Ada 的 rendezvous——"会合即通信"是跨语言的稳定模式;Exchanger 是 Java 中最接近同步 channel 的原语。
问题域定位:构建分层的最底层。一切阻塞型工具最终落到两个原语——CAS(原子状态变更,由硬件原子指令支撑,见基础概念)与 park/unpark(线程阻塞 / 唤醒,本章)。
park/unpark 围绕二值许可(0/1,不累积)工作:unpark 发放许可,park 消耗许可,有许可则立即返回。因此 unpark 可先于 park 到达而不丢失——"检查条件 → 阻塞"之间被唤醒的竞态窗口被许可吸收。这是它取代 wait/notify 成为底层原语的根本原因。
| 约束 | wait/notify | park/unpark |
|---|---|---|
| 锁依赖 | 必须先持有监视器锁 | 无需任何锁 |
| 时序依赖 | notify 早于 wait 即丢失 | unpark 先行被许可记住 |
| 唤醒目标 | 无法指定线程 | 精准定点:unpark(thread) |
park 返回有三种原因:unpark、中断、虚假返回——且 park 不报告原因,调用方必须循环重检条件。AQS 自举约束中的中断契约("不响应但不吞掉")正是建立在这一契约之上。
每个线程挂一个 Parker(互斥量 + 条件变量,Linux 上经 futex)——U 形表"公共原语直接映射 OS 调度原语"的具体落点:park/unpark 是 OS 阻塞原语的 JVM 封装,CAS 是硬件 cmpxchg 的封装。上层全部工具的阻塞语义到此为止,再往下即内核调度。
问题域定位:到此,文档第一次踏出"协调"主线。前面每件工具都承认共享、管理争用;ThreadLocal 走另一条根本路径——不共享:以"每线程一份副本"取消共享这个前提,争用从源头不存在。这正是它在构建分层图中唯一"不经由 CAS+park 原语层"的原因——无争用可仲裁,便不需要原子原语。本质是以空间换免同步。
ThreadLocal 解决隐式参数传递——无需在调用链层层透传的上下文(事务、用户身份、traceId),挂在"当前线程"这个隐式坐标上。
关键设计:ThreadLocalMap 是 Thread 的字段(threadLocals),不是 ThreadLocal 的字段,以 ThreadLocal 实例为 key、值为 value。一个推论值千金——线程访问的永远是自己 Thread 上的 map,天然零竞争,免同步由此而来。
map-on-Thread 这一选择同时派生隔离收益与两个风险,三者同源:
| 后果 | 机制 |
|---|---|
| 线程隔离(收益) | 每线程读写各自 Thread.threadLocals,无共享、无锁 |
| 脏数据(风险) | map 随线程而非任务存活 → 线程池复用线程时残留不清,下个任务读到上个任务的值 |
| 内存泄漏(风险) | Entry 的 key 弱引用 ThreadLocal、value 强引用;ThreadLocal 被回收后 key=null,value 仍被池线程经 Entry 强引用 → 陈旧 Entry 无法释放 |
两个风险的根都是"map 绑在长生命周期的线程上"。对策同源:用完即 remove()——线程池场景必须在 finally 中清理(set/get 仅机会性清理部分 null-key 槽,不可依赖)。
ThreadLocal 的副本严格绑定单线程,跨线程即断。两次演进都在扩张"传递边界",各自付出代价:
| 工具 | 扩张的边界 | 机制 | 局限/代价 |
|---|---|---|---|
| ThreadLocal | 单线程内 | map 挂当前线程 | 跨线程断裂 |
| InheritableThreadLocal | 父→子线程 | 子线程构造时 childValue() 从父拷贝 | 池线程预创建复用,捕获不到"提交时"上下文,失效 |
| TransmittableThreadLocal | 跨线程池 | 提交时捕获快照,执行前 replay、执行后 restore | 需装饰 Runnable/Callable 或线程池 |
TTL 的要害是时机分离:捕获在任务提交时(父线程上下文尚在),应用在任务执行前(已是池线程)。InheritableThreadLocal 把捕获绑死在线程创建时,而池线程创建早于任务提交,故必然漏掉提交时刻的上下文——这才是它在线程池失效的精确原因,而非笼统的"无法适配"。
隐式传递省去层层透参,代价是数据流不可见:依赖被隐藏(损可测试性)、来源不显(损可读性)、跨层耦合。故 ThreadLocal 是"必要的恶"——框架内部用(事务管理、MDC 日志、SecurityContext),业务代码慎用,能显式传参则优先显式。
横向同构:ThreadLocal 是"每线程的隐式环境变量",与 Go
context.Context、KotlinCoroutineContext同解一题,分歧在隐式(绑线程)vs 显式(随调用传递)。时间维度的趋势——虚拟线程下"每线程一副本"成本随线程数爆炸,JDK 以 ScopedValue(不可变、作用域绑定)替代之:隐式上下文正从线程绑定走向作用域绑定。
工具会朽,方法不朽。下面先给不随 JDK 变的决策路径,再给随之派生的工具表。
| 问题域 | 默认(最简) | 出现以下增量需求时 | 升级 / 选用 |
|---|---|---|---|
| 竞争安全 · 临界区互斥(准入 N=1) | synchronized | 试锁 / 限时 / 可中断、多条件队列、公平、跨方法持锁 | ReentrantLock |
| 竞争安全 · 单变量原子 | AtomicXxx(CAS) | 多变量需一致 | 退回锁 |
| 竞争安全 · 读多写少 | ReentrantReadWriteLock | 读极短且状态可拷贝 → 乐观读;需重入 / Condition → 留 RRWL;写占比高 → 退回互斥 | StampedLock |
| 准入控制 N>1(配额 / 限流) | Semaphore | 进程外限流 | 令牌桶(见流量控制) |
| 协作同步 | CountDownLatch(等事件完成) | 需复用 / 对等互等 → 栅栏;动态参与方 / 多阶段 → Phaser;成对换数据 → Exchanger | CyclicBarrier / Phaser / Exchanger |
| 上下文隔离与传播(不共享支线) | ThreadLocal | 父→子线程 → Inheritable;跨线程池 → 快照回放 | InheritableThreadLocal / TTL |
判据是"等待者是否参与计数""读临界区是否够短可乐观""上下文要跨多远"——是可执行的条件,不是"安全""高效"这类形容词。
表下注(横切两域,不单独选工具):