软件技术 · 知识点深化 · 并发编程
线程安全设计模式:不可变、线程封闭、共享发布
写线程安全代码不只会加锁。不可变对象天生线程安全,线程封闭让数据根本不共享,安全发布保证对象初始化后才被别人看到。这一页把三种根本策略讲透,比死记 synchronized 有用得多。
① 怎么学(4 步走,约 60 分钟)
先建立直觉再抠细节,按这四步走最稳:
1看图建立直觉(10 分钟)
读②③:先在脑子里画出本课核心结构图。
2记完整体系(15 分钟)
读④:把对比表和公式看懂,不要急着背。
3跟例题走一遍(20 分钟)
精读⑤:看三个例题怎么用知识点解题。
4刷题纠错(剩余时间)
做⑦⑩,错题回⑥诊断。
本课小目标学完你要能:① 说出线程安全三大策略;② 解释不可变对象为什么安全;③ 区分 ThreadLocal 与 synchronized;④ 列举安全发布的几种方式。
② 一图看懂:线程安全三大策略
读法:中心是本课主题,四条分支展开核心维度,下方是典型应用与高频易错点。
③ 本质直觉:线程安全的根本是"别让人乱抢"
线程安全问题只发生在多线程共享可变数据的时候。所以解决思路有三个方向:
1. 让数据不可变:对象一旦创建就不能改,谁读都一样,根本不需要锁。像 String、Integer 都是不可变类。
2. 让数据别共享:每个线程自己一份,别人碰不到。ThreadLocal 就是这个思路——每个线程有自己的 Map,存自己的变量。
3. 必须共享时,控制好发布时机:对象还没初始化完别让别的线程看到。这就是 DCL 单例要 volatile 的原因。
锁是最后手段,不是第一选择。能不可变就不可变,能封闭就封闭,实在要共享再考虑加锁。
优先顺序不可变 > 线程封闭 > 安全发布 > 加锁。越靠前性能越好、bug 越少。
④ 完整知识体系
三大策略对比
| 策略 | 原理 | 典型例子 |
| 不可变 | 所有字段 final,状态不修改 | String、Integer、LocalDate |
| 线程封闭 | 数据只在单线程内使用 | ThreadLocal、栈封闭 |
| 安全发布 | 初始化完成后再共享 | 静态枚举、volatile 引用 |
| 加锁 | 互斥访问可变共享数据 | synchronized、Lock |
不可变对象三要素所有字段 private final + 类不允许子类化 + 不暴露可变引用
ThreadLocal 结构每个 Thread 有一个 ThreadLocalMap,key 是 ThreadLocal 实例
安全发布的几种方式
① 静态初始化器里创建对象(JVM 保证);② volatile 引用或 AtomicReference;③ 放到 final 字段里(构造函数完成后);④ 放到线程安全容器(ConcurrentHashMap)。
ThreadLocal 内存泄漏
ThreadLocalMap 的 key 是弱引用,value 是强引用。线程池线程不销毁,value 一直存在。用完必须 remove()。
⑤ 应用场景与例题
例1 String 为什么是线程安全的?
多线程读 String 要加锁吗?
不需要。String 所有字段 final,创建后不可修改。任何线程看到的都是同一个不可变状态,没有竞态条件。
例2 ThreadLocal 为什么能避免锁?
每个线程一份变量,别人看不到。
ThreadLocal 为每个线程维护独立副本,A 线程 set 的值只有 A 能 get。天然不共享,不需要锁。典型场景:数据库连接、用户上下文。
例3 DCL 单例为什么是安全发布问题?
不加 volatile 会怎样?
new 对象三步重排可能导致引用先赋值、对象后初始化。另一个线程通过不安全发布拿到半初始化对象。volatile 保证初始化完成后引用才可见。
做题心法先问能不能不可变,再问能不能不共享,最后才考虑锁。
⑥ 高频错误诊断(4 条)
错误1:什么共享变量都加 synchronized过度同步降低性能。优先考虑不可变或线程封闭。
错误2:ThreadLocal 用完不 remove线程池复用线程时,value 残留导致内存泄漏和脏数据。
错误3:发布对象时没等初始化完构造函数里 this 逸出(在构造函数中启动线程引用 this)。
错误4:以为 final 字段就完全安全final 保证构造过程的安全发布,但如果引用的对象本身可变,仍需同步。
⑦ 考点真题演练(5 题)
考点分布
| 考法 | 出题形式 | 应对 |
| 不可变 | 问为什么安全 | 状态不变,无竞态 |
| 线程封闭 | 问 ThreadLocal 原理 | 每线程独立副本 |
| 安全发布 | 问 DCL 为什么要 volatile | 防半初始化 |
| 内存泄漏 | 问 ThreadLocal 为什么泄漏 | key 弱 value 强+线程池 |
真题basic1. 以下哪种对象天生线程安全?
真题mid2. ThreadLocal 用完后应该?
真题mid3. DCL 单例中 volatile 的作用是?
真题hard4. 以下哪个不是安全发布方式?
真题hard5. 不可变对象的必要条件不包括?
⑧ 必背知识点卡
三策略:不可变 > 封闭 > 发布 > 加锁
不可变:final 字段,状态不改 String/LocalDate
封闭:ThreadLocal 每线程副本 用完 remove
发布:初始化完再共享 DCL 要 volatile
锁:最后手段,别滥用
逸出:构造函数中 this 传给其他线程 危险
ThreadLocalMap:key 弱引用 value 强引用 线程池泄漏
⑨ 动手输出:设计一个用户上下文的线程安全方案
场景:Web 请求中要把当前用户 ID 传到下游各层,怎么设计?
① 选型:用 ThreadLocal 存 userId,每个请求线程独立。
② 为什么不用锁:请求间互不干扰,不需要共享,加锁反而慢。
③ 生命周期:请求进入时 set,请求结束时 finally remove。
④ 线程池风险:Tomcat 线程复用,不 remove 会串用户。
⑤ 备选:如果要跨线程池传递,用 TransmittableThreadLocal。
口述思路"数据不共享就不需要锁,ThreadLocal 是最优雅的封闭方案。"
⑩ 分层练习(基础 + 中档 + 拔高)
▍基础 6 题
基础1线程安全问题的根源是什么?
多线程共享可变数据。
基础2不可变对象为什么安全?
状态不变,无竞态条件。
基础3ThreadLocal 用完要做什么?
remove()。
基础4安全发布是什么意思?
对象初始化完成后再被其他线程看到。
基础5String 是可变还是不可变?
不可变。
▍中档 5 题
中档1ThreadLocal 怎么实现每线程副本?
每个 Thread 有 ThreadLocalMap,key 是 ThreadLocal。
中档2为什么构造函数里 this 逸出危险?
其他线程可能看到未初始化完的对象。
中档3volatile 算安全发布吗?
算,它保证可见性和有序性。
中档4栈封闭是什么?
局部变量在栈上,线程私有,天然安全。
中档5为什么优先用不可变?
不需要锁,性能好,没有竞态。
▍拔高 5 题
拔高1ThreadLocal 内存泄漏原理?
Entry key 是弱引用被 GC,value 强引用无法回收。
拔高2TransmittableThreadLocal 解决什么?
线程池线程复用后 ThreadLocal 不传递的问题。
拔高3final 字段的安全发布语义?
构造函数结束后 final 字段一定对其他线程可见。
拔高4为什么 StringBuilder 线程不安全而 StringBuffer 安全?
StringBuffer 加了 synchronized。
拔高5安全发布和不可变有什么区别?
不可变是对象本身状态不变;安全发布是把对象分享出去的时机正确。
⑪ 记忆口诀 + 7 天复习计划
三句口诀① 不可变来帮忙,封闭不共享。② 安全发布初始化完,volatile 挡住重排。③ ThreadLocal 用完 remove,构造函数别逸出。
| 天 | 任务 | 自检 |
| 第 1 天 | 读②③,画三策略关系图 | 优先级说清 |
| 第 2 天 | 背策略表 + 基础 6 题 | 能举例 |
| 第 3 天 | 做中档 5 题,写一个 ThreadLocal 工具类 | 代码正确 |
| 第 4 天 | 做拔高 5 题,解释内存泄漏原理 | 能画 Entry |
| 第 5 天 | 做⑦真题 5 题 | 限时每题 2 分钟 |
| 第 6-7 天 | 合书口述 DCL 为什么要 volatile | 不看资料 |
← 返回软件技术总览