type
status
date
slug
summary
tags
category
icon
password
4 章核心脉络速览
主题 | 关键问题 | 实战关注点 |
对象组合 | 如何把多个已经线程安全的组件“拼”成更大、更复杂的单元? | 尽量重用已经验证过的线程安全类;组合时要保持封装,避免把内部细节暴露给并发环境 |
线程安全类设计三步曲 | 1. 明确状态变量2. 写出对这些变量成立的不变式3. 为状态并发访问拟定同步策略 | 把这三步写进类级注释或外部设计文档,方便后续维护者快速判断“还能不能安全修改” |
4 .1 设计线程安全的类
目标:让维护者 只分析这一类本身 就能确信它是线程安全的,而不必把全部程序装进脑子里。
1. 找出状态变量(state variables)
- 从字段(field)下手
- 原始类型字段 → 状态就是它们的 n 元组
- 例:
Point(x, y)
的状态是(x, y)
- 引用字段 → 状态“递归地”包含被引用对象的字段
- 例:
LinkedList
的状态 = 链表节点集合的状态之和
- 不要遗漏静态可变字段(它们属于“类状态”)
🎯 实践要领:在代码注释里给出“本类状态一览表”,以后别人加字段时就知道必须同步地更新文档。
2. 明确不变式(invariant)
- 定义:任意时刻都必须成立的布尔表达式
- 常见不变式示例
- 计数器
value
∈ [0, Long.MAX_VALUE] - 点对象
distance = sqrt(x² + y²)
(如果这是类的公开不变式)
- 用途:
- 指导“哪些修改必须是原子的”
- 让单元测试能写出“事后断言”验证并发正确性
3. 制定并发访问策略(synchronization policy)
- 主体内容
- 哪些变量 → 由哪把锁 保护
- 哪些方法需要同步、哪些可并行
- 是否借助线程封闭或不可变对象简化同步
- 写法建议
- Java 注解 + 注释并用
- 在类级 Javadoc 中再补一句:“
value
受this
内部监视器保护;所有复合操作必须先获取该锁。”
代码范例拆解——Counter
设计元素 | 解释 |
状态变量 | value |
不变式 | 0 ≤ value ≤ Long.MAX_VALUE |
同步策略 | 采用“Java 监视器模式”:进入任何公有方法前先获得实例锁 this ;保证复合操作 (check ⇒ increment) 的原子性 |
⚠️ 如果以后有人打算加一个 reset() 方法,就必须继续遵守“先拿 this 锁再改 value”这一约定,否则会破坏不变式。
小结与实践建议
- 封装就是并发范围控制器
- 把可变状态藏在私有字段中,对外只暴露经过同步的访问入口。
- 文档是同步策略的“法律条文”
- 没有文档,维护者只能猜测:这是线程封闭?可重入锁?条件变量?——猜错就埋雷。
- 组合优先于继承(在并发语境下尤甚)
- 继承往往暴露内部实现,子类容易破坏父类不变式;组合可在外层重新制定独立的同步策略。
接下来如果你继续贴出第四章的后续内容(如“委托、封闭容器、自定义同步器”等),我会按同样节奏帮你把关键动机、使用时机与典型代码模式梳理出来。
4 .1 .1 收集同步需求 —— 把“应该同时发生”和“绝不能发生”都写清楚
这一小节的精髓:先把业务语义里对“状态合法性”与“状态跃迁”的要求全部列出来,再决定要不要、以及如何加锁或用其它并发手段。只有这样,后面的同步策略才不会遗漏。
1. 先画出“状态空间”与“非法区”
术语 | 说明 | 为啥重要 |
状态空间(state space) | 对象所有字段在理论上可能取到的笛卡尔积 | 确定“潜在”风险:空间越大,需要排除非法状态的可能性越多 |
不变性条件(invariant) | 系统在任何可见时刻都必须满足的布尔表达式 | 违反它=线程安全 Bug |
后验条件(post-condition) | 某个操作做完以后 额外 必须满足的条件 | 指出“状态跃迁”合法性的约束 |
⚡ 关键点:多用final
和不可变类型可以缩小状态空间 —— 极限就是“只存在一个合法状态”,那么并发就简单到不需要同步(完全不可变)。
2. 举例拆解
(1) Counter
再进阶
- 状态空间初始:
Long.MIN_VALUE … Long.MAX_VALUE
- 不变式:
value ≥ 0
- 后验条件:
increment()
必须把状态从 n 变到 n + 1 - ⇒
increment()
必须是复合原子操作(读-改-写不可拆)
(2) NumberRange
——典型“一对相关变量”问题
坏版本(演示用):
- 不变式跨越两个字段:
lower ≤ upper
- 如果两个 setter 各自加锁但用 不同 的锁,就会出现:
- 线程 A
setLower(5)
,线程 BsetUpper(4)
↔ 中间时刻存在(lower=5, upper=4)
—— 数据瞬间非法 - 线程 C
isInRange(4)
读到非法状态,返回错误
同步需求提炼
需求 | 代码层面的对策 |
任何时刻都满足 lower ≤ upper | 两个字段 必须由同一把锁保护,或改成不可变对一起替换 |
setLower / setUpper 更新时要一次性完成 | (a)使用 synchronized 且锁对象相同(b)使用 AtomicReference<IntPair> 整体替换 |
改进示范(简化写法):
3. 步骤化“收集同步需求”
- 列字段 → 标注可变 / 不可变 /
final
- 写不变式 → 看看跨了几个字段
- 写每个公有方法的后验条件 → 判断是否依赖“旧值”
- 对照 1-3 → 推导“哪些操作必须原子”,“哪些字段要捆绑”
- 折中考量
- 若无不变式约束 & 无旧值依赖 → 可以放宽同步,提高吞吐量
- 否则坚持封装 + 原子性,哪怕牺牲一点性能
4. 高阶提示
- “先封装,后并发”:先把可能非法的状态对外屏蔽,再讨论锁;否则易漏。
- 读多写少?考虑更细粒度锁或
StampedLock
/rw-lock
。
- 组合而非继承:子类往往不可见父类的不变式,最容易破坏原有同步假设。
- 文档先行:把上述 1-3 步写在 Javadoc @implNote 里,维护者读一次就能知道安全边界。
4 .1 .2 依赖状态的操作(State-dependent Operations)
一句话抓重点当一个方法只有在“对象当前状态符合某个前提”时才能执行,它就是“依赖状态”的——在并发环境下不能直接失败,而应该等到前提变真,然后再继续。
1. 先验条件 vs. 不变式 / 后验条件
概念 | 触发时机 | 例子 | 同步难点 |
不变式 | 任何时刻都必须成立 | lower ≤ upper (NumberRange) | 违反即 bug |
后验条件 | 某操作完成后必须成立 | increment() 把 n → n + 1 | 需原子地实施 |
先验条件(pre-condition) | 操作开始前需要满足 | 队列非空才能 dequeue() 库存足够才能 reserve(n) | 可能暂时不成立;要等待 |
2. 为什么“等一等”而不是“立刻失败”?
- 单线程:先验条件假了 → 没人会改状态 → 直接抛异常
- 并发:其他线程可能很快让条件变真
- 如果直接失败 = 忙失败 → 不必要的重试/浪费
- 正确做法:阻塞或挂起,直到条件满足再继续
3. 实现依赖状态的三条路线
路线 | 何时用 | 关键 API | 优缺点 |
现成阻塞数据结构 | 队列、栈、映射… | java.util.concurrent 中的 BlockingQueue , LinkedBlockingDeque , SynchronousQueue | 最简单;自带容量/空满条件的等待逻辑 |
同步工具类 | 计数/许可/栅栏… | Semaphore , CountDownLatch , CyclicBarrier , Phaser | 适合需要“资源槽”或“阶段”语义的先验条件 |
显式条件队列 | 定制化复杂条件 | Lock + Condition (或 wait /notifyAll ) | 灵活但易错;14 章专门讲 |
💡 建议:除非真有非常规需求,优先用库,不要手写 wait/notify。
4. 典型代码速览
4.1 队列非空才能取 —— 用 BlockingQueue
take()
内部已封装先验条件:队列非空。
- 不需要显式加锁或
wait/notify
,也避免忙轮询。
4.2 资源不足就等 —— 用 Semaphore
- 先验条件:当前可用连接数 > 0。
Semaphore
的acquire()
/release()
已帮你实现等待与通知。
4.3 复杂条件示例(简版示意)
如果条件是“缓存里至少有 K 条目且系统在活跃模式”,可能同时依赖多个字段,这时:
- 需要在
while
循环里重新检查条件(防止虚假唤醒)。
- 这个模式易踩坑(忘记循环、搞错锁、只
signal
不signalAll
等),因此更推荐把需求改写成能用现成工具类描述的形式。
5. 小结 - 设计 checklist
- 列出现有/新增方法的先验条件。
- 问:如果条件暂时为假,该方法该怎么做?
- 放弃→简单返回/异常
- 等待→进入“依赖状态”范畴
- 优先选用:
BlockingQueue
/TransferQueue
/DelayQueue
…Semaphore
,CountDownLatch
,CyclicBarrier
,Phaser
- 只有在这些工具都无法表达你的条件时,再考虑
Condition
或wait/notify
,并遵循: - 始终在
while
循环里等待 - 保护条件变量的那把锁必须一致
- 优先
signalAll()
,避免遗漏等待者
🔮 预告:第 5 章 会系统介绍这些阻塞容器和同步原语的用法;第 14 章 再深入到Condition
、wait/notify
及显式锁实现细节。继续阅读时,可把本节当作“需求收集指南”。
4 .1 .3 状态的所有权(Ownership)——“谁”真正控制这份数据?
核心问题在一张“从 this 可达的对象图”里,哪些字段算“我的状态”? —— 只有我拥有(own)的那部分。所有权决定了封装边界与加锁策略:谁拥有,就由谁来保障线程安全。
1 | 为什么只说“子集”?
- 对象图:从根对象出发,可达的所有节点
- 对象状态:只取图中的自有数据
HashMap
拥有其Entry
节点 ⇒ 算状态- 如果把外部传进来的
DataSource
放在字段里,但不拥有它(只是引用) ⇒ 不属于本对象状态
判定标准
问题 | “是” ⇒ 属于状态 | “否” ⇒ 只是一条指针 |
对象逻辑上由当前类创建/维护吗? | ✔ | ✘ |
该数据的并发一致性由当前类负责吗? | ✔ | ✘ |
释放内部引用后,调用方就无法再直接操作它吗? | ✔ | ✘ |
2 | 所有权 × 封装 × 同步 —— 三位一体
设计元素 | 关系 |
封装 | 把“拥有的数据”隐藏在私有字段里,对外仅通过受控方法暴露 |
所有权 | 决定能否 独占 修改权;若泄露引用只能变成“共享所有权” |
同步策略 | 谁拥有谁上锁(或保证不可变 / 线程封闭) |
典型坑:泄露可变引用
- 一旦别人拿到
list
的引用,原类就失去独占所有权 → 原有锁策略失效 → 数据竞争
防御手段
- 返回不可变视图
- 只暴露只读接口(
Iterable
而非List
)
- 深度拷贝 / 防御性拷贝:在构造函数接收外部集合时先
new ArrayList<>(arg)
,把所有权转移进来。
3 | 传入对象:默认“不属于我”
来源 | 默认所有权 | 线程安全对策 |
构造器参数 | 调用方 → 我方仅持引用 | 若要长期保存:① 复制得到私有实例;② 明文说明“调用方不得再改”;③ 或假定对象本身线程安全 |
普通方法参数 | 同上(短期使用) | 通常只在方法栈里使用完即弃;无需同步 |
专门的“工厂/封装”方法(如 Collections.synchronizedMap ) | 通过文档契约把所有权转移给被创建对象 | 调用者不得再直接访问原实例 |
4 | 共享所有权:只能用协商同步
- 当多个组件都需要直接修改同一份数据时 → 本类无法单独保证不变量
- 解决方案
- 让数据对象自身线程安全(如
ConcurrentHashMap
) - 共同使用同一把显式锁(锁对象向所有参与者公开)
- 消息传递 / 不变数据:通过复制和交换事件,避免共享可变数据
5 | Servlet Session 示例说明
Web 容器可能在“会话复制”或“钝化/passivation”阶段序列化 HttpSession 里的对象;此时容器线程与应用线程并发访问同一数据。
- 含义:
HttpSession
拥有其属性 Map;却把属性对象的引用共享给容器线程- ⇒ 属性对象本身必须是线程安全的/不可变的,或者应用明确提供同步委托
- 实战建议
- 存入 Session 的对象最好设计为 可序列化且不可变
- 若属性需要可变,使用并发容器或自行管理锁
- 避免把 JPA/Lazy-loaded 实体等非线程安全对象直接塞进 Session
6 | 设计-检查清单
- 列出字段 → 标记“拥有/非拥有”
- 对“拥有 + 可变”的数据:
- 私有化 → 拒绝外部引用泄露
- 制定并记录锁规则 (
@GuardedBy
) 或使用不可变封装
- 对“非拥有”的可变数据:
- 仅短期使用;或复制;或要求其自身线程安全
- 公有 API 里不要返回内部可变对象;若必须返回,给“只读视图”或深拷贝
- 若必须共享所有权:文档里声明同步协议,或用并发容器
下一节若继续探讨“委托(Delegation)、线程封闭(Thread Confinement)、组合模式”等,请继续贴原文;我会沿用概念提炼 → 代码示例 → 实战指引的结构帮你梳理。
Ownership(状态所有权)是什么?
一句话:谁对某块可变数据拥有 独占 决策权,就谁负责在并发环境下保护它的完整性与可见性。
1 | 为什么要强调所有权
场景 | 没有清晰所有权时会怎样? |
线程安全 | 多个对象“以为”自己掌控同一份数据 → 锁策略各自为政 → 数据竞争或死锁 |
封装性 | 外部直接改内部字段,类无法维护不变式 |
可维护性 | 维护者看不出“到底谁应该同步”,轻易打破既有假设 |
2 | 三类常见所有权模型
模型 | Java落地方式 | 线程安全保证思路 |
独占所有权(Exclusive) | 私有字段 + 不泄露可变引用 | 类内部自己加锁/确保不可变 |
共享所有权(Shared) | 把对象引用暴露给多个组件 | 必须协商:同用一把锁,或对象本身线程安全 ( ConcurrentHashMap ) |
转移所有权(Transfer) | 通过方法/队列把引用交出去,自己不再使用 | 通常用阻塞队列、Future等“一手交钱一手交货”的机制;完成即解除旧锁责任 |
📌 思维辅助——可对比 C++/Rust:
unique_ptr
≈ Java 的独占所有权;
shared_ptr
/Arc
≈ 共享所有权;
- Rust 的
move
语义、消息传递(channel)≈ 转移所有权。
3 | 在 Java 中识别 & 记录所有权
- 字段注解
- Javadoc
@implNote
/@param ownsXxx true if ownership is transferred
- 工厂方法命名:
ofOwned
,wrapShared
等提示调用方约定。
- 包 / 访问级别:把“仅供内部线程使用”的类放到私有包或用包可见(不 public)。
4 | 设计技巧与示例
4.1 独占所有权:封装 + 私有锁
4.2 避免引用泄露导致失去所有权
修复方式
- 返回 不可变视图:
Collections.unmodifiableList(list)
- 深拷贝:
new ArrayList<>(list)
- 只暴露 只读接口:
Iterable<String> items()
4.3 共享所有权:让被共享对象自带并发行为
若使用普通 HashMap,就必须约定“外界也用同一把 lock”。
4.4 转移所有权:阻塞队列/消息通道
- 阻塞队列天然实现“先验条件:队列非空”与“显式交接权”。
5 | 与其他并发策略的关系
策略 | 与所有权的耦合点 |
线程封闭 (Thread Confinement) | 把数据“独占”给单线程 → 问题简化;所有权明确为该线程 |
不可变对象 | 把状态“冻结” → 没有写权,就无需谈锁;所有权退化为“只读共享” |
Actor / Event-Loop | “每个 Actor 只改自己的状态” → 把所有权绑定到消息循环线程 |
6 | 排雷清单
构造器参数是外部传入的可变对象?→ 拷贝再保存
返回值是否直接泄露内部可变结构?
把同一对象字段暴露给多线程时,是否明确锁协议或采用并发容器?
文档里是否写明“谁负责同步”?没有就默认调用方 不能 并发更改。
需要序列化/钝化(如
HttpSession
)?→ 属性对象本身得线程安全或不可变。7 | 小结
- Ownership = 控制权 = 锁权责。确认谁拥有数据,就能确定谁加锁、谁维护不变式。
- 独占 → 私有 & 封装,共享 → 协商同步或并发容器,转移 → 明确交接机制。
- 把所有权信息写进代码注释与 API 设计中,才能防止后续修改者无意间破坏线程安全。
4.2 实例封闭
一、概念与动机
- 什么是实例封闭?
- 也称“实例级封闭”(Instance Confinement),指将一个本身非线程安全的对象,限制在单个线程或受控的访问路径中使用,从而无需修改该对象也能实现线程安全。
- 本质是在使用端,对对象的所有访问都加以约束——要么只在一个线程里访问(线程封闭),要么通过同一个锁来保护(锁封闭)。
- 为什么要实例封闭?
- 简化线程安全实现:不用对原始类做任何假设或改造,直接在“外部”通过封闭策略保证安全。
- 灵活的加锁策略:可以根据不同状态变量或访问场景,选用不同的锁来保护不同对象。
二、实现手段与典型示例
1. 单对象加锁(锁封闭)
示例代码(PersonSet)
mySet
是一个非线程安全的HashSet
,但由于它被私有化且所有对它的访问都必须持有PersonSet.this
锁,因而PersonSet
对外表现为线程安全类。
- 对
Person
本身的线程安全不做假设——如果Person
可变,取出后访问仍需额外同步。
2. 线程封闭
- 将对象仅在单个线程内部创建和使用(典型如方法内的局部变量或只在线程内部流转),自然线程安全,无需任何锁。
3. 装饰者模式(Decorator)
- Java 标准库对常用容器(如
ArrayList
、HashMap
)提供了“同步包装”:
- 通过“装饰者”将非线程安全容器封闭到一个持有唯一引用的同步代理里,代理中每个方法都加锁,从而对外表现线程安全。
三、封闭的作用域与注意事项
封闭方式 | 作用域示例 | 优点 | 风险 |
私有成员封闭 | 对象作为类的私有字段 | 易实现、便于分析 | 如果对象引用泄漏,封闭失效 |
局部变量封闭 | 方法内部创建并仅在线程内传递 | 最简洁、零锁开销 | 作用域小,不适用于长生命周期 |
线程级封闭 | 仅在同一线程中创建/传递 | 无需加锁 | 必须严格保证不跨线程使用 |
装饰器封闭 | Collections.synchronized* 系列包装器 | 直接可用、开箱即用 | 同步代价、可能死锁 |
- 作用域不能超出既定范围:只要封闭对象的引用被意外发布(例如存入全局集合、内部类泄漏等),原有的线程安全假设就会失效。
四、与其他锁策略的结合
- 多锁保护:在大型系统中,不同状态变量可以用不同锁来保护。实例封闭为每个受保护对象提供了灵活的“最小粒度锁”。
- 与 ServerStatus 等示例对比:后续章节的
ServerStatus
会演示如何给一个类的多个可变状态分别使用不同锁(而不是简单地给整个对象加一把大锁)。
五、小结
- 实例封闭是构建线程安全类的最简单方式之一,对象内部状态通过私有化 + 统一访问路径得到有效保护。
- 既可以结合加锁(锁封闭),也可以依赖线程封闭机制,或使用装饰者模式对第三方库进行封闭。
- 关键在于:绝不让非线程安全对象的引用在未经保护的情况下流出。
4.2.1 Java 监视器模式(Monitor Pattern)
1. 模式概述
- 核心思想:将对象的所有可变状态封装在对象自身,并由该对象的内置锁(monitor)来保护。
- 实现机制:在 Java 中,每个对象自带一把监视器锁,进入
synchronized
方法/块时由 JVM 字节码指令monitorenter
加锁,退出时由monitorexit
解锁。
2. 典型用法
2.1 内置锁(this
)
- 优点:最简洁 —— 只需在方法前加
synchronized
。
- 缺点:锁对象(
this
)是公有的,可能被外部误用。
2.2 私有锁对象
- 优点:锁完全私有,外部无法干预,便于分析和维护。
- 缺点:代码稍微冗长,需要显式声明和使用锁对象。
3. 性能 & 可维护性对比
特性 | 内置锁( this ) | 私有锁对象 |
代码复杂度 | ★☆☆(最简单) | ★★☆(需声明锁字段) |
锁的可见性 | 公有,可能被外部代码意外获取 | 私有,仅类内部可见 |
安全性分析范围 | 必须全局审查是否被误用 | 只需审查本类内部 |
活性/死锁风险 | 外部也能加锁,易引入活性问题 | 客户端无法干预,风险更低 |
伸缩性/细粒度 | 粗粒度锁,可能成为性能瓶颈 | 可按状态拆分多把私有锁 |
4. 何时选用
- 快速加锁:类内部状态较少,对一把大锁的性能开销可接受。
- 最低样板:希望以最少的代码实现线程安全(如 JDK 的
Vector
、Hashtable
)。
- 后续优化:将来如需更精细的并发控制,可再引入读写锁、分段锁等。
5. 下一步拓展
- 可视化时序图:展示
monitorenter
/monitorexit
在方法执行中的加解锁流程。
- 性能对比实验:监视器模式 vs. 实例封闭(锁封闭)在高并发场景下的吞吐对比。
- ServerStatus 示例:演示如何用多把私有锁分别保护不同状态字段,以提升并发度。
4.2.2 示例:基于监视器模式的“车辆追踪器”(Vehicle Tracker)
1. 背景与需求
- 应用场景:MVC GUI 程序中,一个视图线程(只读)和多个更新线程(写入)并发访问/修改“出租车、警车、货车”等车辆的位置数据。
- 设计目标:
- 对外提供获取所有车辆位置的快照。
- 提供按 ID 获取单个车辆位置的方法。
- 提供更新某辆车位置的接口。
- 保证在多线程环境下线程安全。
2. 核心代码(程序清单 4-4)
3. 线程安全策略
- 监视器锁
MonitorVehicleTracker
的所有关键方法都被synchronized
修饰,使用自身监视器锁保护内部状态locations
。- 外部无法直接访问或修改
locations
或其元素。
- 深拷贝快照
- 构造时、
getLocations()
时均做全量深拷贝,并返回 不可修改的映射视图。 getLocation(id)
也返回一个新的MutablePoint
拷贝,避免暴露内部可变对象。
4. 深拷贝策略的权衡
特性 | 优点 | 缺点 |
一致性 | 外部拿到的快照在整个调用期间内保持不变,内部状态不会被并发写修改影响 | 更新后要查看最新位置,必须重新调用快照方法 |
安全性 | 避免了对内部 MutablePoint 对象的直接修改 | 无需额外锁; MutablePoint 本身可变但未被发布 |
性能 | 对少量车辆或低频调用足够;深拷贝逻辑集中、易于理解 | 车辆数量大或调用频繁时,复制开销显著;复制期间持有锁会阻塞写操作 |
响应延迟 | 视图线程拿到快照迅速渲染 | 在 deepCopy 运行期间,更新线程必须等待 |
5. 缺点与优化方向
- 性能瓶颈
deepCopy
在大数据量时耗时,且在synchronized
块内执行,影响写操作的并发性。- 改进:可改用 读写锁(
ReentrantReadWriteLock
)——读时并发,写时独占;或用 基于并发容器(ConcurrentHashMap<String, Point>
)配合不可变坐标对象(ImmutablePoint
)来避免全量复制。
- 数据新鲜度
- 快照模型保证了一致性,但调用者若需“实时”位置,则需要更高的刷新频率或直接读取内部数据。
- 改进:
- 提供单车 可变视图(但需额外同步);
- 或者用发布/订阅机制,更新时主动推送给视图线程。
- 封装性放宽
下一节(4.2.3)将演示如何放松封装(比如直接公开线程安全的
Map
),同时仍保持线程安全——例如返回一个实时视图而非快照,或在不复制 Map
键集合的前提下依然保证安全。小结:
- 本示例展示了如何用最简单的监视器模式+深拷贝策略,构造一个线程安全且易于理解的数据模型;
- 同时也凸显了“安全 vs. 性能 & 时效性”的典型权衡。
- 后续章节将探讨更高性能/更灵活的设计。
4.3 线程安全性的委托(Delegating Thread Safety)
1. 背景:组合已有线程安全组件
在设计一个新类时,如果它内部所用的所有组件已经是线程安全的,是否还需要给新类再加一层锁?答案——“视情况而定”。
2. 委托模式的要点
- 状态唯一且无额外约束:如果新类的可变状态完全对应于某个已线程安全的组件,且不需要在组件之上再施加复合不变式(比如同时更新多个组件保持一致性),那么新类可以把“线程安全”委托给那个组件。
- 无需额外加锁:不必用
synchronized
或私有锁包装,只要不打破组件本身的语义,就能保持线程安全。
3. 典型示例:CountingFactorizer
- 分析:
count
是唯一的可变状态,由AtomicLong
管理。CountingFactorizer
不在count
之外维护任何额外不变式,也不做复合操作(例如“先读再写”)。- 因此,它的线程安全性完全依赖于
AtomicLong
。
4. 何时“组合即线程安全”?
情况 | 可否直接委托 |
组件本身线程安全,且类对组件状态无额外不变式,不做跨组件复合操作 | ✅ 直接组合,无需额外锁 |
组件间存在不变式(如同时更新两个组件的状态必须保持一致) | ❌ 需要在类层面增加锁或其他同步手段,保证原子性 |
5. 小结与后续
- 委托线程安全 是最轻量的并发策略:把并发控制责任交给成熟、经过验证的组件。
- 局限:只适用于“状态单一且无跨组件约束”的场景;否则需要额外同步。
- 后续章节(如程序清单4-10)会给出“组件都是线程安全的,但组合后仍需自己加锁”的典型反例,帮助你理解何时必须介入更高层次的同步控制。
4.3.1 示例:基于委托的车辆追踪器
1. 不可变坐标类(Point)
- 线程安全性:由于所有字段都是
final
并在构造时初始化,Point
本身即为线程安全,可自由共享与发布,无需复制。
2. 委托给 ConcurrentHashMap
的追踪器
- 无任何显式同步:所有并发安全性由
ConcurrentHashMap
与不可变Point
保证。
- 封装性:
unmodifiableMap
防止外部直接修改 Map 结构;Point
不可变,防止修改坐标。
3. 实时视图 vs. 快照视图
特性 | 基于监视器模式(MonitorVehicleTracker) | 基于委托模式(DelegatingVehicleTracker) |
返回的数据 | 快照(snapshot) | 实时视图(实时反映后续更新) |
视图的一致性 | 一致性强,不随后续写操作改变 | 随后续写操作更新,有“可见性延迟”或“实时一致性” |
性能开销 | 深拷贝 + 同步锁,调用频繁或数据量大时成本高 | 只做替换操作,无全量复制,性能更优 |
适用场景 | 需要对一组数据保持内部一致性 | 需要及时反映最新位置,且接受可能的“视图不一致” |
4. 提供快照的浅拷贝方案
如果既想要“实时性能”又想要“一致快照”,可以在返回时做一次浅拷贝(复制 Map 结构,不复制
Point
):- 由于
Point
是不可变类型,复制 Map 结构即可保证读者拿到的“快照”不随后续写操作而改变。
5. 小结
- 委托策略:将并发控制完全交给成熟的线程安全组件(
ConcurrentHashMap
+ 不可变对象),无需在外层再加锁。
- 行为差异:与监视器模式(快照)相比,委托模式可实现更高的并发吞吐和更低的延迟,但返回的是“实时变化”视图。
- 选择依据:如果需要最新数据且可容忍视图抖动,优先使用委托模式;否则在委托模式上再做一次浅拷贝,平衡一致性与性能。
4.3.2 将线程安全性委托给独立的状态变量
一、核心思想
- 只要组件之间没有不变性约束(invariant)需要同时保持,就可以各自为政,将线程安全委托给它们各自的线程安全实现。
- 这种做法比给整个类加一把大锁更加灵活,也无需任何额外的同步开销。
二、典型示例:VisualComponent
- 数据结构选择:
CopyOnWriteArrayList
- 读操作无需加锁,遍历时可放心读旧快照;
- 写操作(增删监听器)会复制底层数组,适合“写少、读多”的监听器场景。
- 委托缘由:
keyListeners
与mouseListeners
之间互不影响,不存在“同时修改两个列表必须保持一致”的额外不变式;- 每个列表自身已保证线程安全,
VisualComponent
无需再加锁。
三、优势与适用场景
特性 | 说明 |
零锁开销 | 没有 synchronized 或显示锁对象,读写都依赖组件自身机制。 |
高并发性能 | 读操作(事件分发)无需阻塞,写操作(增删监听器)在低频场景下成本可接受。 |
设计简洁 | 不用关心全局锁的持有与释放,只需组合线程安全组件。 |
易于扩展 | 新增另一种监听器类型,只需再声明一个线程安全的集合,无需修改现有同步策略。 |
适用场景
- 组件内部的多个可变子状态之间无复合不变式;
- 子状态都可以独立、安全地并发访问与修改;
- 读多写少、或对写性能要求不高。
四、何时不适用
- 如果两个或多个状态变量必须一起修改才能保持一致性(例如:同时在
keyListeners
和mouseListeners
中添加/删除某个“复合监听器”),就需要引入额外的同步机制(如synchronized
、Lock
或事务式更新)来保证跨组件的原子性。
4.3.3 当“委托”失效时
1. 背景:跨变量不变式(Invariant)
- NumberRange 类通过两个独立的
AtomicInteger
管理“下界”(lower)和“上界”(upper)。
- 不变式要求:始终满足
lower ≤ upper
。
- 虽然单个
AtomicInteger
是线程安全的,但二者组合后产生了“跨变量不变式”,单纯委托给这两个原子变量并不能保证这个不变式。
2. 问题代码(错误示例)
- 竞态场景:
- 线程 A 调用
setLower(5)
时,读到upper = 10
,检查通过; - 与此同时,线程 B 调用
setUpper(4)
时,读到lower = 0
,检查也通过; - 最终,
lower = 5
且upper = 4
,违反了不变式。
3. 失效原因分析
原因类别 | 说明 |
非原子性检查-执行 | “先检查后执行”跨越了两个独立的原子操作,缺乏整体原子性。 |
跨变量依赖 | lower 与 upper 之间存在依赖关系,单独保证每个变量的线程安全并不能保证二者组合的正确性。 |
4. 解决策略
4.1 使用同一把锁保护复合操作
- 原理:
- 将读-改-写的整个检查-执行序列包裹在一个
synchronized
块中,保证操作的原子性和可见性。 - 只要所有访问都使用同一把锁,
lower ≤ upper
不变式就始终成立。
4.2 使用显式 ReentrantLock
- 优点:可中断锁获取、可尝试锁、可公平性配置。
5. 何时需要介入“额外同步”
场景 | 做法 |
跨组件/跨变量不变式 | 在外层类增加锁或其他同步机制 |
复合检查-执行 | 把检查与执行包裹在同一同步上下文中 |
需要一致快照或原子更新多个状态 | 使用大锁、读写锁、事务式更新机制 |
6. 小结
- AtomicXxx 或其他线程安全组件并不能解决所有并发场景——只有在“状态相互独立”时,才可放心委托。
- 跨变量依赖 会破坏单一组件的线程安全性,必须由外层类提供自己的并发控制。
- 下次遇到“先检查后执行”模式,务必确认是否需要整体原子性——若需要,立刻引入合适的锁机制。
提示:如果你想了解更高级的模式(如使用事务内存或乐观 & 悲观锁组合)来维护复杂不变式,或演示“读-写锁下的并发性能对比”,请告诉我!
4.3.4 发布底层的状态变量(Publishing Underlying State Variables)
当把线程安全性委托给底层状态变量时,只有在不会破坏类所维护的不变性(invariant)时,才可以公开或发布这些变量,让外部直接访问或修改。
1. 发布的必要条件
- 线程安全性由该变量自身保证
- 变量类型本身必须是线程安全的(例如
ConcurrentHashMap
、CopyOnWriteArrayList
、不可变对象、原子类型等)。
- 无跨变量不变性约束
- 类中不再对该变量与其他状态变量之间施加任何不变性条件。
- 外部对它的任何修改都不会影响其他状态,也不会走入非法状态。
- 不存在“不允许的状态转换”
- 该变量的任何合法操作(增、删、改)都被类逻辑所接受,无需额外检查或补偿。
2. 典型对比示例
场景 | 是否可发布 | 原因 |
Counter.value ( AtomicLong ) | ❌ | 类约定 value ≥ 0 ,且只有“递增”操作;外部若直接写入负值,会破坏不变性。 |
当前温度(TemperatureSensor) | ✔️ | 温度可为任意值,且类对其值域没有额外约束;外部修改不会破坏不变性。 |
VisualComponent.listeners | ✔️ | CopyOnWriteArrayList 本身线程安全;不同监听器列表互不依赖,无额外不变式。 |
3. 实践建议
- 尽量保持封装
即便“可以发布”,为了未来演进和子类化的灵活性,仍建议通过访问方法而非直接公开字段。
- 文档清晰
如果决定发布某个线程安全字段,务必在类注释中明确说明该字段的线程安全特性和不变性保证。
- 审查不变性
发布前,仔细检查类中所有逻辑,确保对该变量的所有使用场景都不依赖其与其他状态的一致性。
小结:发布底层状态变量是一种权衡——在满足“单一且独立状态”条件下,可以安全地公开它们以简化设计;但在任何“跨状态不变性”存在时,都必须避免发布,或者引入额外的同步机制来维护不变性。
4.3.5 示例:发布状态的车辆追踪器
1. 目标与背景
- 前面我们讨论了两种追踪器设计:
- MonitorVehicleTracker:用监视器锁+深拷贝,返回一致的快照;
- DelegatingVehicleTracker:委托给
ConcurrentHashMap
+不可变Point
,返回实时视图。
- 这里引入第三种:PublishingVehicleTracker,其核心思想是
- 发布(expose)底层可变且线程安全的状态对象;
- 保证线程安全的同时,允许外部直接修改坐标。
2. 可变线程安全的 SafePoint
- 线程安全性:
x,y
被synchronized
保护,get()
与set()
是原子操作;- 私有构造函数模式(Private Constructor Capture Idiom)保证从现有实例拷贝时不被并发修改。
3. 发布状态的追踪器:PublishingVehicleTracker
- 线程安全性完全委托给:
ConcurrentHashMap
保证对locations
结构的并发访问;SafePoint
保证对单个坐标的并发读写。
- 发布状态:
getLocation
和getLocations
均返回对底层可变对象的引用,外部可直接调用set(...)
。
- 实时性:所有坐标更新立即可见,无需额外刷新;与 DelegatingVehicleTracker 类似,返回的是“实时视图”。
4. 适用性与风险
特性 | PublishingVehicleTracker |
实时性能 | 更新后即刻可见,无拷贝开销 |
线程安全性 | 依赖内部组件,不用外层加锁 |
封装性降低 | 发布了底层可变状态,外部可随意修改 SafePoint 内容 |
可施加约束 | 如果需要“范围校验”或“触发动作”,此设计不适用 |
演进限制 | 将来若需在位置变更时做额外操作(如事件通知),需重构 |
5. Private Constructor Capture 模式
- 在
SafePoint(SafePoint p)
中调用私有构造函数new SafePoint(p.get())
,而非直接用p.x,p.y
- 避免在读取
p.x
与p.y
之间出现竞态,确保拷贝的是同一时刻的状态。
6. 小结
- PublishingVehicleTracker 是“发布底层可变状态”策略的经典示例。
- 它适用于对性能与实时性要求极高,且业务对“封装性”或“校验”无额外需求的场景。
- 一旦需要在更新时加入检查、触发回调或保持复杂不变式,就必须在外层引入同步或事件机制。
拓展:
- 如果你需要对比这三种追踪器在高并发场景下的吞吐与延迟,或希望看到它们的时序图/UML 类图,请告诉我!
4.4 在现有的线程安全类中添加功能
1. 背景与动机
- Java 标准库中已有大量经过验证的线程安全基础类,重用它们可以节省开发和测试成本。
- 但有时,这些类缺少某些原子操作——例如“若不存在则添加”(put-If-Absent)——而我们又不能修改它们的源代码。
2. 原子“若不存在则添加”需求
- 功能定义:
如果集合中尚未包含元素 x,则将其添加;否则不做任何操作;并且保证在高并发环境下只会添加一次。
- 线程安全要求:
“检查(contains)”和“添加(add)”必须作为一个原子操作,否则两个线程可能都看到“尚未包含”,都执行添加,导致重复元素。
3. 继承 Vector
的示例:BetterVector
- 优点
- 直接复用
Vector
已有的同步策略和大部分功能。 - 在单一源文件内维护同步逻辑,便于阅读和调试。
- 缺点
- 脆弱:若
Vector
底层同步锁策略改变(例如改用不同锁或细粒度锁),子类中使用的synchronized
就可能不再保护父类的内部状态。 - 依赖于
Vector
公开其同步策略——大多数类(非Vector
)并不鼓励或保证这样做。
4. 扩展 vs. 委托的风险对比
特性 | 继承 ( BetterVector ) | 委托(Wrapper) |
同步策略可见性 | 依赖父类实现,若变更易导致破裂 | 由自己控制锁或委托对象,无隐式依赖 |
源代码位置 | 分散在父类和子类两个文件 | 全部在外层封装类中,易于维护 |
对库演进的敏感度 | 高——父类同步策略改变时容易失效 | 低——只要委托对象接口不变即可 |
可重用性 | 仅限 Vector 或明确公开同步策略的类 | 可对任意 List<E> 、Set<E> 等组合封装 |
5. 推荐做法
5.1 使用组合+显式同步
- 锁完全由外层类控制,不依赖被封装类的同步策略。
- 可对任何
List<E>
(如Collections.synchronizedList
、CopyOnWriteArrayList
)进行封装。
5.2 利用现有并发集合
- 对于键—值存储,
ConcurrentMap
已内置putIfAbsent
:
- 对于需要集合语义(无重复元素),可将
ConcurrentMap<E, Boolean>
作为底层实现。
6. 小结
- 扩展线程安全类(继承)来添加原子操作虽简单,但高度依赖父类同步实现,易受库演进影响。
- 推荐组合+委托或直接使用并发容器,由自己控制同步或利用成熟的并发 API,既安全又灵活。
- 在设计新增操作时,始终确认“检查”与“执行”之间的原子性需求,选择最稳健的实现方式。
4.4.1 客户端加锁机制(Client-Side Locking)
1. 背景
针对通过
Collections.synchronizedList(...)
获得的线程安全 List
,我们既不能修改其源码,也无法通过继承来增加原子操作,因为客户端并不知道返回的 List
类型。因此,需要在客户端显式地使用与被封装对象相同的锁来保护复合操作。
2. 错误示例:错误的同步对象
- 问题所在:
putIfAbsent
被 synchronized
修饰,使用的是 ListHelper.this
作为锁;而
list
本身的同步策略(内部使用的锁)是另一个对象(通常是 list
自身)。- 后果:
客户端操作和
list
内部操作并不在同一把锁保护下,仍然无法保证 contains
与 add
的原子性。3. 正确示例:基于客户端加锁
- 关键点:
synchronized(list)
与 list
内部所有方法(如 add
, contains
)使用的是同一把锁,才能保证复合操作的原子性。- 效果:
在调用
putIfAbsent
时,contains
与 add
在同一锁范围内执行,外部任何线程在此期间都无法修改 list
。4. 客户端加锁的风险与局限
优点 | 缺点 |
· 不需修改被封装类源码 | · 同步策略分散在客户端,易被误用 |
· 可对任意 synchronized 封装器 | · 依赖外部代码“正确”遵循加锁约定 |
ㅤ | · 难以控制多个客户端同时正确加锁 |
- 脆弱性:
- 若封装器改变了内部锁对象(不再使用
this
),客户端加锁就会失效; - 客户端代码必须“了解”并使用相同的锁,违反了封装性原则。
5. 小结
- 扩展类 与 客户端加锁 都会破坏同步策略的封装性,相对更脆弱;
- 推荐:
- 对于关键原子操作,更稳妥的做法是 组合+显式同步(在一个封装类里统一管理锁);
- 或者直接使用更高级的并发容器(如
ConcurrentMap
)中内置的原子方法。