JAVA · Vol.II · DAY 22 · 并发编程
线程安全设计模式:不可变对象、Thread-safe Singleton、生产者-消费者
一场订单状态错乱的事故:线程安全是设计出来的
某电商系统上线后,运营发现偶发"订单状态错乱":一笔已支付(PAID)的订单,几分钟后竟变成了已取消(CANCELLED)。排查发现,订单对象是一个共享的可变对象,被多个线程同时读写——线程 A 把状态改成"已支付"后还没写完,线程 B 又把状态改成别的值,最后写回的顺序完全失控。更常见的还有两起:DCL 单例忘了写 volatile,线上偶发 NPE,重启才能恢复;SimpleDateFormat 被声明成 static 共享,多线程解析日期,解析出来的时间错乱。
这三类事故的根子都不是"锁没用好",而是设计上允许了共享可变状态(Shared Mutable State)存在。真正的并发高手,第一反应不是"哪里要加锁",而是"能不能让这里根本没有共享、没有可变"。
"面试问到线程安全,很多人第一反应就是 synchronized。但真正的高手知道——最好的线程安全,是从设计上就不需要加锁。"
加锁(synchronized、ReentrantLock)只是解决线程安全的"最后手段"。锁意味着竞争,竞争意味着上下文切换和性能损耗,用不好还会带来死锁。Doug Lea 说过:"线程安全的最高境界不是把锁用好,而是让共享可变状态尽量少出现。"
把"线程安全"的手段按成本从低到高排成一座金字塔,这就是本文的路线图:
用餐厅后厨打个比方:一把公共菜刀(共享可变状态),三个厨师抢着用,就得排队等锁、还可能打架;给每个厨师发一把自己的刀(线程局部),互不干扰;而"切好的配菜"(不可变快照)谁都能随手拿,完全不用抢。本文的每一站,都是在教你少造"公共菜刀"、多造"配菜"。
本文将覆盖:不可变对象、单例 5 种写法、生产者-消费者(Producer-Consumer)、并发容器(ConcurrentHashMap / CopyOnWriteArrayList / ThreadLocal)、编码军规与生产落地。
不是考你会不会写 synchronized,而是考你有没有设计并发程序的思维:能否从架构层面减少共享状态?能否在"不可变 / 隔离 / 并发容器 / 加锁"之间做对选择?本文每一站都按这个思路展开。
不可变对象:零开销的线程安全
如果对象创建后状态永远不能改变,那么无论多少线程同时访问它都不会有线程安全问题——没有"写"操作,就不存在数据竞争(Data Race)。这种对象叫不可变对象(Immutable Object)。JDK 里 String、Integer、BigDecimal、LocalDate、DateTimeFormatter 都是不可变类——它们能被 static final 挂在类上供所有线程随便读,正是这个原因。
public final class OrderSnapshot { // 规则 1:final 类,禁止继承 private final String orderId; // 规则 2:所有字段 private final private final BigDecimal totalAmount; private final LocalDateTime createTime; private final List<String> items; // 可变引用字段!规则 3 的重点 public OrderSnapshot(String id, BigDecimal amt, LocalDateTime time, List<String> items) { this.orderId = id; this.totalAmount = amt; this.createTime = time; this.items = List.copyOf(items); // 规则 3:防御性拷贝(Defensive Copy) } // 规则 4:只提供 getter,不提供 setter public String getOrderId() { return orderId; } public BigDecimal getTotalAmount() { return totalAmount; } public List<String> getItems() { return items; } // copyOf 返回的就是不可变 List }
线程安全的本质是防止多线程同时修改共享状态。如果状态根本不能修改,就不存在"同时修改"的问题。不可变对象可被任意多线程同时读取,无需任何同步——这是零开销(Zero-Cost)的线程安全,比任何锁都快。
注意一个容易混淆的点:不可变 ≠ 字段不改变,而是"对外不可变"。比如 String 的 substring() 返回的是新对象,原对象纹丝不动;BigDecimal.add() 返回新值,不会改掉自己。约定是:任何"修改"都返回新对象,绝不改动自身。
DTO / VO / 快照 / 事件对象 / 配置项,这些"只读一次、到处分发"的数据都应该设计成不可变:① 天然线程安全,随便放缓存、随便被并行读;② 值语义稳定,做 equals()/hashCode() 安全;③ 配合函数式风格(如 Stream、CompletableFuture)时不会产生并发副作用。这也是为什么 LocalDate 替代 Date、DateTimeFormatter 替代 SimpleDateFormat——后者就是吃了"可变 + 非线程安全"的亏。
防御性拷贝:可变引用字段怎么防
上一站的 List<String> items 是个可变引用字段——它本身是 final,指向的却是别人传进来的 ArrayList。如果不做防御性拷贝(Defensive Copy),外部改这个 List,就等于改了你"不可变"对象里的状态,"不可变"就名存实亡了。
追问:private final List<String> list 字段,为什么对象还不是不可变的?
final 保证的是"引用不能换",不保证"引用指向的东西不能被改"。外面的人把同一个 ArrayList 传进来,再通过 getItems() 拿回去,两边拿着同一个数组,任意一方 add/remove,另一方立刻"被修改"。要防住,就得在两个口子都做拷贝:进(构造时拷贝)和出(getter 时拷贝/返回不可变视图)。
// ✗ 错误:入口没拷贝,getter 直接泄漏内部引用 public class BadConfig { private final List<String> blackList; public BadConfig(List<String> list) { this.blackList = list; } // 外部还能改! public List<String> getBlackList() { return blackList; } // 内部被泄漏! } // ✓ 正确:入口 copyOf,getter 返回的本来就是不可变 List public class SafeConfig { private final List<String> blackList; public SafeConfig(List<String> list) { this.blackList = List.copyOf(list); // 入口拷贝一份,与外部彻底切断 } public List<String> getBlackList() { return blackList; } // 内部本身就是不可变 List }
数组字段同理,getter 用 clone() 返回副本;Date / 集合这类可变引用字段,标准做法就是"进也拷、出也拷"。
Collections.unmodifiableList(list) 只是给原 list 套了一层只读视图(View)——通过视图调 add 会抛异常,但持有原 list 的人仍然能改它,改了视图也能"看到"。真正不可变的是 Java 9+ 的 List.of() / List.copyOf()(以及 Set.of、Map.of),它们拷贝后连底层引用都不留。所以别拿 unmodifiable 冒充不可变。
真实案例:路由配置类把 List<Route> 直接塞进字段,结果另一个线程在"热更新配置"时往这个 List 里 add,正在遍历路由的请求线程立刻 ConcurrentModificationException,网关 502 了一分钟。修法就是上面这版 copyOf + 不可变视图,一行改动根治。
单例:并发面试的常青树,从饿汉式开始
单例(Singleton)是并发面试的必考题,因为单例 = 全局共享 = 并发访问点。考单例,本质是考"你怎么管理共享状态"。单例有 5 种主流写法,我们一站一种,从最简单的开始。
写法一:饿汉式(Eager Initialization)——类加载即创建
public class EagerSingleton { private static final EagerSingleton INSTANCE = new EagerSingleton(); private EagerSingleton() {} public static EagerSingleton getInstance() { return INSTANCE; } } // 优点:简单可靠、天然线程安全 | 缺点:类一加载就创建,可能白占内存
为什么它线程安全?因为 static final 字段在类初始化阶段创建,而 JVM 保证类的 <clinit> 只执行一次、且对并发访问有锁保护(同一时刻只有一个线程能执行类的初始化)。所以饿汉式的线程安全是 JVM 层面白送的,一行同步代码都不用写。
代价是没有延迟加载:只要类被引用(哪怕只是 EagerSingleton.class),实例就创建了。如果这个单例很重(连接池、加载大配置),而程序启动后根本没用它,就白白浪费了内存和启动时间。但反过来说,如果实例很轻、又几乎必用,饿汉式反而是最稳妥的选择——比如常量类、工具类。
懒汉式:synchronized 的代价
想要延迟加载,最直接的想法是"用到才建",但直接写会有竞态,于是给方法加 synchronized——这就是懒汉式(Lazy Initialization):
public class LazySingleton { private static LazySingleton instance; private LazySingleton() {} public static synchronized LazySingleton getInstance() { if (instance == null) instance = new LazySingleton(); return instance; } } // 优点:延迟加载 | 缺点:每次调用都过锁,高并发下是热点
问题在于 synchronized 加在了整个方法上:实例早就创建好了,后面每次 getInstance() 依然要排队过锁。读一个已经存在的引用,本来是无锁操作,却被锁串行化了——一百个线程同时取单例,就是一个接一个排队,吞吐量直线下降。
注意别说过时结论:现代 JVM 对 synchronized 做了大量优化(锁升级、偏向锁等),方法级加锁并不是"慢成狗"。但偏向锁从 JDK 15 起默认关闭、JDK 18 起废弃移除,锁的语义开销回归现实;更关键的是——锁住一个本不需要锁的操作,是设计问题,不是性能问题。后面几站都是在解决"怎么把锁去掉"。
DCL:volatile 与半初始化对象
双重检查锁(Double-Checked Locking,DCL)的思路:先无锁检查一次,非空直接返回(快路径);为空才进锁,进锁后再检查一次(慢路径)。这样只有"第一次创建"才加锁:
public class DclSingleton { private static volatile DclSingleton instance; // 必须 volatile! private DclSingleton() {} public static DclSingleton getInstance() { if (instance == null) { // 第一次检查(无锁,快路径) synchronized (DclSingleton.class) { if (instance == null) // 第二次检查(有锁,慢路径) instance = new DclSingleton(); } } return instance; } }
为什么必须双重检查?因为两个线程可能同时通过第一层检查(都看到 null),如果第二层不检查,就会创建出两个实例。为什么第二层要在锁内?因为不加锁的 instance = new ... 不是原子的,会被重排。
new DclSingleton() 在字节码层面拆成三步:①
new:分配内存(此时对象是"半成品")②
invokespecial:调用构造器,完成初始化③
putstatic:把引用赋值给 instance
JIT 允许把 ②③ 重排成 ①③②:先让引用"指过去",再回头初始化。于是出现经典事故——
线程 A 执行到 ③(instance 已经非 null,但构造器还没跑完);线程 B 第一层检查发现 instance != null,直接返回这个半成品——字段还是默认值,用的时候 NPE。解决:字段加 volatile,利用 volatile 写-读的 happens-before 语义,禁止构造器相关指令重排到赋值之后(JSR-133 之后 volatile 才具备这个能力,所以 DCL 在 JDK 5+ 才真正成立,这是个常被追问的版本事实)。
DCL 是"既要延迟加载、又不想每次加锁"的经典解,但有两个短板:① volatile 容易忘写,忘写就埋雷;② 代码读起来绕。所以很多团队(包括阿里规约的思路)在生产里更推荐后面两种写法——静态内部类或枚举,它们把同样的事情交给 JVM 做,一行锁都不用写。
静态内部类:无锁的延迟加载
静态内部类(Static Nested Class Holder)写法,业界也叫 Holder 模式:把实例放在一个私有的静态内部类里,让 JVM 的类加载机制同时搞定"延迟"和"线程安全":
public class HolderSingleton { private HolderSingleton() {} private static class Holder { private static final HolderSingleton INSTANCE = new HolderSingleton(); } public static HolderSingleton getInstance() { return Holder.INSTANCE; } } // 延迟加载 + ClassLoader 保证线程安全 + 无锁
原理一句话:静态内部类只有在被引用时才会被加载。HolderSingleton 加载时,Holder 什么都不做;只有第一次调用 getInstance(),才触发 Holder 的类加载,此时 JVM 执行 Holder 的类初始化(创建 INSTANCE),而类初始化天然线程安全(同一类只会被初始化一次,多个线程会阻塞等待初始化完成)。
饿汉式是"类加载就创建",Holder 是"用到才触发创建"——同样是 ClassLoader 白送的线程安全,Holder 多了一个"延迟"能力,而且没有锁、没有 volatile。延迟加载 + 线程安全 + 零同步代码,这就是它被生产环境偏爱的原因。
枚举单例:为什么它是综合最优
枚举(Enum)单例是 Joshua Bloch 在《Effective Java》里力荐的写法,也是面试里"你推荐哪种单例"的标准答案:
public enum ConfigSingleton { INSTANCE; // 唯一的实例,JVM 保证 private final Map<String, String> cache = new ConcurrentHashMap<>(); // 单例里可以带自己的状态 public String get(String key) { return cache.get(key); } public void put(String key, String value) { cache.put(key, value); } } // 使用:ConfigSingleton.INSTANCE.get("timeout");
为什么它最推荐?四个"天然":
- 天然唯一:枚举常量在类加载时由 JVM 创建且只创建一次,多线程并发取
INSTANCE绝对只有一个实例; - 天然防反射:反射
Constructor.newInstance()创建枚举会直接抛IllegalArgumentException: Cannot reflectively create enum objects——JDK 在语言层面禁止了这条路; - 天然防序列化破坏:枚举的序列化只写出枚举名字,反序列化时走
Enum.valueOf()找回 JVM 里已有的那个常量,绝不会 new 出第二个实例; - 天然线程安全:同样由类加载机制保证,且实例化时机可控(首次访问枚举常量时)。
饿汉 / 懒汉 / DCL / 静态内部类,本质都是"普通类 + 静态字段",都能被两个手段打破单例:① 反射:setAccessible(true) 后调私有构造器,new 出第二个实例;② 序列化:实现 Serializable 后反序列化会绕过构造器生成新实例(需要 readResolve() 才能兜住)。枚举把这两条路都在语言层面堵死了——这就是"枚举最推荐"的底气。代价只有一个:枚举不能继承其它类(单继承限制),需要继承的场景才退回去用静态内部类。
不需要继承 → 用枚举;框架限制不能用枚举(如部分老代码风格)→ 用静态内部类;实例极轻且必用 → 饿汉式也完全没问题;DCL 只在面试题里当主角,生产慎写。
五种写法对比总表与生产选型
把 5 种写法放在一张图里,面试时"从入门到最佳实践"一口气讲完:
| 写法 | 线程安全 | 延迟加载 | 防反射 | 防序列化 | 适用场景 |
|---|---|---|---|---|---|
| 饿汉式 | ✅ ClassLoader | ❌ 类加载即建 | ❌ | ❌ | 实例轻、几乎必用(常量/工具类) |
| 懒汉式 | ✅ 方法级 synchronized | ✅ | ❌ | ❌ | 几乎不推荐,每次调用过锁 |
| DCL | ✅ volatile + 锁 | ✅ | ❌ | ❌ | 面试常考;生产慎用(易忘 volatile) |
| 静态内部类 | ✅ ClassLoader | ✅ | ❌ | ❌ | 需要延迟加载且不能用枚举时 |
| 枚举 | ✅ JVM 保证 | ✅(首次访问常量时) | ✅ 语言层面禁止 | ✅ 反序列化返回同实例 | 默认首选 |
"不需要延迟加载选饿汉式;需要延迟加载且 JDK 允许用枚举——天然防反射、防序列化;框架限制不能用枚举时用静态内部类。DCL 容易写错 volatile,生产环境谨慎使用。"
单例的坑:有状态单例、反射与序列化破坏
选型只是第一步,单例真正在线上出问题,往往是下面这几个坑。
坑 1:有状态单例 = 定时炸弹
单例是全 JVM 共享的,如果单例里放了可变字段,就等于把共享可变状态做成了全局的——多线程一读写就竞态。最典型的就是 static SimpleDateFormat(内部有可变 Calendar 字段,非线程安全)和"全局计数器"。
// ✗ 定时炸弹:单例里的可变字段被多线程乱改 public enum Counter { INSTANCE; private int count; // 可变状态!多个线程 ++count 会丢更新 public int incr() { return ++count; } } // ✓ 正确:状态要么外移,要么用并发原子工具 public enum Counter { INSTANCE; private final AtomicLong count = new AtomicLong(); // 原子自增,无锁且安全 public long incr() { return count.incrementAndGet(); } }
坑 2:反射 / 序列化破坏(非枚举写法)
普通类单例都能被反射绕过私有构造器,new 出第二份:
// 攻击:反射调用私有构造器,绕过 getInstance Constructor<EagerSingleton> c = EagerSingleton.class.getDeclaredConstructor(); c.setAccessible(true); EagerSingleton second = c.newInstance(); // 第二个实例诞生! // 防御:构造器里加一道"只允许创建一次"的检查(加锁保证并发安全) private static boolean created; private EagerSingleton() { synchronized (EagerSingleton.class) { if (created) throw new IllegalStateException("单例已被创建!"); created = true; } }
序列化破坏同理:对象实现 Serializable 后,反序列化会绕过构造器生成新实例,需要在类里补一个 readResolve() 返回原实例。这些补丁代码写多了,你就明白为什么枚举单例最省心——它把这些防御都做进了语言里。
坑 3:单例 ≠ 全 JVM 唯一
面试追问:"什么情况下单例会变成多例?"答案至少三个:① 多 ClassLoader(如 Tomcat 每个 WebApp 一个 ClassLoader,同一个类各加载一份);② 反射/序列化(上面讲的);③ 分布式部署——每台 JVM 一个实例,跨进程的"全局唯一"必须靠数据库唯一键、Redis 锁、ZooKeeper 之类的分布式手段,单例模式管不到。
Spring 容器里 @Bean 默认是"单例"(Singleton Scope),含义是"每个容器一个实例",不是 JVM 级单例——两个 Spring 容器 / 两个 ClassLoader 下会有多份。另外 Spring 单例 Bean 默认无状态(stateful 要小心),这和单例模式的"全局唯一"是两码事,别混为一谈。
生产者-消费者:BlockingQueue 完整可运行示例
生产者-消费者(Producer-Consumer)是最经典的并发协作模式:生产者负责生产数据放入缓冲区,消费者从缓冲区取出处理。缓冲区满时生产者阻塞,空时消费者阻塞——这个"满/空"的同步逻辑,BlockingQueue(阻塞队列)已经全部封装好了,你不需要写一行 wait/notify。
下面是可以直接复制运行的完整示例(含 main 和优雅退出):2 个生产者各产 5 条消息,2 个消费者并行消费,生产者干完投"毒丸(Poison Pill)"通知消费者下班:
import java.util.concurrent.*; public class ProducerConsumerDemo { private static final String POISON = "POISON"; // 毒丸:通知消费者下班 public static void main(String[] args) throws Exception { BlockingQueue<String> queue = new ArrayBlockingQueue<>(10); // 有界队列 ExecutorService pool = Executors.newFixedThreadPool(4); // 2 个生产者:各产 5 条,产完投一颗毒丸 for (int p = 1; p <= 2; p++) { final int no = p; pool.submit(() -> { try { for (int i = 1; i <= 5; i++) { queue.put("P" + no + "-item-" + i); // 满时阻塞 } queue.put(POISON); // 告诉消费者:没有更多数据了 } catch (InterruptedException e) { Thread.currentThread().interrupt(); // 恢复中断标志 } }); } // 2 个消费者:吃到毒丸就退出 for (int c = 1; c <= 2; c++) { final int no = c; pool.submit(() -> { try { while (true) { String data = queue.take(); // 空时阻塞 if (POISON.equals(data)) break; // 下班! System.out.println("C" + no + " 消费: " + data); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }); } pool.shutdown(); // 不再接收新任务 pool.awaitTermination(10, TimeUnit.SECONDS); // 等全部任务跑完 } }
take() 是阻塞的,生产者干完活后消费者会永远等下去,程序退不掉。毒丸就是一条约定好的"结束信号"消息:生产者发完数据后补发一颗毒丸,消费者收到就 break。多少个消费者就发多少颗毒丸(这里是 2 颗)。这是生产环境常用的优雅停机技巧,比 shutdownNow 硬中断更平滑。
别忘了 InterruptedException 的处理规范:捕获后要 Thread.currentThread().interrupt() 恢复中断标志,不能吞掉——否则上层代码无法感知中断。这是阿里规约明确要求的一条。
阻塞队列家族与反压
选队列先看懂 BlockingQueue 的四组 API,它们的区别就是"队列满/空时怎么办":
| 方法组 | 队列满/空时的行为 | 适用场景 |
|---|---|---|
add() / remove() | 直接抛 IllegalStateException / NoSuchElementException | 不允许失败、立即报错 |
offer() / poll() | 返回 false / null(非阻塞探测) | 不想等待,失败就下次再来 |
put() / take() | 阻塞等待,直到有空间/有数据 | 生产者-消费者(最常用) |
offer(e, t, unit) / poll(t, unit) | 等待最多 t 时间,超时返回 false/null | 带超时上限的等待,防无限阻塞 |
再看主流实现怎么选:
| 实现 | 有界/无界 | 特点 | 适用场景 |
|---|---|---|---|
| ArrayBlockingQueue | 有界(必须指定容量) | 数组实现,一把锁,公平可配 | 通用生产者-消费者,首选 |
| LinkedBlockingQueue | 可选(默认无界) | 链表实现,两把锁(put/take 分离) | 高吞吐,但生产必须指定容量 |
| SynchronousQueue | 容量 0 | 不存数据,生产者和消费者直接交接 | newCachedThreadPool 底层用它 |
| PriorityBlockingQueue | 无界 | 按优先级出队 | 任务带优先级 |
| DelayQueue | 无界 | 元素到延迟时间才能取出 | 定时任务、延迟重试 |
无界队列在生产者速度远大于消费者时会无限增长,最终 OOM——这是 new LinkedBlockingQueue() 不带容量参数的常见事故。有界队列天然形成反压(Backpressure):队列满时生产者阻塞、被迫降速,系统自动达到"生产速率 = 消费速率"的平衡。就像高速公路收费站:车流太大时,排队让车辆自然限速,而不是让车全部挤进高速(内存)里爆掉。
日志异步落盘(Logback 的 AsyncAppender 底层就是有界 ArrayBlockingQueue,默认容量 256,满了丢弃告警)、任务分发队列、消息推送缓冲、削峰填谷——这些场景统一模板:有界队列 + put/take + 毒丸停机。别自己用 wait/notify 重造轮子,JDK 封装的队列在"锁粒度、条件队列、性能"上都比手写可靠。
分段锁与读写分离:ConcurrentHashMap 简述
前面的模式都是在"减少共享",但有些数据必须共享(缓存、注册表、计数器)。这时候的思路从"消灭共享"变成"精细地管理共享"——ConcurrentHashMap(CHM)就是教科书。
CHM 的演进本身就是一道高频面试题:
| 版本 | 锁策略 | 读操作 | 粒度 |
|---|---|---|---|
| JDK 7 | 分段锁(Segment Locking):整表切成若干 Segment(默认 16 段,每段继承 ReentrantLock),写操作只锁所在的段 | 需要加锁/加 volatile 保证可见性 | 段级(默认 16 个锁) |
| JDK 8 | 放弃分段锁:插入用 CAS,冲突时才 synchronized 锁单个桶头节点 | 完全无锁(Node 的 val/next 是 volatile) | 桶级(几百个锁) |
类比一下:JDK 7 像"图书馆按楼层锁门"——借一层楼的书,整层锁上;JDK 8 像"每个书架独立管理"——多数时候读者直接拿书(CAS 无锁),只有同一本书被抢着借(哈希冲突)才排队。锁粒度从"段"细化到"桶",并发度从 16 提到接近数组长度。
CHM 背后有个普适思想:读操作尽量不加锁,写操作才互斥——也就是读写分离。同样思想的还有 ReentrantReadWriteLock(读读共享、读写互斥,详见卷二读写锁那一篇)和"缓存 + DB"架构(读走缓存无锁,写落库加锁)。面试聊到 CHM,能顺带说出"读无锁 + 写锁桶 + 读写分离"这三点,段位就出来了。
// synchronizedMap:读和读之间也互斥!100 线程读也要排队 Map<String, String> syncMap = Collections.synchronizedMap(new HashMap<>()); // ConcurrentHashMap:100 线程可以同时读,互不影响 ConcurrentMap<String, String> chm = new ConcurrentHashMap<>(); // get() → 无锁读取(volatile 保证可见性) // put() → CAS + synchronized(只锁单个桶节点) // 注意:CHM 的 size()/isEmpty() 是弱一致的,别拿来做精确判断
Map 选 CHM,List 选 COW(读多写少),Queue 选 ConcurrentLinked,要阻塞就上 BlockingQueue。忘掉 Vector、Hashtable、synchronizedXxx。
CopyOnWrite 模式:读多写少的利器
写时复制(Copy-On-Write,COW)的思路:读操作永远无锁,写操作"复制一份新的再改,改完整体换掉引用"——读线程看到的永远是某个一致时刻的完整快照,永远不会读到"改了一半"的数据。代表性实现是 CopyOnWriteArrayList:
public boolean add(E e) { synchronized (lock) { // ① 写才加锁 Object[] es = getArray(); // ② 拿到当前数组 Object[] newElements = Arrays.copyOf(es, es.length + 1); newElements[es.length] = e; // ③ 复制 + 修改(原数组不动) setArray(newElements); // ④ volatile 替换引用,读线程立刻可见 return true; } } // get(i) 无锁:直接读 volatile 数组的某个下标
类比:文档的"另存为新版本"——写的人复制一份改,读者手里还是旧版本,谁都不会看到涂改到一半的稿子。或者像 Git:提交(写)生成新快照,所有历史快照不可变,任何时刻 checkout 出来的都是完整一致的状态。
| 维度 | CopyOnWriteArrayList | ArrayList + 手动加锁 |
|---|---|---|
| 读 | 无锁,O(1) | 每次读都要抢锁 |
| 写 | 加锁 + 复制整个数组 O(n) + 内存翻倍 | 加锁直接改,O(1) |
| 迭代器 | 弱一致性(看到创建时的快照),不抛 ConcurrentModificationException | 可能抛异常/读到中间状态 |
| 适合 | 读多写极少(配置、黑白名单、监听器列表) | 写频繁或数据量大 |
COW 不是万能药:写一次要复制整个数组,如果数组有 10 万元素、每秒写 100 次,就是每秒复制 1000 万次引用——GC 压力爆炸。所以它只适合"读 10000 次才写 1 次"的场景:网关黑白名单、路由规则、监听器注册表、配置快照。另外它的迭代器是弱一致的(Weakly Consistent),遍历期间看不到并发写,这是设计取舍,不是 bug。
典型落地:网关的热更新黑白名单用 CopyOnWriteArrayList 或 CopyOnWriteArraySet——配置中心推送新名单时整份换掉,正在校验的请求用旧名单,永不阻塞、永不错乱。记住判断标准:写频率 < 读频率的千分之一 才值得上 COW。
ThreadLocal 存储模式:每线程一个私有储物柜
如果一份数据本来就只属于当前线程(请求号、事务上下文、数据库连接),那根本不用共享——用线程局部存储(Thread-Local Storage),也就是 ThreadLocal。它让每个线程持有自己的副本,读自己的、写自己的,零竞争。
原理一句话(细节见卷二 ThreadLocal 深剖那一篇):每个 Thread 内部有一个 ThreadLocalMap,以 ThreadLocal 实例为 key、你的数据为 value。set 存进当前线程的 map,get 从当前线程的 map 取——天然隔离。
public class TraceContext { private static final ThreadLocal<String> TRACE_ID = new ThreadLocal<>(); public static void set(String id) { TRACE_ID.set(id); } public static String get() { return TRACE_ID.get(); } public static void clear() { TRACE_ID.remove(); } // 必须清理! } // 拦截器里:进入请求时 set,处理完 finally 里 remove try { TraceContext.set(requestId); // 本线程往后的日志都能带上 traceId handle(request); } finally { TraceContext.clear(); // 防止线程池复用后"串号" }
生产里 ThreadLocal 的典型位置:日志 MDC 的 traceId(链路追踪)、Spring 事务上下文(TransactionSynchronizationManager)、请求级用户信息、数据库连接复用。
ThreadLocal 配线程池有个大坑:线程池的线程是复用的,上一次请求在这个线程里 set 的值,下次请求还赖在这个线程的 map 里。于是 A 请求的 traceId 出现在 B 请求的日志里(串号)、A 的登录用户信息泄漏给 B(越权)。所以规范只有一条:用 ThreadLocal 必须成对写 try/finally + remove()。另外线程池场景要"父线程值传给子线程",用 InheritableThreadLocal;要跨线程池透传,用阿里的 TransmittableThreadLocal(都详见 ThreadLocal 深剖篇)。
线程安全编码六大军规
规则 1:最小化共享状态 —— 能线程局部就不共享,能局部变量就不实例变量。用 ThreadLocal 但记得 remove()。
规则 2:优先不可变对象 —— 不可变 = 天生线程安全 = 零同步开销。DTO、VO、配置对象都应是 Immutable。
规则 3:用并发集合代替同步包装 —— ConcurrentHashMap > synchronizedMap,锁粒度更细、算法更优。
规则 4:避免有状态单例 —— 有状态单例 = 定时炸弹。把状态外移到 AtomicInteger 或数据库。
规则 5:永远使用线程池 —— new Thread().start() ✘。手动创建线程无法控制并发数量,高峰期 OOM。
规则 6:缩小同步范围 —— synchronized 块越短越好,绝不在 synchronized 里做 IO。
// ✘ 反面:在 synchronized 里做 IO public synchronized void processOrder(Order order) { validateOrder(order); // 需要锁 updateInventory(order); // 需要锁 sendEmail(order.getUserEmail()); // 发邮件可能 2 秒!其他线程干等 } // ✔ 正面:只锁必要部分 public void processOrder(Order order) { synchronized (this) { validateOrder(order); updateInventory(order); } sendEmail(order.getUserEmail()); // IO 移到锁外 }
SimpleDateFormat 非线程安全(内部有可变 Calendar 字段)!定义为 static 共享,多线程 format()/parse() 会日期错乱。方案:用 Java 8 的 DateTimeFormatter(不可变,线程安全,推荐),或 ThreadLocal<SimpleDateFormat> 兜底。
| 常见陷阱 | 后果 | 正确做法 |
|---|---|---|
| DCL 忘写 volatile | 半初始化对象,NPE | instance 必须 volatile |
| static SimpleDateFormat | 日期解析错乱 | 用 DateTimeFormatter |
| 无界队列不限容量 | 生产者快于消费者时 OOM | 始终用有界队列 |
| 手动 new Thread() | 线程数失控 OOM | 使用线程池 |
| HashMap 多线程使用 | 数据丢失(JDK 8+) | 换 ConcurrentHashMap |
| synchronized 里做 IO | 吞吐量暴跌 | IO 移到锁外 |
| ThreadLocal 不 remove() | traceId 串号、内存泄漏 | try/finally + remove() |
| 内部可变 List 不防御拷贝 | 不可变对象被外部改穿 | 进/出双向拷贝(copyOf) |
模式落地地图:每个模式在真实系统里的位置
把本文所有模式放进一张"生产地图",面试时能说出"我在哪儿见过它"才是真懂:
| 模式 | 真实系统里的位置 | 具体例子 |
|---|---|---|
| 不可变对象 | DTO / VO / 快照 / 事件 / 配置项 | 订单快照、路由规则、接口出参对象 |
| 单例(枚举/静态内部类) | 工具类、常量池、全局客户端 | 配置加载器、Redis 客户端、注册中心连接 |
| 生产者-消费者 | 异步落盘、任务队列、削峰 | Logback AsyncAppender、日志采集、消息推送 |
| ConcurrentHashMap | 本地缓存、注册表、统计 | Spring BeanDefinition 存储、接口频控计数 |
| CopyOnWrite | 黑白名单、监听器、路由表 | 网关白名单、配置中心热更新 |
| ThreadLocal | 请求上下文、链路追踪、连接复用 | 日志 MDC traceId、Spring 事务上下文 |
| 读写分离 | 缓存 + 配置中心、读多写少存储 | 本地缓存 + 数据库、配置快照 |
| 加锁(最后手段) | 必须互斥的写操作 | 库存扣减、账户余额变动 |
这套思维在分布式系统里完全复用:不可变对象 ≈ 事件溯源里不可变的 Event;ThreadLocal ≈ 线程/租户隔离;CopyOnWrite ≈ 不可变基础设施(配置全量下发、蓝绿发布);分段锁 ≈ 分库分表(把一把大锁拆成 N 把小锁);反压 ≈ 消息队列的消费限速。并发模式从来不是 Java 独有,是"共享状态管理"的通用解法——这句话说出口,面试官会高看你一眼。
- 状态不变 → 不可变对象,零开销线程安全
- 全局唯一实例 → 枚举(最佳)或静态内部类(需延迟加载)
- 生产-消费解耦 → ArrayBlockingQueue(有界)
- 并发 Map → ConcurrentHashMap
- 读多写少 List → CopyOnWriteArrayList
- 线程私有数据 → ThreadLocal + finally remove()
- 万不得已才上锁,且锁范围越小越好
面试 30 秒总结
如果面试官问"你怎么保证线程安全?",用下面这段 30 秒版答案(背下来,然后按他的追问展开对应站点):
① 能用不可变对象就不用可变对象——DTO、配置、快照做成 Immutable,零同步开销;
② 必须共享的,优先用并发容器——ConcurrentHashMap、CopyOnWriteArrayList、BlockingQueue;
③ 线程私有数据用 ThreadLocal,但必须 try/finally remove();
④ 单例优先枚举(JVM 保证唯一、防反射、防序列化)或静态内部类;
⑤ 最后才考虑加锁,且锁范围越小越好、绝不在锁里做 IO。"
面试官顺着往下追的四个高频追问,一站一个答案:
- Q1:单例有哪几种写法? → 第 4–9 站:饿汉 / 懒汉 / DCL / 静态内部类 / 枚举,重点讲 DCL 的 volatile 和枚举为什么最优。
- Q2:DCL 为什么要 volatile? → 第 6 站:
new三步可能重排成"先赋引用后初始化",volatile 靠 happens-before 禁重排,防半初始化对象。 - Q3:CopyOnWriteArrayList 什么时候不能用? → 第 14 站:写频繁或数组大时,每次写复制整数组,内存翻倍、GC 压力大;它适合读多写极少。
- Q4:BlockingQueue 和普通队列有什么区别? → 第 12 站:put/take 会阻塞,天然提供"满/空"同步与反压;四组 API 的行为差异要能脱口而出。
面试官问线程安全,别急着背 API。先给出"金字塔"框架(不可变 → 线程局部 → 并发容器 → 加锁),再落一两个具体场景(如"我做过订单快照用不可变对象、日志用有界队列 + 毒丸停机")。有框架、有案例、有取舍,比背十条八股有用得多。
这一篇你掌握了什么
核心知识点回顾
- 线程安全金字塔:不可变对象(零成本)→ 线程局部(隔离)→ 并发容器(封装同步)→ 加锁(最后手段)。加锁解决不了设计问题,减少共享可变状态才是根本;
- 不可变对象:final 类 + 全部字段 private final + 防御性拷贝 + 只读 getter;可变引用字段要"进也拷、出也拷";
unmodifiableList只是视图,List.of()/copyOf()才是真不可变; - 单例 5 种写法:饿汉(ClassLoader 白送线程安全)、懒汉(每次过锁)、DCL(必须 volatile 防半初始化)、静态内部类(无锁延迟加载)、枚举(JVM 保证唯一 + 天然防反射/防序列化,综合最优);
- 单例的坑:有状态单例 = 定时炸弹(状态用 AtomicXXX 或外移);普通类单例可被反射/序列化破坏;多 ClassLoader 与分布式部署下"单例"不唯一;Spring 单例 ≠ 单例模式;
- 生产者-消费者:BlockingQueue 封装了满/空同步;四组 API(抛异常 / 返回 false / 阻塞 / 超时);有界队列天然形成反压防 OOM;毒丸模式优雅停机;
- 并发容器:CHM 从 JDK 7 分段锁演进到 JDK 8 的 CAS + 锁桶头、读完全无锁;CopyOnWriteArrayList 读无锁、写复制,适合读多写极少;ThreadLocal 线程私有,必须 finally remove() 防串号;
- 编码军规:最小化共享、优先不可变、并发集合替代同步包装、避免有状态单例、用线程池、缩小同步范围、锁外做 IO、DateTimeFormatter 替代 SimpleDateFormat。
一句话总结
线程安全不是"把锁用好",而是"让共享可变状态尽量少出现":先用不可变对象和线程局部把共享消灭在设计里,再用并发容器把剩下的共享管精细,最后才轮到锁——能用不可变,就别上锁。
👉 下一篇预告:Fork/Join 与并行 Stream——分治思想、工作窃取与并行流的五大陷阱。
Comments · 评论