哈喽,各位程序员伙伴~ 👋
上一篇我们拆解了LinkedList的双向链表结构,搞懂了它「头尾增删快、查询慢」的核心原因。而作为Java集合中使用频率最高的实现类,ArrayList几乎是后端开发的「必修课」——日常开发的列表查询、数据展示,九成以上的场景都会用到它。
但你真的懂ArrayList吗?
- 它的「动态数组」到底怎么实现的?为什么能实现下标随机访问?
- 扩容机制的底层逻辑是什么?每次扩容会带来哪些性能开销?
- 为什么同样是O(n),ArrayList中间增删比LinkedList更慢?
- 看似万能的ArrayList,藏着哪些容易踩坑的性能陷阱?
这篇文章,我们延续「痛点开篇 + 通俗类比 + 源码精读 + 实战避坑」的风格,从底层结构到核心操作,从扩容机制到实战选型,彻底拆解ArrayList(基于JDK8源码),搞懂它「查快插慢」的本质,以及那些被忽略的性能优化点和高频坑点。
一、开篇痛点:为什么ArrayList是「默认首选」,却也最容易用错?
ArrayList能成为集合框架的「顶流」,核心原因是契合绝大多数业务场景——我们日常开发中,「查询多、增删少、尾部增删为主」的场景占比超90%,而这正是ArrayList的「主场」。
但看似简单的ArrayList,却藏着很多容易被忽略的问题:
- 新手无脑用
add(int index, E element) 做中间插入,导致系统性能瓶颈;
- 不知道ArrayList初始化容量的重要性,频繁扩容触发数组拷贝,浪费内存和CPU;
- 误以为「ArrayList增删都是O(n),随便用」,却忽略了扩容和元素移动的隐性开销;
- 用ArrayList存储百万级数据,却没发现底层数组的冗余空间导致的内存浪费。
先看一组同场景性能对比数据(基于JDK8,10万次操作,数据量10万),直观感受ArrayList的优势与短板:
| 操作类型 |
ArrayList耗时(ms) |
LinkedList耗时(ms) |
性能优势方 |
核心原因 |
| 随机下标查询 |
1 |
25 |
ArrayList |
数组下标随机访问,O(1) |
| 尾部插入 |
5 |
6 |
基本持平 |
均为O(1)操作 |
| 中间插入(下标5万) |
421 |
312 |
LinkedList |
ArrayList需移动5万元素,LinkedList仅遍历5万节点 |
| 头部插入 |
1286 |
8 |
LinkedList |
ArrayList需移动全部10万元素,O(n)极致开销 |
| 迭代器遍历 |
3 |
4 |
ArrayList |
数组连续内存,缓存命中率更高 |
核心结论:ArrayList的「快」是有边界的,它的优势集中在随机查询、尾部增删、连续遍历,而中间/头部增删是天生短板——这一切的根源,都在于它的底层结构:动态数组。
二、核心结构:动态数组的底层实现(源码拆解 + 通俗类比)
ArrayList的本质是对数组的封装与增强——普通数组是「固定长度」的,一旦创建无法扩容,而ArrayList实现了「动态扩容」,让数组能根据元素数量自动调整长度,这也是它和普通数组的核心区别。
1. 通俗类比:可弹性扩容的「储物柜」
把ArrayList想象成一组带编号的「储物柜」:
- 每个储物柜有唯一编号(对应数组下标),从0开始依次递增,支持直接按编号找柜子(随机访问);
- 储物柜的总数量就是「数组容量」,初始时会规划一定数量的柜子(初始容量);
- 当储物柜被放满时,管理员会找一个更大的空间,把所有物品从旧柜子搬到新的更大的储物柜组(扩容 + 数组拷贝),这个过程会有额外开销;
- 往储物柜中间放/拿物品时,需要把后面的物品全部往后/往前挪一位(中间增删的元素移动),物品越多,挪动越耗时。
这个类比完美对应ArrayList的核心特性:下标随机访问(快)、扩容拷贝(隐性开销)、中间增删元素移动(慢)。
2. 源码精读:ArrayList的核心属性与底层数组
基于JDK8源码,ArrayList的核心属性仅有几个,却决定了它的所有核心行为,我们逐行拆解:
public class ArrayList<E> extends AbstractList<E>
implements List<E>, RandomAccess, Cloneable, java.io.Serializable {
// 序列化ID
private static final long serialVersionUID = 8683452581122892189L;
// 默认初始容量:创建无参构造的ArrayList时,初始底层数组为EMPTY_ELEMENTDATA,首次添加元素时扩容为10
private static final int DEFAULT_CAPACITY = 10;
// 空数组常量:无参构造时的初始数组,区别于DEFAULTCAPACITY_EMPTY_ELEMENTDATA,用于区分首次扩容逻辑
private static final Object[] EMPTY_ELEMENTDATA = {};
// 默认空数组常量:带初始容量为0的构造方法时使用
private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};
// 核心:ArrayList的底层存储容器——真正存储元素的数组,transient表示不参与序列化
transient Object[] elementData;
// 实际存储的元素个数,注意:size ≤ 数组容量(elementData.length)
private int size;
// 带初始容量的构造方法:指定初始数组大小
public ArrayList(int initialCapacity) {
if (initialCapacity > 0) {
this.elementData = new Object[initialCapacity];
} else if (initialCapacity == 0) {
this.elementData = EMPTY_ELEMENTDATA;
} else {
throw new IllegalArgumentException("Illegal Capacity: "+ initialCapacity);
}
}
// 无参构造方法:初始数组为DEFAULTCAPACITY_EMPTY_ELEMENTDATA,首次add时扩容为10
public ArrayList() {
this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}
}
关键核心结论(必须吃透)
- 底层核心是Object数组:
elementData 是真正存储元素的容器,所有增删查操作最终都是对这个数组的操作;
- size ≠ 数组容量:
size 是实际元素个数,elementData.length 是数组总容量,二者的差值就是「数组冗余空间」;
- 默认初始容量的坑:无参构造的ArrayList,初始数组是空的,并非直接创建容量为10的数组,而是首次调用add方法时才会扩容为10(懒加载优化);
- 标记接口的意义:实现
RandomAccess 接口——这是一个标记接口,没有任何方法,仅表示该集合「支持快速随机访问」,遍历工具类(如Collections)会根据该接口选择最优遍历方式(下标遍历/迭代器遍历)。
三、核心操作:为什么查快插慢?(源码 + 时间复杂度分析)
ArrayList所有核心操作的性能差异,都源于数组的固有特性:支持下标随机访问(O(1)),但增删需要移动元素(O(n))。我们拆解最常用的「查、增、删」操作,结合源码讲清原理和性能。
1. 查询操作:O(1)快到极致,随机访问的天花板
ArrayList的查询是所有线性集合中效率最高的,核心原因是底层数组的下标随机访问特性——通过下标可以直接定位到数组的内存地址,无需任何遍历,时间复杂度稳定O(1)。
核心查询方法 get(int index) 源码(JDK8精简版):
public E get(int index) {
rangeCheck(index); // 校验下标合法性:index < size,否则抛IndexOutOfBoundsException
return elementData(index); // 直接通过下标获取数组元素
}
// 私有方法:直接返回数组指定下标元素,真正的O(1)操作
@SuppressWarnings("unchecked")
E elementData(int index) {
return (E) elementData[index];
}
// 下标校验:仅校验是否越界,不校验是否为负数(负数会直接触发数组本身的ArrayIndexOutOfBoundsException)
private void rangeCheck(int index) {
if (index >= size)
throw new IndexOutOfBoundsException(outOfBoundsMsg(index));
}
关键细节
- 校验仅判断
index >= size,因为数组本身会对负数下标抛异常,无需额外处理,减少性能开销;
- 无任何遍历、循环操作,直接通过「数组名[下标]」获取元素,这是最底层的内存访问方式,效率拉满;
- 实现
RandomAccess 标记接口后,工具类会优先选择下标遍历,而非迭代器遍历,进一步提升效率。
对比LinkedList:LinkedList的 get(int index) 需遍历链表(即使优化了头尾遍历),时间复杂度O(n),在大数据量下,查询效率和ArrayList天差地别。
2. 插入操作:尾部O(1)真香,中间/头部O(n)拉胯
ArrayList的插入操作分尾部插入和指定下标插入,二者的性能天差地别,核心差异在于「是否需要移动元素」。
(1)尾部插入:add(E e)(理想情况O(1),扩容时O(n))
这是ArrayList最常用、性能最好的插入方式,源码逻辑(精简版):
public boolean add(E e) {
ensureCapacityInternal(size + 1); // 确保数组容量足够,不足则扩容
elementData[size++] = e; // 直接将元素放入数组尾部的空闲位置,O(1)
return true;
}
核心两步:
- 容量校验:调用
ensureCapacityInternal 检查数组是否有空闲空间,若 size+1 > elementData.length,则触发扩容机制;
- 元素赋值:直接将元素赋值给
elementData[size],然后 size++,无任何元素移动,纯O(1)操作。
隐性开销:如果触发扩容,会执行「新数组创建 + 旧数组元素拷贝」,这个过程是O(n)时间复杂度,这也是为什么建议提前指定ArrayList初始容量的核心原因。
(2)指定下标插入:add(int index, E element)(固定O(n))
这是ArrayList的性能短板,也是最容易用错的方法,源码逻辑(精简版):
public void add(int index, E element) {
rangeCheckForAdd(index); // 校验下标合法性:0 ≤ index ≤ size
ensureCapacityInternal(size + 1); // 容量校验,不足则扩容
// 核心开销:数组拷贝,移动元素——将index后的所有元素往后挪一位
System.arraycopy(elementData, index, elementData, index + 1, size - index);
elementData[index] = element; // 插入新元素
size++; // 元素个数+1
}
性能核心:System.arraycopy 是本地方法(native),虽然效率比手动循环高,但本质还是移动元素——需要把下标 index 后的 size-index 个元素全部往后移动一位,腾出位置给新元素。
- 头部插入(index=0):需要移动全部size个元素,是最耗时的插入方式,时间复杂度O(n);
- 中间插入(index=size/2):需要移动一半元素,时间复杂度仍为O(n);
- 尾部插入(index=size):等价于
add(E e),无需移动元素,O(1)。
对比LinkedList:二者中间插入均为O(n),但ArrayList是「移动元素」,LinkedList是「遍历找节点」,在大数据量下,ArrayList中间插入的耗时通常更高(元素移动的开销 > 链表遍历的开销)。
3. 删除操作:和插入同理,中间/头部删除必移动元素(O(n))
删除操作的性能逻辑和插入完全对称,核心开销仍是元素移动,源码以 remove(int index) 为例(精简版):
public E remove(int index) {
rangeCheck(index); // 下标校验
modCount++; // 结构修改次数,用于迭代器并发修改校验
E oldValue = elementData(index); // 获取待删除元素
// 计算需要移动的元素个数:index后的元素个数
int numMoved = size - index - 1;
if (numMoved > 0)
// 核心开销:将index后的元素往前挪一位,覆盖待删除元素
System.arraycopy(elementData, index+1, elementData, index, numMoved);
// 置空最后一个元素,让GC回收,避免内存泄漏
elementData[--size] = null;
return oldValue;
}
关键细节
- 元素移动:
numMoved > 0 表示待删除元素不是最后一个,需要将后续元素往前移动一位,覆盖待删除元素,时间复杂度O(n);
- 内存优化:删除后会将
elementData[--size] 置为 null,让垃圾回收器能回收该对象,避免「数组持有对象引用导致的内存泄漏」;
- 快速失败:修改
modCount,如果迭代器遍历过程中发生删除操作,会触发 ConcurrentModificationException,避免并发修改导致的数据错乱。
核心结论:ArrayList的删除操作,只有尾部删除是真正的O(1),其余位置的删除都需要移动元素,耗时随数据量增加而飙升。
四、核心机制:ArrayList的扩容逻辑(源码精读,避坑关键)
扩容机制是ArrayList的核心灵魂,也是最容易被忽略的性能点——频繁扩容会导致大量的数组拷贝和内存申请,严重影响性能。理解扩容逻辑,才能从根源上避免相关坑点。
1. 扩容的核心触发条件
当执行 add 系列方法时,会先调用 ensureCapacityInternal(size + 1) 校验容量,触发扩容的唯一条件:
size + 1 > elementData.length
即「添加新元素后,实际元素个数会超过数组当前容量」,此时必须扩容,否则会触发数组下标越界异常。
2. 完整扩容流程(源码拆解,从触发到完成)
我们以无参构造的ArrayList首次添加元素为例,拆解从容量校验到扩容完成的完整流程,核心方法调用链:
add(E e) → ensureCapacityInternal(int minCapacity) → calculateCapacity(Object[] elementData, int minCapacity) → ensureExplicitCapacity(int minCapacity) → grow(int minCapacity)(真正的扩容方法)
步骤1:计算最小需要的容量(calculateCapacity)
核心是处理「无参构造的空数组首次扩容」的特殊逻辑,源码:
private static int calculateCapacity(Object[] elementData, int minCapacity) {
// 若数组是无参构造的默认空数组,最小容量取DEFAULT_CAPACITY(10)和minCapacity的最大值
if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
return Math.max(DEFAULT_CAPACITY, minCapacity);
}
return minCapacity;
}
关键:无参构造的ArrayList,首次添加元素时,minCapacity 是1,会被修正为10,这就是「默认初始容量为10」的底层逻辑。
步骤2:判断是否需要扩容(ensureExplicitCapacity)
private void ensureExplicitCapacity(int minCapacity) {
modCount++; // 结构修改次数+1
// 最小需要的容量 > 数组当前容量 → 触发扩容
if (minCapacity - elementData.length > 0)
grow(minCapacity);
}
步骤3:真正的扩容实现(grow)—— ArrayList扩容的核心
这是扩容的最终执行方法,包含新容量计算和数组拷贝两个核心步骤,源码(JDK8精简版):
private void grow(int minCapacity) {
// 旧容量:数组当前的容量
int oldCapacity = elementData.length;
// 核心:新容量 = 旧容量 + 旧容量/2 → 即扩容1.5倍(位运算更高效,oldCapacity >> 1 等价于 oldCapacity/2)
int newCapacity = oldCapacity + (oldCapacity >> 1);
// 处理特殊情况:新容量 < 最小需要的容量(如旧容量为0时)
if (newCapacity - minCapacity < 0)
newCapacity = minCapacity;
// 处理超大容量:新容量超过Integer.MAX_VALUE - 8,则扩容为Integer.MAX_VALUE
if (newCapacity - MAX_ARRAY_SIZE > 0)
newCapacity = hugeCapacity(minCapacity);
// 核心:数组拷贝——创建新容量的数组,将旧数组元素拷贝到新数组
elementData = Arrays.copyOf(elementData, newCapacity);
}
// 处理超大容量的情况,避免数组容量超过JVM的限制
private static int hugeCapacity(int minCapacity) {
if (minCapacity < 0) // 溢出
throw new OutOfMemoryError();
return (minCapacity > MAX_ARRAY_SIZE) ? Integer.MAX_VALUE : MAX_ARRAY_SIZE;
}
扩容核心结论(必须记住)
- 默认扩容倍数:1.5倍(通过位运算
oldCapacity >> 1 实现,比直接除法更高效);
- 首次扩容特殊:无参构造的ArrayList,首次添加元素时,空数组会直接扩容为容量10;
- 扩容本质:创建新的更大容量的数组,并通过
Arrays.copyOf 完成旧数组元素到新数组的拷贝,这个过程是O(n)时间复杂度,且会申请新的内存空间;
- 超大容量限制:数组最大容量为
Integer.MAX_VALUE(约21亿),避免超出JVM的内存管理限制。
3. 手动扩容优化:ensureCapacity(int minCapacity)
既然自动扩容有O(n)开销,那么在已知元素数量的场景下,我们可以手动调用 ensureCapacity 方法,提前扩容到指定容量,避免多次自动扩容的开销:
// 手动扩容方法:直接指定数组的最小容量
public void ensureCapacity(int minCapacity) {
int minExpand = (elementData != DEFAULTCAPACITY_EMPTY_ELEMENTDATA)
? 0
: DEFAULT_CAPACITY;
if (minCapacity > minExpand) {
ensureExplicitCapacity(minCapacity);
}
}
使用场景:比如要往ArrayList中添加10000个元素,无参构造的ArrayList会经历多次自动扩容(10→15→22→33→...→10000+),每次都有数组拷贝开销;而提前调用 list.ensureCapacity(10000),仅需一次扩容,大幅提升性能。
五、实战避坑:ArrayList的5个高频错误用法(90%的人踩过)
基于源码和底层原理,我们梳理了ArrayList最容易踩的5个坑,每个坑都给出错误写法和正确解决方案,从根源上避坑。
坑点1:无参构造后存储大量数据,导致频繁自动扩容
错误场景:创建无参构造的ArrayList,直接添加10万+元素,触发多次自动扩容(10→15→22→...),每次都做数组拷贝,性能极差。错误写法:
// 错误:无参构造,频繁自动扩容
List<String> list = new ArrayList<>();
for (int i = 0; i < 100000; i++) {
list.add("data" + i);
}
正确解决方案:提前指定初始容量,匹配实际元素数量,避免多次扩容:
// 正确:指定初始容量,仅一次扩容(若需要)
List<String> list = new ArrayList<>(100000);
for (int i = 0; i < 100000; i++) {
list.add("data" + i);
}
进阶优化:如果元素数量不确定,可指定「预估容量 + 20%」,预留一定的冗余空间,避免少量新增元素再次触发扩容。
坑点2:频繁做中间/头部增删操作,忽略元素移动开销
错误场景:用ArrayList实现「消息队列」「栈」等需要高频头尾/中间增删的场景,导致大量元素移动,性能瓶颈。错误写法:
// 错误:ArrayList头部插入,每次移动全部元素
List<String> msgQueue = new ArrayList<>();
// 高频头部插入新消息
msgQueue.add(0, "新消息1");
msgQueue.add(0, "新消息2");
正确解决方案:根据场景选择合适的集合:
- 高频头尾增删:用LinkedList(O(1))或ArrayDeque(底层数组,头尾操作O(1),效率比LinkedList更高);
- 必须用ArrayList:尽量将增删操作放在尾部,避免中间/头部操作。
坑点3:遍历中直接调用remove/add方法,触发并发修改异常
错误场景:用普通for循环/增强for循环遍历ArrayList时,直接调用 remove/add 方法,修改 modCount 导致迭代器校验失败,抛出 ConcurrentModificationException。错误写法:
// 错误:增强for循环遍历中删除元素,触发并发修改异常
List<String> list = new ArrayList<>(Arrays.asList("a", "b", "c", "d"));
for (String s : list) {
if ("b".equals(s)) {
list.remove(s);
}
}
正确解决方案:
- 用迭代器遍历并操作(推荐):迭代器的
remove/add 方法会同步更新 modCount 和 expectedModCount,避免异常;
// 正确:迭代器遍历中安全删除
ListIterator<String> iterator = list.listIterator();
while (iterator.hasNext()) {
String s = iterator.next();
if ("b".equals(s)) {
iterator.remove();
}
}
- 倒序for循环遍历:从后往前遍历,删除元素后不会影响前面元素的下标,避免漏遍历;
- JDK8+流式操作:用
removeIf 方法,底层是迭代器实现,安全高效。
坑点4:误以为ArrayList是线程安全的,多线程环境下直接使用
错误场景:多线程同时对ArrayList进行增删查操作,导致数据错乱、数组下标越界、空指针异常——ArrayList是非线程安全的,底层数组和size均无同步机制。错误写法:
// 错误:多线程同时操作ArrayList,存在线程安全问题
List<String> list = new ArrayList<>();
new Thread(() -> {
for (int i = 0; i < 1000; i++) {
list.add("thread1-" + i);
}
}).start();
new Thread(() -> {
for (int i = 0; i < 1000; i++) {
list.add("thread2-" + i);
}
}).start();
正确解决方案:
- 用线程安全集合:
Collections.synchronizedList(new ArrayList<>())(通过同步锁实现,简单但性能一般);
- 高并发场景:用
CopyOnWriteArrayList(写时复制,读多写少场景性能极佳,底层也是动态数组);
- 手动加锁:在增删查操作外层加
synchronized 锁,适合自定义同步逻辑的场景。
坑点5:用ArrayList存储超大对象,忽略冗余空间的内存浪费
错误场景:用ArrayList存储百万级超大对象(如每个对象占1MB),由于ArrayList扩容为1.5倍,会产生大量的冗余空间(比如实际存储100万元素,数组容量150万,冗余50万位置),导致堆内存严重浪费。正确解决方案:
- 手动缩容:调用
trimToSize() 方法,将数组容量缩为实际元素个数 size,释放冗余内存:
list.trimToSize(); // 数组容量 = size,无冗余空间
- 选择合适的集合:存储超大对象且元素数量固定时,直接用普通数组(无冗余空间),或用
LinkedList(按需创建节点,无提前内存申请)。
六、对比总结:ArrayList与LinkedList终极选型指南
我们结合ArrayList的源码细节,给出更精准、更落地的终极选型指南,附场景化选型建议,告别无脑选择。
1. 核心差异终极对比表(补充扩容、线程安全等细节)
| 对比维度 |
ArrayList(动态数组) |
LinkedList(双向链表) |
| 底层结构 |
动态Object数组,支持扩容 |
双向链表(Node节点 + prev/next指针) |
| 随机访问 |
O(1)(下标直接访问,实现RandomAccess) |
O(n)(必须遍历链表) |
| 插入/删除 |
尾部O(1)(扩容时O(n)),中间/头部O(n)(元素移动) |
头尾O(1),中间O(n)(遍历找节点) |
| 扩容机制 |
自动扩容1.5倍,需数组拷贝(O(n)隐性开销) |
无扩容,按需创建节点(无拷贝开销) |
| 内存占用 |
数组冗余空间(容量 > size),无额外指针开销 |
无冗余空间,但每个节点有prev/next指针(额外内存开销) |
| 遍历效率 |
下标遍历O(1),迭代器遍历O(n),缓存命中率高 |
仅迭代器遍历O(n),缓存命中率低(节点内存不连续) |
| 线程安全 |
非线程安全 |
非线程安全 |
| 核心优势 |
随机查询、尾部增删、连续遍历、大数据量查询 |
高频头尾增删、无扩容开销、中间增删大数据量下更优 |
2. 场景化终极选型口诀 + 建议
核心选型口诀
查多增删少,尾部操作多 → 选ArrayList
头尾增删多,中间操作少 → 选LinkedList
精细化场景建议
- 优先选ArrayList的场景(90%的业务场景):
- 数据展示、列表查询、分页加载(高频随机查询);
- 仅尾部增删的场景(如日志收集、数据缓存);
- 已知元素数量,可提前指定初始容量(避免扩容);
- 需要高效遍历的场景(下标遍历效率拉满)。
- 必须选LinkedList的场景:
- 实现栈、队列、双端队列(Deque)(高频头尾增删);
- 中间增删操作频繁,且数据量较大(元素移动开销 > 链表遍历开销);
- 内存敏感场景,避免数组冗余空间(但需接受指针的额外开销)。
- 进阶替代方案:
- 高并发读多写少 → 用CopyOnWriteArrayList(ArrayList线程安全版);
- 高频头尾增删,追求更高效率 → 用ArrayDeque(底层循环数组,头尾操作O(1),比LinkedList更快);
- 固定长度数据存储 → 用普通数组(无扩容、无冗余、效率最高);
- 海量数据存储 → 用Vector(不推荐,性能差)或第三方集合。
七、写在最后:理解底层,才能用好工具
ArrayList看似简单,实则是Java集合框架中最能体现数据结构思想的实现类——它的所有特性,都源于「数组」这个基础数据结构的固有属性:
- 数组的下标随机访问,造就了ArrayList的查询优势;
- 数组的固定长度,催生了动态扩容机制,也带来了拷贝开销;
- 数组的连续内存存储,让中间增删必须移动元素,成为天生短板。
而LinkedList则是「链表」数据结构的完美落地,二者的差异,本质是数组与链表的经典对决。
作为开发者,我们无需死记硬背「谁快谁慢」,更重要的是理解底层数据结构的特性,根据业务场景选择合适的工具——这也是我们进行源码分析的核心目的:知其然,更知其所以然,让每一次技术选择都有底层逻辑支撑。更多深入的数据结构和Java集合框架的讨论,欢迎在云栈社区与大家交流。
下一篇预告
这篇我们拆解了ArrayList的动态数组结构、扩容机制和实战坑点,搞懂了它「查快插慢」的本质。下一篇,我们将进入Java并发集合的领域,拆解 《CopyOnWriteArrayList源码解析:写时复制,如何实现线程安全且高效读?》——彻底搞懂写时复制的核心原理,以及它在高并发场景下的应用。
评论区聊聊:你在项目中踩过ArrayList的哪些坑?是怎么解决的?