【047】还在用 Vector?并发遍历崩了吧!CopyOnWriteArrayList 才是真神

在这里插入图片描述


📌📌📌📌📌📌📌📌📌📌📌📌📌📌📌📌📌📌📌📌📌📌

📙 作者: 编程技术圈(哇哥面试陪跑)
👉 欢迎关注、分享、评论
✔️ 持续分享更多干货内容
🌐🌏🌎➕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—— 领导看了监控,第一次对他露出了笑脸。

在这里插入图片描述
在这里插入图片描述

### 介绍 - **ArrayList**:是 `List` 接口下可变长度数组的实现,实现了所有可选的 `List` 操作,允许所有类型的元素,包括 `null`。除实现 `List` 接口外,还提供了一些用于操作内部存储列表的数组长度的方法,与 `Vector` 相比,除了不同步外大致相同[^4]。 - **LinkedList**:基于双向链表实现,是一种线性数据结构,每个元素都包含对前一个和后一个元素的引用。 - **CopyOnWriteArrayList**:是线程安全的 `List` 实现,其写操作(如 `add`、`set`、`remove`)通过创建底层数组的新副本来实现,读操作则直接读取原数组,因此具有弱一致性,不能用于实时读的场景[^2]。 - **Vector**:同样是基于动态数组实现的 `List`,与 `ArrayList` 类似,但它是线程安全的,其增删改查方法都加了 `synchronized` 来保证同步[^1][^3]。 ### 特点 - **ArrayList**: - 底层是动态数组,支持随机访问,通过索引可以快速访问元素,时间复杂度为 $O(1)$。 - 插入和删除操作效率较低,尤其是在数组中间位置,因为需要移动后续元素,平均时间复杂度为 $O(n)$。 - 非线程安全。 - **LinkedList**: - 基于双向链表,插入和删除操作效率高,尤其是在链表头部和尾部,时间复杂度为 $O(1)$。 - 不支持高效的随机访问,访问元素需要从头或尾开始遍历链表,平均时间复杂度为 $O(n)$。 - 非线程安全。 - **CopyOnWriteArrayList**: - 线程安全,适合读多写少的并发场景。 - 写操作时会创建数组副本,内存开销大,写效率低,尤其是在多线程同步激烈、数据量较大时,可能会导致频繁复制数组,造成内存浪费,甚至引发 `young gc` 或 `full gc`。 - 具有弱一致性,不能用于实时读的场景。 - **Vector**: - 线程安全,所有方法都使用 `synchronized` 同步,保证在多线程环境下的数据一致性。 - 由于同步开销,性能相对较低,尤其是在并发读的场景下。 ### 区别 - **线程安全方面**:`ArrayList` 和 `LinkedList` 是非线程安全的,`CopyOnWriteArrayList` 和 `Vector` 是线程安全的。但 `Vector` 是通过在每个方法上加 `synchronized` 实现同步,而 `CopyOnWriteArrayList` 仅在增删改操作上加锁,读操作不加锁,所以在并发读多写少的情况下,`CopyOnWriteArrayList` 的读性能更好[^3]。 - **底层数据结构**:`ArrayList` 和 `Vector` 基于动态数组,`LinkedList` 基于双向链表,`CopyOnWriteArrayList` 也是基于数组,但写操作会创建副本[^1]。 - **性能表现**:在随机访问方面,`ArrayList` 和 `Vector` 更有优势;在插入和删除操作上,尤其是在列表两端,`LinkedList` 效率更高;`CopyOnWriteArrayList` 写操作性能较差,读操作性能较好。 ### 使用场景 - **ArrayList**:适用于需要频繁随机访问元素,且对线程安全没有要求的场景,如遍历列表、根据索引查找元素等。 ```java import java.util.ArrayList; import java.util.List; public class ArrayListExample { public static void main(String[] args) { List<String> list = new ArrayList<>(); list.add("apple"); list.add("banana"); System.out.println(list.get(0)); } } ``` - **LinkedList**:适合需要频繁进行插入和删除操作的场景,如实现栈、队列等数据结构。 ```java import java.util.LinkedList; import java.util.Queue; public class LinkedListExample { public static void main(String[] args) { Queue<String> queue = new LinkedList<>(); queue.add("apple"); queue.add("banana"); System.out.println(queue.poll()); } } ``` - **CopyOnWriteArrayList**:适用于读多写少的并发场景,如缓存系统、事件监听器列表等。 ```java import java.util.List; import java.util.concurrent.CopyOnWriteArrayList; public class CopyOnWriteArrayListExample { public static void main(String[] args) { List<String> list = new CopyOnWriteArrayList<>(); list.add("apple"); list.add("banana"); for (String item : list) { System.out.println(item); } } } ``` - **Vector**:在需要线程安全且对性能要求不高的场景下使用,不过由于其同步开销较大,现在使用场景相对较少。 ```java import java.util.Vector; import java.util.List; public class VectorExample { public static void main(String[] args) { List<String> list = new Vector<>(); list.add("apple"); list.add("banana"); System.out.println(list.get(0)); } } ```
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值