文章目录

📌📌📌📌📌📌📌📌📌📌📌📌📌📌📌📌📌📌📌📌📌📌
📙 作者: 编程技术圈(哇哥面试陪跑)
👉 欢迎关注、分享、评论
✔️ 持续分享更多干货内容
🌐🌏🌎➕tcmeta, 欢迎沟通交流
📌📌📌📌📌📌📌📌📌📌📌📌📌📌📌📌📌📌📌📌📌📌
零、引入
王二的脸涨得像刚从酱缸里捞出来的萝卜,紫中带红。办公桌上的键盘被他捶得咚咚响,屏幕上ConcurrentModificationException的红字,比领导刚甩过来的批评信还刺眼 —— 他用 Vector 存商品列表,并发遍历的时候偏要修改,结果程序直接炸了。
“Vector 不是线程安全的吗?怎么还出这种幺蛾子!” 王二扯着嗓子喊,惊飞了窗台上的麻雀。隔壁工位的哇哥正用旧报纸包茶叶,闻言抬了抬眼皮,慢悠悠道:“线程安全也分三六九等,Vector 这玩意儿,就像清末的驿站,守着老规矩,慢是慢了点,倒也没出过塌天的乱子 —— 可到了民国的火车时代,再用驿站传信,不闹笑话才怪。”
👉👉👉 点赞 + 关注,跟着哇哥和王二,用仓库记账的道理把 CopyOnWriteArrayList 和 Vector 的区别嚼碎,下次再写并发集合,保你比领导还门儿清。

一、王二的 “驿站之坑”:Vector 的线程安全是 “笨办法”
王二写的代码不复杂,就是用 Vector 存秒杀商品,主线程遍历的时候,子线程偷偷修改库存 —— 结果遍历到一半就崩了。代码如下,透着股菜鸟的直白:
package cn.tcmeta.juc;
import java.util.Vector;
import java.util.concurrent.TimeUnit;
public class VectorDisasterSample {
// 用Vector存秒杀商品列表(商品名-库存)
private static final Vector<String> SECKILL_GOODS = new Vector<>();
static {
// 初始化商品
SECKILL_GOODS.add("华为Mate60-100");
SECKILL_GOODS.add("苹果15-80");
SECKILL_GOODS.add("小米14-150");
}
public static void main(String[] args) {
// 线程1:遍历商品列表(模拟用户查询)
Thread queryThread = new Thread(() -> {
// 使用增强for循环遍历,这会使用Iterator
for (String goods : SECKILL_GOODS) {
System.out.println("查询线程:" + goods);
try {
// 延长遍历时间,增加并发冲突概率
TimeUnit.MILLISECONDS.sleep(200);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}, "Query-Thread");
// 线程2:修改商品库存(模拟用户秒杀)
Thread modifyThread = new Thread(() -> {
try {
// 等待查询线程开始遍历
TimeUnit.MILLISECONDS.sleep(100);
// 修改元素值(不会导致异常)
SECKILL_GOODS.set(0, "华为Mate60-99");
System.out.println("修改线程:华为Mate60库存减1");
// 关键操作:添加新元素,改变集合结构
TimeUnit.MILLISECONDS.sleep(300);
SECKILL_GOODS.add("新商品-200");
System.out.println("修改线程:添加新商品");
// 或者删除元素,也会导致异常
// SECKILL_GOODS.remove(1);
// System.out.println("修改线程:删除苹果15");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}, "Modify-Thread");
// 启动线程
queryThread.start();
modifyThread.start();
}
}

王二瘫在椅子上,百思不解:“Vector 的方法不都加了 synchronized 吗?怎么还会并发修改异常?”
哇哥把包好的茶叶往桌上一放,纸包发出窸窣的响:“这就是 Vector 的笨办法 —— 它的线程安全是‘全锁’,每个方法都套个 synchronized,像给仓库大门挂了把大锁,不管是记账(读)还是搬货(写),都得先开锁。可遍历的时候,迭代器会记着当前的‘账本版本’,你这边改了,版本对不上,它就喊‘不对劲’,直接抛异常。”
他顿了顿,又道:“更糟的是,读和写互斥 ——100 个线程查商品,1 个线程改库存,那 100 个查询线程都得等着,效率低得像驴拉磨。”

二、用 “仓库记账” 讲透核心区别:锁门记账 vs 抄本记账

哇哥拽过王二桌上的草稿纸,画了两个歪歪扭扭的仓库,一个门上挂着大锁,一个桌上堆着账本抄本 —— 这是他的拿手好戏,再复杂的技术,到他手里都能变成街头巷尾的事儿。
“你把 Vector 当成‘老仓库’,” 哇哥指着第一个仓库,“记账先生(线程)要查账(读),得先把大门锁上;要改账(写),也得先锁门。哪怕是 100 个人查账,也只能排队一个个来,锁门期间谁都别想进 —— 这就是 Vector 的 synchronized 方法,读写互斥。”
“那 CopyOnWriteArrayList 呢?” 王二凑过来问。
“这是‘新仓库’,” 哇哥指着第二个仓库,“账本(数组)是固定的,谁要查账,直接拿一本去看,不用锁门;要是有人要改账(写),不直接改原账本,而是抄一本新的,在新账本上改,改完了再把‘账本架子’换成新的 —— 这就是‘写时复制’,读的是旧账本,写的是新账本,两者互不干扰。”
✅ 核心原理对比(王二记在烟盒内侧)
哇哥怕他忘,用粉笔在桌角写了两行字,像私塾先生留的作业:

“简单说,Vector 是‘一刀切’的锁,CopyOnWriteArrayList 是‘读放行,写复制’,” 哇哥总结,“就像菜市场,Vector 是不管买菜的还是卖菜的,都堵在门口查票;CopyOnWriteArrayList 是买菜的直接进,卖菜的先登记抄表再进,效率能一样吗?”
三、原理拆解:CopyOnWriteArrayList 的 “写时复制” 魔法

王二还是半信半疑:“写的时候复制数组,那旧数组里的线程咋办?”
“旧数组还在,” 哇哥打开 JDK 源码,指着 CopyOnWriteArrayList 的add方法,“你看,写操作的时候会加锁,复制原数组到新数组,在新数组上修改,然后把引用指向新数组 —— 旧数组没人用了,自然会被 GC 回收。”
➡️ 源码核心:写操作的 “复制 - 修改 - 替换” 三步
public boolean add(E e) {
synchronized (lock) { // 1. 写操作加锁,防止多个线程同时复制
Object[] es = getArray();
int len = es.length;
// 2. 获取旧数组,复制一个新数组(长度+1)
es = Arrays.copyOf(es, len + 1);
es[len] = e; // 3. 在新数组上添加元素
setArray(es); // 4. 把数组引用换成新数组
return true;
}
}
public E get(int index) {
return elementAt(getArray(), index);
}
王二盯着源码,突然拍腿:“怪不得遍历的时候修改没事!遍历的是旧数组,写操作改的是新数组,两者井水不犯河水!”
“总算开窍了,” 哇哥递给他一支烟,“而且读操作连锁都不用加,因为数组是 final 的,一旦创建就不会变 —— 这就是‘不可变对象’的线程安全,比加锁省心多了。”
四、改造代码:CopyOnWriteArrayList 让并发遍历稳如老狗
哇哥拿过王二的鼠标,把 Vector 换成 CopyOnWriteArrayList,只改了一行代码,原本崩掉的程序瞬间变得温顺:
package cn.tcmeta.juc;
import java.util.Iterator;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.TimeUnit;
/**
* @author: laoren
* @description: 优化版:CopyOnWriteArrayList搞定并发遍历修改
* @version: 1.0.0
*/
public class CopyOnWriteRescue {
// 把Vector换成CopyOnWriteArrayList,其他逻辑不变
private static final CopyOnWriteArrayList<String> SECKILL_GOODS = new CopyOnWriteArrayList<>();
static {
SECKILL_GOODS.add("华为Mate60-100");
SECKILL_GOODS.add("苹果15-80");
SECKILL_GOODS.add("小米14-150");
}
static void main() {
// 线程1:遍历商品(读)
Thread queryThread = new Thread(() -> {
Iterator<String> iterator = SECKILL_GOODS.iterator();
while (iterator.hasNext()) {
String goods = iterator.next();
System.out.println("查询线程:" + goods);
try {
TimeUnit.MILLISECONDS.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
// 遍历完再查一次,看修改结果
System.out.println("查询线程:最终商品列表" + SECKILL_GOODS);
}, "Query-Thread");
// 线程2:修改商品(写)
Thread modifyThread = new Thread(() -> {
try {
TimeUnit.MILLISECONDS.sleep(200);
SECKILL_GOODS.set(0, "华为Mate60-99");
System.out.println("修改线程:华为Mate60库存减1");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}, "Modify-Thread");
queryThread.start();
modifyThread.start();
}
}

王二看着日志,眼睛都直了:“真的不抛异常!而且修改还生效了!”
“这就是写时复制的魔法,” 哇哥笑道,“查询线程拿的是旧数组的‘快照’,修改线程改的是新数组,等查询线程遍历完,新数组已经上位了 —— 既保证了遍历安全,又不影响修改,比 Vector 的‘一刀切’强多了。”
五、并发效率对比:读多写少场景,CopyOnWriteArrayList 碾压 Vector

为了让王二彻底信服,哇哥写了个并发测试代码,100 个读线程,2 个写线程,对比两者的耗时:
package cn.tcmeta.juc;
import java.util.*;
import java.util.concurrent.*;
public class CopyOnWriteVsVectorDemo {
private static final int READER_COUNT = 100; // 读线程数量
private static final int WRITER_COUNT = 2; // 写线程数量
private static final int READ_TIMES = 1000; // 每个读线程的读取次数
public static void main(String[] args) throws InterruptedException {
// 测试 Vector
Vector<String> vector = new Vector<>();
initializeData(vector);
long vectorTime = testPerformance(vector, "Vector");
// 测试 CopyOnWriteArrayList
CopyOnWriteArrayList<String> cowList = new CopyOnWriteArrayList<>();
initializeData(cowList);
long cowTime = testPerformance(cowList, "CopyOnWriteArrayList");
System.out.println("Vector 耗时: " + vectorTime + " ms");
System.out.println("CopyOnWriteArrayList 耗时: " + cowTime + " ms");
System.out.println("性能提升: " + (vectorTime * 1.0 / cowTime) + "倍");
}
private static void initializeData(List<String> list) {
for (int i = 0; i < 1000; i++) {
list.add("item-" + i);
}
}
private static long testPerformance(List<String> list, String name)
throws InterruptedException {
CountDownLatch startSignal = new CountDownLatch(1);
CountDownLatch doneSignal = new CountDownLatch(READER_COUNT + WRITER_COUNT);
// 创建读线程(大量)
for (int i = 0; i < READER_COUNT; i++) {
// 修改读线程中的遍历部分
new Thread(() -> {
try {
startSignal.await();
for (int j = 0; j < READ_TIMES; j++) {
// 对于 Vector,需要同步遍历操作
synchronized(list) {
for (String item : list) {
item.length();
}
}
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
doneSignal.countDown();
}
}, name + "-Reader-" + i).start();
}
// 创建写线程(少量)
for (int i = 0; i < WRITER_COUNT; i++) {
new Thread(() -> {
try {
startSignal.await();
// 模拟写少场景:偶尔添加元素
for (int j = 0; j < 10; j++) {
list.add("new-item-" + System.currentTimeMillis());
try {
Thread.sleep(10); // 模拟写操作间隔
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
doneSignal.countDown();
}
}, name + "-Writer-" + i).start();
}
// 开始计时并启动所有线程
long startTime = System.currentTimeMillis();
startSignal.countDown();
doneSignal.await();
long endTime = System.currentTimeMillis();
return endTime - startTime;
}
}

王二看得倒吸一口凉气:“差了 4 倍!这也太夸张了!” 【这里只要知道性能差异即可,至于几倍,不重要.测试环境、场景的不同, 这些也会不一样】
“读多写少场景下,这很正常,” 哇哥解释,“Vector 的 100 个读线程都在等 2 个写线程释放锁,而 CopyOnWriteArrayList 的读线程根本不用等,直接遍历旧数组,效率自然高。”
六、面试必问:CopyOnWriteArrayList 核心问题(附答案)

哇哥知道王二要面试,特意整理了 3 道高频题,让他抄在小本本上,像考前划重点:
👉 面试题 1:CopyOnWriteArrayList 和 Vector 的核心区别是什么?(必答)
用 “仓库记账” 的例子答,面试官一听就懂:
- 线程安全实现:Vector 是给每个方法加 synchronized,读写都锁;CopyOnWriteArrayList 是写时复制,读无锁,写加锁(只锁写操作);
- 并发效率:Vector 读写互斥,读多写少场景下效率极低;CopyOnWriteArrayList 读写不互斥,读线程并行,写线程串行,并发效率高;
- 遍历安全性:Vector 遍历中修改会抛 ConcurrentModificationException;CopyOnWriteArrayList 遍历的是旧数组,修改不影响,遍历安全;
- 内存占用:Vector 内存正常;CopyOnWriteArrayList 写时复制新数组,内存暂用高,适合写操作少的场景。
➡️ 面试题 2:CopyOnWriteArrayList 有什么缺点?适用场景是什么?
缺点:
- 写操作内存开销大:每次写都复制新数组,数据量大时内存压力大;
- 数据一致性问题:读的是旧数组,可能拿到过时数据(最终一致,非实时一致);
- 写操作串行:多个写线程会排队,写多场景效率低。
适用场景:读多写少、对数据实时一致性要求不高的场景,比如:
- 系统配置列表(配置修改少,查询多);
- 日志查询列表(日志写入后很少改,查询频繁);
- 秒杀商品列表(商品信息修改少,用户查询多)。
📌 面试题 3:CopyOnWriteArrayList 的迭代器为什么不能修改?
因为迭代器持有旧数组的引用,在迭代器创建时就固定了 —— 源码里迭代器的构造方法会把当前数组存起来,后续的修改操作改的是新数组,迭代器里的旧数组不会变,所以迭代器的add/remove方法会直接抛 UnsupportedOperationException,这是故意设计的,保证遍历安全。
七、总结:并发集合选型心法(王二编的顺口溜)

- Vector 是老古董,全锁互斥效率穷;
- CopyOnWrite 新花样,写时复制真叫棒;
- 读无锁,写加锁,读多写少才适合;
- 遍历安全不抛错,内存暂高别嫌多;
- 配置日志秒杀场,选它保你不背锅。
💯 哇哥的临别赠言
“下次面试再被问这俩的区别,你先骂一句 Vector 的笨办法,再讲仓库记账的例子,最后把并发效率测试代码写出来,” 哇哥拍了拍王二的肩膀,“面试官一准觉得你是真用过,不是背概念的菜鸟 —— 我当年面字节,就靠这招过了并发题。”
王二点了点头,把优化后的代码部署到测试环境,1000 并发查询加 10 并发修改,程序稳如老狗,响应时间从之前的 500ms 降到 30ms—— 领导看了监控,第一次对他露出了笑脸。


766

被折叠的 条评论
为什么被折叠?



