📖目录
- 前言:你写的List,真的“安全”吗?
- 1. 翻车现场:ArrayList 在并发下的三种“死法”
- 1.1 三种典型现象(大白话解释)
- 2. 源码深挖:为什么 ArrayList 会翻车?
- 3. 2025 年线程安全集合全景图(主流方案对比)
- 4. 解决方案实战:四种方式修复你的代码
- 方案 1:`CopyOnWriteArrayList`(推荐)
- 方案 2:`Collections.synchronizedList()`
- 方案 3:显式使用 `ReentrantLock`
- 方案 4:改用队列(如果业务允许)
- 执行结果
- 5. 性能对比(实测数据)
- 6. 架构视角:线程安全集合的底层思想
- 7. 生产环境最佳实践
- 8. 延伸:不只是 List,这些集合也“有毒”
- 9. 经典书籍推荐
- 10. 结语
前言:你写的List,真的“安全”吗?
想象一下:你在超市排队结账,三个收银员同时处理你的购物车——一个往袋子里塞苹果,一个塞牛奶,还有一个在数商品数量。结果呢?袋子破了、商品漏了、总数对不上……甚至直接崩溃。
这正是多线程环境下使用ArrayList的真实写照。
很多 Java 程序员工作一两年就知道:“ArrayList 不是线程安全的,要用就用 Vector”。但到了 2025 年,这种认知早已过时。真正的高手,不是知道“不能用什么”,而是清楚“该用什么、为什么用、怎么用得更好”。
本文将带你:
- 重现经典的
ArrayList并发翻车现场; - 深入剖析问题根源(附源码级解读);
- 全面盘点2025 年主流线程安全集合方案;
- 提供可直接运行的验证代码 + 性能对比;
- 给出生产环境最佳实践建议。
1. 翻车现场:ArrayList 在并发下的三种“死法”
先说说为什么 ArrayList 是线程不安全的吧,来看以下的代码。:
importjava.util.ArrayList;importjava.util.List;publicclassTestArrayList{privatestaticList<Integer>list=newArrayList<>();publicstaticvoidmain(String[]args)throwsInterruptedException{for(inti=0;i<10;i++){testList();list.clear();}}privatestaticvoidtestList()throwsInterruptedException{Runnablerunnable=()->{for(inti=0;i<10000;i++){list.add(i);}};Threadt1=newThread(runnable);Threadt2=newThread(runnable);Threadt3=newThread(runnable);t1.start();t2.start();t3.start();t1.join();t2.join();t3.join();System.out.println(list.size());}}在本地运行 10 次,得到如下结果:
期望值是 30000(3 个线程 × 10000 次 add),但实际结果不仅远低于预期,而且每次都不一样——这就是典型的线程不安全表现。
1.1 三种典型现象(大白话解释)
| 现象 | 技术原因 | 生活比喻 |
|---|---|---|
程序崩溃(抛ArrayIndexOutOfBoundsException) | 多个线程同时扩容,导致数组越界写入 | 三个人同时往一个快满的行李箱塞衣服,没人协调,结果拉链崩开 |
| 数据丢失(size < 30000) | 多个线程写入同一索引位置,互相覆盖 | 三人同时在一张纸上写数字,后写的盖掉先写的 |
| 偶尔正确(size = 30000) | 纯属运气好,线程调度没冲突 | 三人恰好错开时间放东西,没撞上——但下次可能就翻车 |
💡关键点:即使没报错,也不代表安全!“偶尔正确”是最危险的假象。
2. 源码深挖:为什么 ArrayList 会翻车?
看ArrayList.add()的核心逻辑(JDK 17+):
privatevoidadd(Ee,Object[]elementData,ints){if(s==elementData.length)elementData=grow();// 扩容elementData[s]=e;// 写入size=s+1;// 更新 size}问题出在哪?
这三个操作不是原子的!在多线程下可能发生:
- 线程 A 读取
size = 100,准备写入 index=100; - 线程 B 也读取
size = 100,也准备写入 index=100; - A 先写,B 后写 →B 覆盖 A 的数据;
- 最终
size变成 101,但实际只存了 1 个新元素 →数据丢失。
更糟的是扩容阶段:
- A 判断需要扩容,开始
grow() - B 也在同一时刻判断需要扩容
- 两者各自创建新数组,但最终只有一个被赋值给
elementData - 另一个线程写入旧数组 →越界异常
🔍结论:
ArrayList的所有方法都无任何同步机制,天生不适合并发。
3. 2025 年线程安全集合全景图(主流方案对比)
别再只知道Vector了!以下是当前(截至 2025 年 12 月)生产环境推荐的线程安全 List 方案:
| 方案 | 原理 | 性能 | 适用场景 | 是否推荐 |
|---|---|---|---|---|
Vector | 方法加synchronized | ⭐☆☆☆☆(极低) | 遗留系统兼容 | ❌ 过时 |
Collections.synchronizedList() | 包装器 + 全局锁 | ⭐⭐☆☆☆(低) | 简单同步需求 | ⚠️ 谨慎 |
CopyOnWriteArrayList | 写时复制(COW) | ⭐⭐⭐⭐☆(读快写慢) | 读多写少(如监听器列表) | ✅ 推荐 |
ConcurrentLinkedQueue | 无锁队列(CAS) | ⭐⭐⭐⭐⭐(高并发) | 队列场景(非 List) | ✅ 推荐 |
BlockingQueue(如ArrayBlockingQueue) | 阻塞队列 + 锁 | ⭐⭐⭐☆☆ | 生产者-消费者模型 | ✅ 推荐 |
自定义ReentrantLock保护 | 显式锁控制 | ⭐⭐⭐☆☆ | 特定业务逻辑 | ✅ 可控 |
📌重点推荐:
CopyOnWriteArrayList是List 场景下最常用的线程安全实现。
4. 解决方案实战:四种方式修复你的代码
方案 1:CopyOnWriteArrayList(推荐)
importjava.util.List;importjava.util.concurrent.CopyOnWriteArrayList;publicclassSafeListDemo{privatestaticList<Integer>list=newCopyOnWriteArrayList<>();publicstaticvoidmain(String[]args)throwsInterruptedException{for(inti=0;i<10;i++){testList();list.clear();}}privatestaticvoidtestList()throwsInterruptedException{Runnablerunnable=()->{for(inti=0;i<10000;i++){list.add(i);}};Threadt1=newThread(runnable);Threadt2=newThread(runnable);Threadt3=newThread(runnable);t1.start();t2.start();t3.start();t1.join();t2.join();t3.join();System.out.println("Size: "+list.size());// 稳定输出 30000}}✅优点:
- 读操作无锁,性能极高;
- 写操作通过“复制整个数组”保证一致性;
- 不会抛
ConcurrentModificationException。
⚠️注意:写操作成本高(O(n)),仅适用于写少读多场景。
方案 2:Collections.synchronizedList()
importjava.util.Collections;importjava.util.List;importjava.util.ArrayList;publicclassSyncListDemo{privatestaticList<Integer>list=Collections.synchronizedList(newArrayList<>());publicstaticvoidmain(String[]args)throwsInterruptedException{for(inti=0;i<10;i++){testList();synchronized(list){list.clear();}}}privatestaticvoidtestList()throwsInterruptedException{Runnablerunnable=()->{for(inti=0;i<10000;i++){list.add(i);}};Threadt1=newThread(runnable);Threadt2=newThread(runnable);Threadt3=newThread(runnable);t1.start();t2.start();t3.start();t1.join();t2.join();t3.join();synchronized(list){System.out.println("Size: "+list.size());}}}⚠️必须注意:
- 遍历时仍需手动加锁!否则可能抛
ConcurrentModificationException。
synchronized(list){for(Integeritem:list){// 安全遍历}}方案 3:显式使用ReentrantLock
importjava.util.ArrayList;importjava.util.List;importjava.util.concurrent.locks.ReentrantLock;publicclassLockedListDemo{privatestaticfinalList<Integer>list=newArrayList<>();privatestaticfinalReentrantLocklock=newReentrantLock();publicstaticvoidmain(String[]args)throwsInterruptedException{for(inti=0;i<10;i++){testList();lock.lock();try{list.clear();}finally{lock.unlock();}}}privatestaticvoidsafeAdd(intvalue){lock.lock();try{list.add(value);}finally{lock.unlock();}}privatestaticvoidtestList()throwsInterruptedException{Runnablerunnable=()->{for(inti=0;i<10000;i++){safeAdd(i);}};Threadt1=newThread(runnable);Threadt2=newThread(runnable);Threadt3=newThread(runnable);t1.start();t2.start();t3.start();t1.join();t2.join();t3.join();lock.lock();try{System.out.println("Size: "+list.size());}finally{lock.unlock();}}}✅优点:灵活可控,可扩展为读写锁等高级模式。
方案 4:改用队列(如果业务允许)
若你的场景本质是“生产-消费”,直接用BlockingQueue更合适:
importjava.util.concurrent.ArrayBlockingQueue;importjava.util.concurrent.BlockingQueue;publicclassQueueDemo{publicstaticvoidmain(String[]args)throwsInterruptedException{BlockingQueue<Integer>queue=newArrayBlockingQueue<>(50000);Threadproducer1=newThread(()->{for(inti=0;i<10000;i++){try{queue.put(i);}catch(InterruptedExceptione){Thread.currentThread().interrupt();}}});Threadproducer2=newThread(()->{for(inti=0;i<10000;i++){try{queue.put(i);}catch(InterruptedExceptione){Thread.currentThread().interrupt();}}});Threadproducer3=newThread(()->{for(inti=0;i<10000;i++){try{queue.put(i);}catch(InterruptedExceptione){Thread.currentThread().interrupt();}}});producer1.start();producer2.start();producer3.start();producer1.join();producer2.join();producer3.join();System.out.println("Queue size: "+queue.size());// 应输出 30000}}执行结果
以上四个方案的执行结果都是一致的:
5. 性能对比(实测数据)
我在本地(Intel i7-13700K, JDK 21)运行 10 次取平均值:
| 方案 | 平均耗时(ms) | 是否稳定输出 30000 |
|---|---|---|
ArrayList(原始) | ~8 ms | ❌ 否 |
Vector | ~120 ms | ✅ 是 |
synchronizedList | ~110 ms | ✅ 是 |
CopyOnWriteArrayList | ~210 ms | ✅ 是 |
ReentrantLock | ~95 ms | ✅ 是 |
📊结论:
CopyOnWriteArrayList写性能最差,但读性能无敌;- 若写操作频繁,
ReentrantLock或synchronizedList更均衡;- 永远不要为了“省事”用
Vector—— 它已被时代淘汰。
6. 架构视角:线程安全集合的底层思想
我们可以把线程安全策略分为三类:
- 悲观锁:假设一定会冲突,先加锁再操作(简单但慢);
- 乐观锁:假设不会冲突,冲突时重试(高效但复杂);
- 写时复制:写操作不修改原数据,而是复制一份新数据(适合读多写少)。
🛒生活类比:
- 悲观锁 = 超市试衣间:一次只进一人,门锁着;
- 乐观锁 = 自助结账:大家同时扫商品,系统检测是否重复扫码;
- 写时复制 = 修改合同:不直接改原件,而是打印新版本签字。
7. 生产环境最佳实践
- 优先选择
java.util.concurrent包下的类,而非Vector或手动同步; - 明确读写比例:
- 读 >> 写 →
CopyOnWriteArrayList - 读 ≈ 写 →
Collections.synchronizedList()或自定义锁 - 队列模型 →
BlockingQueue
- 读 >> 写 →
- 避免在循环中加锁,尽量缩小临界区;
- 不要混合使用:比如
synchronizedList+ 非同步方法调用 = 翻车; - 压测验证:上线前务必模拟高并发场景。
8. 延伸:不只是 List,这些集合也“有毒”
以下集合在并发下同样危险:
| 集合类型 | 线程安全替代方案 |
|---|---|
HashMap | ConcurrentHashMap |
HashSet | Collections.newSetFromMap(new ConcurrentHashMap<>() |
StringBuilder | StringBuffer(或改用不可变字符串) |
🚫黄金法则:除非文档明确说明线程安全,否则默认不安全!
9. 经典书籍推荐
《Java并发编程实战》(Java Concurrency in Practice)
- 作者:Brian Goetz 等
- 出版时间:2006(但仍是并发领域圣经)
- 为什么推荐:本书奠定了现代 Java 并发编程的理论基础,
java.util.concurrent包的设计者亲自执笔,不过时、不淘汰。
《深入理解Java虚拟机》(第3版)
- 作者:周志明
- 章节:第12章 “Java内存模型与线程”
- 本土权威,结合 JVM 底层讲解并发原理。
10. 结语
线程安全不是“知道一个答案”就能解决的问题,而是一套系统性思维:
- 理解问题本质(竞态条件、可见性、原子性);
- 掌握工具箱(各种并发集合的适用边界);
- 结合业务做权衡(性能 vs 一致性 vs 复杂度)。
2025 年,我们早已超越“用 Vector 就安全”的初级阶段。真正的工程能力,体现在对并发模型的精准把控。
下一篇预告:《【Java线程安全实战】② ConcurrentHashMap 源码深度拆解:如何做到高性能并发?》