type
status
date
slug
summary
tags
category
icon
password
我们已经知道了同步代码块和同步方法可以确保以原子的方式执行操作,但一种常见的误解是,认为关键字synchronized只能用于实现原子性或者确定“临界区(Critical Section)”。
同步还有另一个重要的方面:内存可见性(Memory Visibility)。我们不仅希望防止某个线程正在使用对象状态而另一个线程在同时修改该状态,而且希望确保当一个线程修改了对象状态后,其他线程能够看到发生的状态变化。
如果没有同步,那么这种情况就无法实现。你可以通过显式的同步或者类库中内置的同步来保证对象被安全地发布。
可见性
NoVisibility可能会持续循环下去,因为读线程可能永远都看不到ready的值。一种更奇怪的现象是,NoVisibility可能会输出0,因为读线程可能看到了写入ready的值,但却没有看到之后写入number的值,这种现象被称为“重排序(Reordering)”。
只要在某个线程中无法检测到重排序情况(即使在其他线程中可以很明显地看到该线程中的重排序),那么就无法确保线程中的操作将按照程序中指定的顺序来执行。[1]
当主线程首先写入number,然后在没有同步的情况下写入ready,那么读线程看到的顺序可能与写入的顺序完全相反。在没有同步的情况下,编译器、处理器以及运行时等都可能对操作的执行顺序进行一些意想不到的调整。在缺乏足够同步的多线程程序中,要想对内存操作的执行顺序进行判断,几乎无法得出正确的结论。
这听起来有点恐怖,但实际情况也确实如此。幸运的是,有一种简单的方法能避免这些复杂的问题:只要有数据在多个线程之间共享,就使用正确的同步。
为什么 NoVisibility
会出现两种诡异结果?
现象 | 根本原因 |
一直死循环(读线程永远看不到 ready == true ) | 可见性问题 ready 写入发生在 CPU-A 的缓存行里,但从未被刷新到主内存;CPU-B 一直在自己的缓存中读取旧值 false 。 |
打印 0 而不是 42 | 重排序 JVM 或处理器把两条写操作 number = 42; → ready = true; 调整成了ready = true; → number = 42; 读线程恰好先读取到新的 ready ,随后又在自己的缓存中读到旧的 number (=0)。 |
在没有任何同步保障时,编译器、CPU、甚至 JIT 都被允许重新排序独立指令,只要A) 每条线程内部观察不到改变;B) 单线程语义正确。当程序跨线程共享变量而不告知 JVM“这段代码需要顺序一致性”时,就会触发上述“看似不可能”的现象。
如何修复 —— 给“共享边界”加同步
方案 1:把两字段都声明为 volatile
- 可见性 对同一
volatile
变量的 写 → 读 建立 happens-before:
写
ready=true
之前的所有写(即 number=42
)都会在内存屏障前被刷新——读线程一定先读到 42。- 禁止重排序 JMM 规定:
- 写 volatile 之前的普通写 不得 与之交换顺序;
- 读 volatile 之后的普通读 不得 被提前。
方案 2:使用显式锁
- 可见性 & 原子性 同一把锁的 解锁 → 加锁 间天然具备 happens-before。
- 灵活点 除了保证顺序,还能扩展成多字段修改、条件等待等复杂场景。
方案 3:使用原子类(更推荐整体替换快照)
- 思想:把相关状态打包成不可变快照→一次原子发布,避免字段级同步复杂度。
一张表选方案
场景 | 推荐同步手段 | 优点 | 注意 |
简单标志、配置快照,只需可见性 | volatile | 语义直白、读写开销低 | 只适用于单写或写不依赖旧值 |
多字段一致性、复合操作 | synchronized / ReentrantLock | 简单可靠,支持条件等待 | 可能有竞争;需正确划定临界区 |
高并发计数器、累加 | AtomicLong /LongAdder | 无锁、细粒度 | 多字段仍需额外协调 |
更新=整体替换对象 | AtomicReference + 不可变快照 | 设计简洁,一次写发放所有字段 | 只读路径需要拆包 |
核心记忆点
- 共享变量 = 必须告诉 JVM“别给我乱排队!”
volatile
、锁、原子类都能做到。
- 不要只同步写、忘了同步读——顺序是双向契约。
- 如果读线程自旋检查标志位,务必让标志位
volatile
;否则它可能永远卡在 CPU 缓存里。
一句话:跨线程共享数据时,“看得见” 和 “按顺序” 不是自然而然的——用同步手段把这两件事声明清楚,才能让 JVM 和 CPU 按你写的顺序办事。
失效数据
NoVisibility展示了在缺乏同步的程序中可能产生错误结果的一种情况:失效数据。当读线程查看ready变量时,可能会得到一个已经失效的值。除非在每次访问变量时都使用同步,否则很可能获得该变量的一个失效值。更糟糕的是,失效值可能不会同时出现:一个线程可能获得某个变量的最新值,而获得另一个变量的失效值。
非原子的64位操作
加锁与可见性
内置锁可以用于确保某个线程以一种可预测的方式来查看另一个线程的执行结果,如图3-1所示。当线程A执行某个同步代码块时,线程B随后进入由同一个锁保护的同步代码块,在这种情况下可以保证,在锁被释放之前,A看到的变量值在B获得锁后同样可以由B看到。换句话说,当线程B执行由锁保护的同步代码块时,可以看到线程A之前在同一个同步代码块中的所有操作结果。如果没有同步,那么就无法实现上述保证。

加锁的含义不仅仅局限于互斥行为,还包括内存可见性。为了确保所有线程都能看到共享变量的最新值,所有执行读操作或者写操作的线程都必须在同一个锁上同步。
Volatile变量
Java语言提供了一种稍弱的同步机制,即volatile变量,用来确保将变量的更新操作通知到其他线程。当把变量声明为volatile类型后,编译器与运行时都会注意到这个变量是共享的,因此不会将该变量上的操作与其他内存操作一起重排序。volatile变量不会被缓存在寄存器或者对其他处理器不可见的地方,因此在读取volatile类型的变量时总会返回最新写入的值。
在访问volatile变量时不会执行加锁操作,因此也就不会使执行线程阻塞,因此volatile变量是一种比sychronized关键字更轻量级的同步机制。
从内存可见性的角度来看,写入volatile变量相当于退出同步代码块,而读取volatile变量就相当于进入同步代码块。然而,我们并不建议过度依赖volatile变量提供的可见性。如果在代码中依赖volatile变量来控制状态的可见性,通常比使用锁的代码更脆弱,也更难以理解。
仅当volatile变量能简化代码的实现以及对同步策略的验证时,才应该使用它们。如果在验证正确性时需要对可见性进行复杂的判断,那么就不要使用volatile变量。volatile变量的正确使用方式包括:确保它们自身状态的可见性,确保它们所引用对象的状态的可见性,以及标识一些重要的程序生命周期事件的发生(例如,初始化或关闭)。
当且仅当满足以下所有条件时,才应该使用volatile变量:
- 对变量的写入操作不依赖变量的当前值,或者你能确保只有单个线程更新变量的值。
- 该变量不会与其他状态变量一起纳入不变性条件中。
- 在访问变量时不需要加锁。
使用 volatile
必须同时满足的 3 个条件
1. 写入操作 不依赖 当前值
或者 保证只有一个线程写
- 原因
volatile
只保证 单次读/写 的可见性,不保证“读-改-写”序列的原子性;多线程并发写极易丢失更新。- 典型反例
- 何时安全
- 单线程写、多线程读(例如配置开关
volatile boolean enabled
)。 - 写入与旧值无关:直接替换对象引用
config = newConfig
。
2. 变量 不与其他字段共同维护不变式
- 原因
volatile
无法把多个字段“打包”成原子更新,跨字段不变式容易被破坏。- 典型反例
- 何时安全
- 该字段完全独立,不与其他字段形成业务约束。
- 需要跨字段一致性时,使用锁或把状态压缩进一个原子变量(例如
long state
+ CAS)。
3. 访问它时不再需要加锁
- 原因
如果对同一对象的其他操作已经必须拿锁,再把字段声明为
volatile
只是重复且容易误导。- 典型反例
- 何时安全
- 整个对象的并发控制就是靠这几个
volatile
字段,无需额外锁。 - 单生产者 / 单消费者场景:数组元素由一方写,
head
/tail
用volatile
发布。
小结:为什么三条都要同时满足?
条件 | 保护你免于的典型坑 |
① 写不依赖旧值/单线程写 | 丢失更新、非原子自增 |
② 不跨字段不变式 | 半初始化、撕裂读 |
③ 访问时无锁 | 两套并发机制并存、含糊不清 |
实践建议
- 先用锁写出正确版本。
- 通过性能分析确认锁成瓶颈后,再检查能否同时满足这三条。
- 若满足,再改用
volatile
/ 原子类,并补充详细注释 + 并发测试。
发布与逸出
1 . 什么是“发布”一个对象?
- 含义:把某个对象引用暴露给当前作用域以外的代码/线程。常见做法包括
- 把引用保存到
public
/static
/protected
字段; - 作为返回值从方法中返回;
- 作为参数传给其他对象,或放入共享容器(集合、队列、回调注册表等)。
一旦发布,别的线程就可能在“无同步保护”的情况下并发访问该对象。
2 . 什么是“逸出”?
逸出:本不应发布的对象(尤其是尚未完全初始化的 this)被意外地发布,导致外部线程拿到引用。
3 . 逸出的典型场景与风险
3-1. 构造期间提前泄漏 this
- 风险:另外的线程可能在对象未初始化完就调用
handleEvent
,看到半截状态。
3-2. 直接返回可变内部字段
- 风险:调用方可以随意修改
endpoints
,破坏封装和不变式(如end[0] <= end[1]
)。
3-3. 放入非线程安全的全局容器
- 风险:多线程并发访问时,列表本身会出现数据竞争,元素内部状态也可能未安全发布。
4 . 为什么逸出危险?
- 初始化安全性
- JMM 只保证“构造函数 返回之后”其他线程才能看到对象的正确状态。构造期间泄漏会破坏这一保证。
- 不变式可能失效
- 可变内部状态被外部随意改写,类自身难以保证业务约束。
- 可见性问题
- 如果没用
final
/volatile
/ 锁等“安全发布”手段,其他线程可能读到过期或乱序的数据。
5 . 如何安全地发布对象
- 等对象完全构造后再发布
- 使用静态工厂或 Builder 隔离构造与注册。
- 使用不可变对象或防御性拷贝
- 借助安全的发布机制
static final
字段:JVM 保证类初始化完成后,其他线程看到的都是完全初始化的对象。volatile
或显式锁:写前写后形成 happens-before,确保可见性。- 线程安全容器(
ConcurrentLinkedQueue
,BlockingQueue
等):容器内部已做好同步。
6 . 小结
- 发布 = 把对象引用交给外部代码 / 线程使用。
- 逸出 = 不应发布却发布,或在对象尚未完全初始化时就发布。
- 一旦逸出,可能导致:半初始化、封装破坏、数据竞态——产出隐蔽且难复现的并发 Bug。
- 防范要点:
- 构造期间绝不让
this
逃逸。 - 返回可变内部数据时用拷贝或只读包装。
- 真需要共享时,确保使用不可变对象或安全发布技术。
线程封闭
当访问共享的可变数据时,通常需要使用同步。一种避免使用同步的方式就是不共享数据。如果仅在单线程内访问数据,就不需要同步。这种技术被称为线程封闭(Thread Confinement),它是实现线程安全性的最简单方式之一。当某个对象封闭在一个线程中时,这种用法将自动实现线程安全性,即使被封闭的对象本身不是线程安全的[CPJ 2.3.2]。
在Java语言中并没有强制规定某个变量必须由锁来保护,同样在Java语言中也无法强制将对象封闭在某个线程中。线程封闭是在程序设计中的一个考虑因素,必须在程序中实现。Java语言及其核心库提供了一些机制来帮助维持线程封闭性,例如局部变量和ThreadLocal类,但即便如此,程序员仍然需要负责确保封闭在线程中的对象不会从线程中逸出。
Ad-hoc线程封闭
什么是 Ad-hoc 线程封闭?
- 定义
Ad-hoc(特设、临时)线程封闭指:完全依赖程序员的约定 来保证“某个对象只被一个特定线程访问”,而 没有任何语言或库层面的硬性限制 来强制这一规则。
- 典型场景
- Swing / JavaFX 的 UI 线程:要求所有组件和模型只能在事件派发线程更新。
- Android 的 主线程(UI Thread):View、Activity 等对象被规定只能在主线程触碰。
- 游戏循环中的 渲染线程专属对象:纹理缓存、场景树等必须留在渲染线程。
为什么说它“非常脆弱”?
脆弱点 | 具体原因 | 结果表现 |
没有编译期或运行时保护 | 语言层面无法声明“此引用仅限线程 T 使用”,也无法像 private / final 那样施加可见性检查。 | 违规访问只在运行时、并且往往是 偶发、难重现 的竞态或 IllegalStateException 。 |
引用常保存在公有或全局变量中 | GUI 框架往往把组件对象挂在树形结构或全局上下文( Stage , Scene , Display )里,其他线程很容易拿到引用。 | 稍不留意就从后台线程调用了 button.setText() → 跨线程 UI 操作异常。 |
多人协作 & 代码演进 | 约定写在文档或代码注释里,新同事 或 后期重构 容易忽略。 | 隐蔽 Bug 潜伏至上线,偶尔才在高并发或特定 OS 版本中爆发。 |
缺乏自动化检测 | 静态分析器很难推断“此调用发生在 UI 线程吗?”;动态检测成本高。 | 主要依靠人工 Code Review + 经验。 |
例子:GUI 组件引用为何易泄漏?
- 问题点
statusLabel
是public final
,任何线程都能访问;- 没有语言机制阻止后台线程调用
setText
; - 依靠文档“Swing 组件只能在 EDT 操作”这条人为约定;
- 一旦误用,可能导致 随机死锁、非法状态,或直接被 Swing 抛异常。
- 改进策略
将字段设为
private
+ 中央调度(invokeLater
、Platform.runLater
),减少误触机会。如何减轻 Ad-hoc 封闭的脆弱性?
- 封装引用
private
字段 + 只在所属线程提供操作接口。- 使用 守卫性方法,内部自动切换到正确线程。
- 线程检查工具/注解
- Android 的
@UiThread
,@WorkerThread
(Lint 检查)。 - Google ErrorProne 的 ThreadConfined 分析器。
- 统一调度器
- 把所有跨线程调用 funnel 到一个 API:
- 强制使用调度器可以在运行时报错或记录违规。
runOnUiThread(Runnable)
, dispatcher.invoke(block)
。- 架构层隔离
- MVVM / MVI:ViewModel 层持有 LiveData/StateFlow,由框架保证切换到主线程。
- Reactor / RxJava:
.observeOn(AndroidSchedulers.mainThread())
。
- 代码审查与单元/集成测试
- Review 时专门检查 UI/模型是否在正确线程。
- 编写并发测试 (
CountDownLatch
,UiThreadTestRule
) 验证封闭假设。
小结
- Ad-hoc 线程封闭 = 靠约定而非语言特性。
- 优点:实现简单、无锁高效。
- 缺点:无编译期/运行时护栏,极易因疏忽而破坏。
- 防护思路:封装字段 ➜ 集中调度 ➜ 静态/动态检查 ➜ 团队规范。
在并发设计中,“能线程封闭就线程封闭;不能保证封闭就显式同步”——但别把“封闭”的责任仅仅寄托于口头协议。
栈封闭
1 什么是“栈封闭”?
术语 | 含义 |
线程封闭 (Thread Confinement) | 让对象只被一个线程访问,从而省去同步。 |
栈封闭 (Stack Confinement) | 线程封闭的一种特例——对象仅通过方法中的局部变量可达;它随栈帧创建、方法返回即不可达。其他线程拿不到该栈帧,自然也看不见对象。 |
不要混淆:ThreadLocal 通过线程-map 保存副本,是“线程本地存储”;栈封闭只依赖局部变量的位置,不需要额外结构。
2 为什么栈封闭更“牢靠”?
- 由 JVM 结构天然保证
- 每个线程都有独立的栈;GC root 扫描不会跨线程栈引用。
- 只要引用停留在局部变量表里,就不可能被别的线程读到。
- 静态分析容易
- 编译器/IDE 很容易判断引用没有写入字段或传递到回调,从而给出 escape analysis 优化或警告。
- Ad-hoc 封闭仅靠“团队约定”,工具很难验证。
- 不受线程复用影响
- 线程池复用线程时,栈帧早已弹出;而
ThreadLocal
需要手动remove()
,否则可能内存泄漏。
3 典型示例
3-1 简单的局部对象
md
只有当前线程能用;无须加锁或ThreadLocal
。
3-2 局部引用 vs. 对象逸出
tmp
在返回前是栈封闭;一旦返回,引用交给调用方,便不再封闭。
4 与 ThreadLocal
的区别
特性 | 栈封闭 | ThreadLocal |
生命周期 | 方法调用期间 | 线程存活期间或手动 remove() 为止 |
使用成本 | 零开销 | 需要键-值存取,易泄漏 |
适用场景 | 短期、一次性对象 | 创建代价高或跨方法调用链复用的对象 |
误用风险 | 仅对象逸出 | 泄漏到线程池,导致 OOM |
5 与 Ad-hoc 线程封闭的对比
方面 | 栈封闭 | Ad-hoc 封闭 |
保护手段 | JVM 栈隔离,编译器可验证 | 纯靠“别的线程不要用”这种口头协议 |
打破方式 | 赋值给字段 / 返回值 / 传给其他线程 | 同左,但更易发生:很多对象本就保存在公有字段 |
维护难度 | 低——写法简单清晰 | 高——需要审查所有数据流 |
6 何时会意外破坏栈封闭?
- 赋值给可跨线程的字段或集合
- 把
this
或局部对象注册到回调/监听器(回调可能在别的线程执行)
- 在 Lambda / 匿名内部类中引用局部变量
- 变量被捕获后,其值可能在方法返回后仍被使用。
- 返回对象本身或其可变内部结构
检查口诀:“只要引用离开栈帧,封闭就结束。”
7 实用建议
- 首选栈封闭:能放局部就放局部,简单高效。
- 需跨方法复用 →
ThreadLocal
,并记得在线程池环境里清理。
- 必须共享 → 明确同步(锁、原子变量、并发容器)。
- 借助工具:开启 JDK 逃逸分析、使用 SpotBugs SC_METHOD_RETURNS_FIELD 等规则。
小结
- 栈封闭=线程封闭的“硬核”形式,凭借 JVM 栈的天然隔离获得最高级别的并发安全。
- 只要 引用不逃出方法,就无需担心可见性、竞态或锁开销。
- 真正的挑战在于识别并防止逸出——一旦对象被写进共享结构或作为返回值,就必须重新评估并发策略。
ThreadLocal类
1. 工作原理(核心要点)
ThreadLocal<T>
本身只是一个键(key)
- 每条线程内部维护一个私有
ThreadLocalMap
key = ThreadLocal 实例
value = 该线程自己的副本
- 调用流程
set(v)
→ 写入“当前线程的”ThreadLocalMap
get()
→ 从同一个 Map 读取
- 结果:同一个
ThreadLocal
在不同线程返回的值互不影响
2. 典型使用场景
- 不可重入或线程不安全的工具类
SimpleDateFormat
, DecimalFormat
等- 每线程上下文数据
连接、事务、Session、Request‐scope 信息
- 日志/链路追踪 ID 透传
在一次请求/任务生命周期内保持一致
3. 与其他手段对比
手段 | 生命周期 | 开销 | 典型用途 |
栈封闭 | 方法调用期间 | 最低 | 临时对象 |
ThreadLocal | 整个线程 (需 remove) | 中等 (哈希) | 需跨多方法且只本线程使用 |
显式锁同步 | 受锁保护的代码段 | 最高(锁竞争) | 真正要跨线程共享的数据 |
4. 最佳实践 & 易踩坑
- 用
withInitial()
懒加载
线程池环境必须 remove()
- 避免存放大对象或外部资源
每线程一份可能爆内存;可存代理或连接池句柄
- 不要用来跨线程通信
真要继承值 →
InheritableThreadLocal
/ 第三方 TransmittableThreadLocal
5. 实战示例:线程安全日期格式化
- 无锁、高效、线程安全
ISO_FMT
对每个线程都是独立实例
6. 扩展家族
InheritableThreadLocal
子线程在创建时复制父线程初值
TransmittableThreadLocal
(阿里开源)
解决线程池复用导致的 ThreadLocal 值丢失/串值问题
7. 核心提醒
- 首选栈封闭;不够用再考虑
ThreadLocal
- 使用后一定记得清理
- 谨防隐式依赖和内存泄漏
一言以蔽之:ThreadLocal 让“全局单例”变成“线程单例”,好处是省锁,坏处是易忘记清理。
不变性
不可变对象 (Immutable Object) —— 关键要点一览
结论 | 说明 |
不可变 ⇒ 天然线程安全 | 状态只初始化一次,无并发修改;读操作之间没有竞态。 |
“全部 final 字段”≠ 不可变 | final List<String> names 仍可变,因为 names.add() 能改内部状态。 |
判定标准 | 1) 状态永不改变 2) 所有字段 final 3) 构造期间 this 不向外泄漏。 |
1. 为什么“只读”就保证线程安全?
- Java 内存模型:对象构造完成后,若其字段都是
final
,JMM 保证“构造写”对任何线程的“后续读”可见。
- 无状态变化 ⇒ 读线程永远看到同一组值,不可能读到“中间态”或产生数据竞争。
2. 三条硬要求逐一拆解
- 状态不可变
- 无 setter、无可变公共字段。
- 若字段引用其他对象,需要深度不可变或防御性拷贝。
- 所有字段
final
- 保证 happens-before:一旦构造完成,字段值对外可见且不会被重新赋值。
- 仅是必要条件之一—not sufficient。
- 正确创建(无 “
this
逸出”) - 在构造函数结束前,
this
不能被发布到其他线程(注册监听器、将自身放入静态集合等)。 - 否则别的线程可能看到“半初始化”状态,破坏不变式。
3. “字段都是 final
” 仍可能可变的示例
- 风险:调用方拿到
names
后随意修改,破坏封装并引入并发问题。
- 修正:
4. 设计 Java 不可变类的实践清单
步骤 | 要点 |
类声明为 final | 防止子类增加可变状态或泄漏 this 。 |
字段 private final | 不暴露实现细节;一次赋值后保持常量。 |
无 Setter / 只读访问器 | 公开的 API 只返回状态,不允许修改。 |
深拷贝或不可变封装 | 对于数组、集合、日期等可变类型:• 构造时 copyOf / clone() • Getter 返回只读视图或拷贝。 |
构造完成后再发布 | 使用工厂方法或 Builder 避免 this 泄漏。 |
5. 常见不可变类型
- JDK 内建:
String
,Integer
(及其他包装类),LocalDate
,Path
- Google Guava:
ImmutableList
,ImmutableMap
- Project Lombok:
@Value
注解可快速生成不可变类(仍需关注深拷贝)
结语
不可变对象通过“一次初始化、终身只读”天生规避了并发修改带来的复杂性。要真正做到深层不可变,必须同时满足 “状态不改 + 字段
final
+ 正确构造” 三大条件,并谨慎处理任何可变字段的引用。Final域
final
域在 Java 内存模型中的特殊语义
1. “安全发布”保证:初始化写 → 所有后续读可见
当对象构造 正确结束 且没有 this 逸出时,JMM 规定:
- 写入
final
字段 与 任意线程随后读取该字段 之间自动建立 happens-before 关系。
- 读线程不仅能看到
final
字段的最终值,还能看到构造函数内先于该写入的所有其他写(即对象的完整初始化)。
结果:只要构造完成,就可以 无锁地共享不可变对象。
2. 将字段设为 final
带来的 3 个好处
- 减少可变性 ⇒ 状态空间更小,逻辑更简单
- “基本不可变” 对象:只保留极少数可变字段,其余皆
final
。 - 调试 / 推理时关注的变动维度大幅缩减。
- 为维护者提供意图信号
- 看到
final
就知道:“这是初始化后永不变的常量”,可以放心在断言、不变式、哈希计算中使用。
- 启用编译器/JIT 优化
- 常量折叠、逃逸分析、锁消除等更易触发。
- 读
final
字段时省去部分内存屏障。
3. final
≠ 完全不可变
- 如果
final
字段保存 可变对象的引用,整体仍然可变。
- 解决办法:
- 用 深拷贝 或
List.copyOf()
在构造时构建只读副本; - 或返回不可变视图 (
Collections.unmodifiableList
); - 或直接使用 Guava/Java 17 的不可变集合。
4. 实践指南
建议 | 说明 |
默认 private final | 与 “默认私有化” 同理:除非确有需求,否则让字段既私有又终态。 |
构造后不再修改 | 如果后来发现要变,更改为“可变 + 受锁保护”而不是偷偷移除 final 。 |
避免 this 在构造期逸出 | 使用 Builder 或静态工厂;不要在构造函数里注册回调 / 把自己放进静态集合。 |
对可变成员作防御 | 数组、集合、 Date 等在赋值和返回时都要拷贝/封装。 |
口诀“能final
就final
,能不可变就不可变;必要可变字段要集中管理 + 清晰同步。”
这样既获得了 线程安全的默认保障,也让代码读写者一眼看明白“哪些东西永远不会变”。
示例:使用Volatile类型来发布不可变对象
为什么 “两个 AtomicReference
+ 自己拼装” 不够安全?
场景 | 线程 A | 线程 B | 结果 |
读取-更新交错 | ① get(lastNumber) → N₀ ② 被抢占 | ① set(lastNumber, N₁) ② set(lastFactors, f(N₁)) | 线程 A 继续执行时拿到 旧数 + 新分解,缓存状态撕裂。 |
- 两个字段分别原子,但组合状态不是原子。
- 用
volatile
也一样,只保证 可见性,不保证复合写的一致性。
解决思路:把 “一组相关状态” 封装进不可变对象
为什么 线程安全?
- 不可变
- 所有字段
final
,构造后状态不可再改。 - JMM 保证:对象引用对外可见时,其
final
字段已正确初始化(安全发布)。
- 原子性(弱,但足够)
- 缓存变量可声明为
volatile OneValueCache cache;
- 写入时:用
new OneValueCache(num, factors)
构造完整快照,然后一次volatile write
发布——原子替换整块状态。 - 读取时:一次
volatile read
拿到引用,要么获取到旧快照,要么新快照;不可能看到撕裂状态。
- 防御性拷贝
- 内部数组在构造和返回时都做
copyOf
,保证外部线程无法通过引用修改缓存内容。
和“全部上锁”相比
方案 | 读性能 | 写性能 | 复杂度 |
不可变快照 + volatile 引用 | 无锁读,极快 | 构造 + 一次 volatile 写 | 低:无竞争、无死锁 |
互斥锁保护两字段 | 获取锁(可能竞争) | 同 | 中:需正确划定临界区 |
两个 AtomicReference | 可能读到撕裂状态 | 同 | ✖ 错误:无法保证一致性 |
如何在 Servlet / Controller 中使用
- 命中路径:只执行一次
volatile read
,完全无锁。
- 失配路径:重新计算后构造新的不可变快照,再一次
volatile write
发表。
- 任何时刻:其他线程要么看到旧缓存,要么看到新缓存;缓存内部绝不混杂。
经验法则
当多个相关字段必须一起观察或一起更新时:✅ 封装为不可变对象 → 用单一引用原子发布 / 替换。这样既避免锁竞争,也杜绝了“状态撕裂”与可见性问题。
安全发布
不正确的发布:正确的对象被破坏
为什么 StuffIntoPublic 的做法会「把已经构造好的对象变坏」?
- 没有同步 → 引用写入对其他线程只是普通写
- JMM 允许重排序:② 中的“写字段
n=42
”和“把引用写进holder
”顺序可能被 CPU / 编译器交换。 - 也允许“写字段”滞留在线程的本地缓存 🚏,还没刷到主内存。
- 其他线程读取时可能发生的 3 种结果
读到的 holder | 读到的 holder.n | 效果 |
null | —— | 看到老值 → 失效引用 |
Holder@X | 0 | 引用是新的,但字段仍是默认值(部分构造) |
Holder@X | 42 | 一切正常 |
糟糕之处:第二种“半初始化对象”不能用任何静态分析检测出来,却会在高并发压力下偶发。
Holder.assertSanity()
为何可能抛 AssertionError
- 线程 第一次 读到
n
为0
(失效值)。
- 稍后 同一线程再次读同一字段,缓存刷新,读到
42
。
- 于是出现
0 != 42
⇒ 抛异常 —— 证明了字段值在单线程内竟然“自己变了”。
“未正确发布”带来的两个严重问题
问题 | 行为表现 | 结果 |
失效引用 | 读到 null / 旧对象 | 逻辑错误、NPE、脏数据 |
半初始化对象 | 字段值为默认 / 混合值 | 不变式被破坏、难以复现的崩溃 |
何谓“正确发布 (Safe Publication)”?
对象构造 → 可见给其他线程 之间要建立 happens-before,可选方式:
final
字段 + 构造结束后再共享
- 把引用写入
volatile
字段
- 通过锁保护写入 / 读取
- 在静态初始化块或
static final
字段中创建
- 经由线程安全容器(
ConcurrentHashMap
、BlockingQueue
)转移
改写示例(任选其一)
① 用 volatile
发布
② 用锁发布
实用守则
- 创建完毕才能暴露引用:绝不在构造函数里把
this
放进全局变量 / 监听器。
- 默认使用
final
:final
字段 + 正确构造获得免费的可见性保障。
- 如果共享可变状态 ⇒ 一律同步或使用并发容器。
一句话总结对象构造完再同步发布,才算“长得好”;没同步就乱扔引用,别人就能看到“长到一半”的怪胎。
不可变对象与初始化安全性
Java 内存模型对「不可变对象」的 初始化安全性 保证
final-field semantics——只要对象真正不可变,其他线程即使无同步地拿到它的引用,也一定看到其完整、一致的状态。
1 什么叫“初始化安全”?
- 问题回顾
普通可变对象若被“错误发布”,另一线程可能先读到默认值 0、再突然读到 42(
Holder.n
例子)。- JMM 额外规则
对象构造完成后,若 所有字段都是 final 且此后再也不修改,写final
字段 与 任何线程随后读该字段 之间自动建立 happens-before。-– JSR-133, §17.5.1
因此:
- 读线程不可能见到字段的默认值或“半初始化”值;
- 字段值在同一线程内也不会自行跳变;
- 还传递到构造期间 先于写
final
字段的其他写——整个对象处于一致状态。
2 满足三大条件才能享受这份保障
条件 | 说明 |
状态不再修改 | 类自身不提供任何会改变字段的操作;外界也拿不到可变内部结构的引用。 |
所有字段 final | 含对象图:若字段引用别的对象,那个对象也应在构造后冻结。 |
正确构造(无 this 逸出) | 构造函数返回前, this 不能暴露给其他线程。 |
缺一不可。 一旦有后门修改 final 字段或构造期间泄漏引用,初始化安全立即失效。
3 把 Holder
变成真正的不可变对象
- 构造完毕后再把
Holder
引用塞给其他线程,即使没有volatile
/ 锁,
读线程也只能见到
n = 42
(或构造时传入的其他值),绝对不会见到 0。assertSanity()
里两次读同一final
字段,JMM 保证返回同一个值 → 断言永远通过。
若仍用旧版本(n 不是 final),写线程可以在发布后偷改 n,或者另一线程第一次读到默认 0、第二次读到 42──断言就可能触发。
4 实战经验
建议 | 解释 |
除非必要,可写字段越少越好 | 限制状态空间,天然减少并发 Bug 面积。 |
默认把字段声明为 private final | 想改再放开,比事后补救简单可靠。 |
对集合、数组也要“深度不可变” | 只做 final 引用但对外暴露可变对象,会破坏初始化安全。 |
确保构造期不泄漏 this | 用 Builder /静态工厂,把发布动作推到构造完成之后。 |
小结
- 不可变对象 = “状态不可改 + 字段全 final + 正确构造”。
- JMM 给予它们 额外的可见性保证:一旦构造完并发布出去,即使无同步,所有线程也看到同一份、完整的状态。
- 利用这条规则,把相关数据打包成不可变快照,可在高并发场景中既避免锁、又避免“半初始化”风险。
安全发布的常用模式
可变对象的 安全发布 —— 一次写清楚
目标:写入线程 “把引用放出去” 之后,任何线程 都能看到对象 完整、最新 的状态。
1 为什么“发布”也要同步?
- 引用可见 ≠ 状态可见
- 先拿到引用,却读到对象字段的 旧值 / 默认 0;
- 甚至先读旧值,过一会儿又读到新值 —— 典型“撕裂”现象。
如果发布动作没有 happens-before 保障,别的线程可能:
- 要避免这种情况,发布写 必须与 对象内部写 一起形成 原子快照。
2 JMM 支持的 4 种安全发布手段
# | 手段 | HB 建立点 | 典型代码 |
① | 静态初始化 | 类初始化 ( <clinit> ) 完成 → 读 static 字段 | java\nclass Config {\n static final Cache CACHE = new Cache();\n}\n |
② | volatile / AtomicReference | 写 → 随后任何线程读同一变量 | java\nvolatile Cache cache;\ncache = new Cache(); // 发布\nCache c = cache; // 获取\n |
③ | 写入正确构造对象的 final 字段 | 构造结束 → 读该 final 字段 | java\nclass Wrapper {\n final Cache cache;\n Wrapper(Cache c){ cache = c; }\n}\n |
④ | 锁保护的字段 | 解锁 → 之后任何线程 加同一把锁 | java\nsynchronized(lock){ cache = new Cache(); }\n...\nsynchronized(lock){ Cache c = cache; }\n |
⚠️ 并发容器(ConcurrentHashMap、BlockingQueue 等)内部即采用 ② + ④ 机制,天然安全发布。
3 示例:用 volatile
做“快照式”缓存
- 读取路径:一次
volatile
读,完全无锁。
- 写入路径:构造不可变
OneValueCache
➜ 一次volatile
写 —— 发布成功。
4 发布 之后 如何继续保证可见性?
发布手段 | 后续读 | 后续写(修改状态) |
静态初始化 | 直接读 | 仍需 锁 / 原子变量 保护内部可变状态 |
volatile / AtomicReference | 直接 get() | - 整体替换:再 set() 新快照- 局部修改:需额外同步 |
final 链 | 直接读 | 无法改;改就得 替换新包装对象 |
锁保护 | 必须继续用 同一把锁 | 同左 |
5 常见误区 ⛔
public
字段 + 构造后赋值 → 依旧非安全发布。
AtomicReference.lazySet()
不是同步发布(弱语义),要用set() / compareAndSet()
。
- “双重检查单例” 必须 将实例字段声明为
volatile
,否则有半初始化风险。
- 把可变对象放进 并发集合 只是安全发布了 引用,内部字段如仍可变,要自己同步。
快速记忆
不加锁就发布?先想想这 4 条:① 静态常量 ②volatile
/Atomic*
③final
链 ④ 同一把锁只有在这 4 条路径里写出的引用,别的线程才一定能看到“生于完整、用于完整”的对象。
安全发布例子详解
下面分别用 完整、可编译的 Java 代码 演示 JMM 支持的 4 种安全发布方式。
每段示例都包含:
- 发布线程(Producer)
- 使用线程(Consumer)
- 关键语句上的解释注释
说明:为突出发布机制,示例对象 Data 只有一个字段 value;你可以把它替换成任何拥有内部可变状态的类。
1. 静态初始化(class initialization)
- 保障原理:类加载完成后,JMM 会把
<clinit>
中的所有写与以后对该类静态字段的读建立 happens-before。
2. volatile
字段(或 AtomicReference
)
2-A. volatile
2-B. AtomicReference
- 保障原理:对同一
volatile
变量的 写→读 之间,JMM 自动设置内存屏障,保证构造期间的写对读线程可见。
3. 写入正确构造对象的 final
字段
- 保障原理:
final
字段在构造函数中写入后,与其后的任何线程读之间存在 happens-before,前提是构造期间没有this
逸出。
4. 锁保护字段(synchronized / Lock)
- 保障原理:
unlock
到lock
之间自动存在 happens-before,保证写入与后续读的可见性,同时还提供互斥。
附:演示用 Data
类
何时选哪一种?
发布方式 | 典型用途 | 读开销 | 写开销 | 说明 |
静态初始化 | 常量、饿汉单例 | 最低 | 类加载一次 | 最简单可靠,无延迟加载 |
volatile / Atomic | 快照式热更新、双检锁单例 | 低 | 低 | 无锁读;整体替换更高效 |
final 链 | 依赖注入、不变配置 | 低 | 构造时一次 | 只读;一旦要改需整体替换 |
锁保护 | 真正可变共享对象 | 中 | 中/高 | 提供互斥;易理解但可能竞争 |
经验口诀
- 静态常量先,能
final
就final
- 只需整体替换 →
volatile
/AtomicReference
- 真要经常改内部状态 → 锁保护
- 发布后,继续沿用同一种同步方式,不要混用。
事实不可变对象
定义对象在 技术上 可变(字段不一定全是final
),但一经发布就不再修改。只要它们通过安全发布暴露给其它线程,今后就可以当作真正的不可变对象来无锁使用。
1 为什么“安全发布 + 不再修改”就够?
- 安全发布(4 种机制见前文)保证:
- 引用可见 ⇒ 构造期所有写入也可见。
- 不再修改意味着:
- 没有后续写→读竞态;
- 所有线程始终观察同一快照。
因此:访问线程完全可以省掉同步,而编写者也不必达到“所有字段都
final
”的硬性要求。2 典型场景
场景 | 说明 |
运行时加载配置快照 | YAML/DB ⇒ 构造 Config 对象 ⇒ volatile 发布;后续只读。 |
HTTP 请求上下文 | Controller 把请求信息封装为 RequestContext ,完成后只读。 |
DTO / 事件消息 | 生产者线程构造 DTO ⇒ 投递到队列;消费者只读。 |
结果缓存(替换式) | 计算新结果 ⇒ 构造 CacheEntry ⇒ AtomicReference.set() ;旧对象永不再改。 |
3 示例:可变字段≠再修改 ➜ “事实不可变”
可变字段:timeoutSeconds、endpoints。不会再改:发布后,应用逻辑不提供任何 setter,也不暴露可写引用(集合返回只读视图)。构造期做好拷贝:防止外部线程借助旧列表把内部状态改掉。
4 安全发布 2 种实战写法
4-A volatile
引用 —— 热更新配置
- 任意线程读到的
config
要么旧快照,要么新快照,永远完整一致。
- 不需要给
Config
每个字段都打final
(虽然愿意也可以)。
4-B 锁保护字段 —— 初始化后只读
只要初始化后不再写 routing,可以把 ③ 的锁去掉,改为局部变量保留引用再查询,仍然安全。
5 对比:事实不可变 vs. 真不可变
点 | 真不可变 ( String , 自定义 all-final) | 事实不可变 |
字段要求 | 全 final ;深度不可变 | 不强制 ( final 有助于意图表达) |
构造后可改? | 绝对不可 | 逻辑约定“不改”,技术上能改但禁止 |
发布后的读 | 无锁、绝对安全 | 无锁、只要不再改也安全 |
内存安全 | 100%(编译器可假设常量) | 程序员需自律;误改会破坏全局一致性 |
适用 | 值对象、Key、缓存快照 | 运行期快照、DTO、配置 |
6 使用指南 ✔
- 先问自己 “发布后还会不会改?”
- 不会 → 直接当事实不可变处理。
- 可能 → 继续用锁 / 原子变量维护。
- 发布前填满所有状态;构造期做必要的防御性拷贝。
- 发布动作使用安全机制(静态 init / volatile / final 链 / 锁)。
- 绝不提供 setter,也不暴露可写集合/数组。
- 在代码审查中明确标注:
// after publish, treat as immutable
.
结论
- 把“一旦就绪就永不修改”的对象抽象为 事实不可变,
- 再配合 JMM 的 安全发布,
- 既省掉了后续同步,也避免了硬性“全
final
”带来的开发麻烦,
- 是高并发代码中常用且高效的设计手段。
可变对象
下面用对照表 + 完整示例代码,说明三类对象的发布与后续访问策略。
对象类型 | 典型特征 | 发布要求 | 后续访问 | 示例 |
不可变 (Immutable) | 所有字段 final 、无可变引用、构造期不泄漏 this | 任意 方式都行( static final / 普通赋值等) | 完全无锁 | Money , String , LocalDate |
事实不可变 (Effectively Immutable) | 字段不一定 final ,但发布后“再也不改” | 必须 安全发布(静态 init、 volatile 、AtomicRef 、锁…) | 无锁读即可 | 配置快照、DTO、事件对象 |
可变 (Mutable) | 状态随时能改 | 必须 安全发布 | 修改、读取都要靠线程安全机制(锁、原子类、并发容器或对象自身线程安全) | 计数器、缓存 Map |
1. 不可变对象:无论怎么丢都安全
- 后续访问:
Point p = SomeClass.ORIGIN;
—— 不需任何同步。
2. 事实不可变对象:安全发布一次,之后当成只读
- 要点
volatile
写/读 保证 发布时 的状态对所有线程可见。- 规则:快照一旦存入
CURRENT
就 不再更改其内部;更新 = 构造新对象再替换。
3. 可变对象:发布、使用都得同步
3-A. 用锁保护
3-B. 借助线程安全类
- 后续访问:始终通过
inc()
/snapshot()
—— 已内置同步。
4. 决策速查
- 能否保证发布后永不修改?
- 是 → 走 事实不可变 路线;安全发布一次即可;随后无锁读。
- 否 → 走 可变 路线;既要安全发布,又要持续同步或用线程安全容器/类。
- 是否能设计成真正不可变?
- 能 → 直接不可变,省去发布和同步烦恼。
记忆口诀
- Immutable:怎么发都行,一辈子只读。
- Effectively Immutable:安全发一次,之后别改。
- Mutable:安全发 + 持续同步,任何时候都要守住锁/原子性。
安全地共享对象
给对象“贴上标签”:4 种典型共享策略
策略 | 谁能访问 | 允许的操作 | 需要的同步 | 典型实现 | 关键风险点 |
1. 线程封闭(Thread Confinement) | 仅 1 个线程 | 读 / 写 | 无 | 局部变量、 ThreadLocal 、Actor 模型的私有状态 | 将引用泄漏到别的线程(回调、共享集合) |
2. 只读共享(Shared Read-Only) | 多线程 | 只读 | 无(安全发布一次) | 不可变类、配置快照、DTO | 发布后偷偷写、返回可写集合/数组 |
3. 线程安全共享(Thread-Safe) | 多线程 | 读 / 写 | 内部已同步 | ConcurrentHashMap 、Atomic* 、同步方法 | 组合多个原子操作时仍需额外加锁 |
4. 受保护共享(Guarded) | 多线程 | 读 / 写 | 必须拿指定锁 | 成员字段受 synchronized(lock){…} 保护 | 忘记持锁;改用不同锁;锁泄漏 |
发布原则在把对象引用交给别人之前,明确告知调用方:
- “这是一份只读数据,随便用”;
- “这是线程安全对象,可直接并发调用”;
- “读写它前必须拿
cacheLock
”;
- “别把它传出本线程”。
1⃣ 线程封闭示例
fmt
永远活在任务线程的栈上,无需同步。
- 别把
fmt
缓存到静态字段或传给别的线程!
2⃣ 只读共享示例(事实不可变 + 安全发布)
3⃣ 线程安全共享示例
- 调用者无需额外同步;所有并发保障藏在
LongAdder
内部。
- 组合操作(例如 “读后清零”)仍应加锁或使用
synchronized
包裹。
4⃣ 受保护共享示例
- 规则:任何直接或间接访问
map
的代码块都必须synchronized(lock)
。
- 推荐在 Javadoc 或方法命名中显式声明:“该实例由 lock 保护”。
如何让“既定规则”一目了然?
- 命名
ImmutableOrder
,ThreadSafeCounter
,CacheGuard
…
- Javadoc / 注解
常用注解:
@Immutable
, @ThreadSafe
, @GuardedBy("lock")
.- 包可见性 + 工厂
只能通过受控 API 获取对象,防止绕过规则。
- 单元 / 并发测试
用
jcstress
、JUnit
+ 多线程确保规则没被破坏。小结
- 先定策略,再写代码。没想清楚“谁能改”“怎么同步”就去分享引用,是并发 BUG 的根源。
- 发布时说清楚:只读?线程安全?需要锁?
- 调用方照规则办:只读不写、要锁就锁、线程安全可直接用。