List 集合详解!
开篇语
哈喽,各位小伙伴们,你们好呀,我是喵手。运营社区:C站/掘金/腾讯云/阿里云/华为云/51CTO;欢迎大家常来逛逛
今天我要给大家分享一些自己日常学习到的一些知识点,并以文字的形式跟大家一起交流,互相学习,一个人虽可以走的更快,但一群人可以走的更远。
我是一名后端开发爱好者,工作日常接触到最多的就是Java语言啦,所以我都尽量抽业余时间把自己所学到所会的,通过文章的形式进行输出,希望以这种方式帮助到更多的初学者或者想入门的小伙伴们,同时也能对自己的技术进行沉淀,加以复盘,查缺补漏。
小伙伴们在批阅的过程中,如果觉得文章不错,欢迎点赞、收藏、关注哦。三连即是对作者我写作道路上最好的鼓励与支持!
一、ArrayList:一间会自动扩建的“顺序仓库”
1.1 底层结构
- 底层是 动态数组:
Object[] elementData - 元素在内存中是 连续存储 的
- 支持 随机下标访问,因此
get(index)和set(index)是 O(1)
List<String> list = new ArrayList<>();
list.add("A");
list.add("B");
list.add("C");
System.out.println(list.get(1)); // B,典型 O(1) 访问
1.2 扩容机制(grow 策略)
看下核心思想就行,不用死记源码细节。
当你 add 一个元素,而内部数组已经满了,就会触发扩容操作:
大致伪代码(基于 JDK 8 思路简化):
private void grow(int minCapacity) {
int oldCapacity = elementData.length;
// 1.5 倍扩容:newCapacity = old + old/2
int newCapacity = oldCapacity + (oldCapacity >> 1);
if (newCapacity < minCapacity) {
newCapacity = minCapacity;
}
// 复制到新数组
elementData = Arrays.copyOf(elementData, newCapacity);
}
关键点总结:
-
扩容比例:约 1.5 倍
-
数组拷贝成本:O(n)
- 每次扩容都要把旧数组的元素复制到新数组里
-
均摊复杂度(amortized)
- 虽然扩容是 O(n),但不是每次
add都扩容 - 所以 末尾追加 add 操作平均是 O(1)
- 虽然扩容是 O(n),但不是每次
1.3 时间复杂度分析(ArrayList)
| 操作 | 复杂度 | 说明 |
|---|---|---|
get(index) |
O(1) | 直接按下标访问数组 |
set(index, element) |
O(1) | 直接覆盖数组元素 |
add(element)(尾部) |
均摊 O(1) | 偶尔会 O(n) 扩容 |
add(index, element) |
O(n) | 需要挪动 index 之后的所有元素 |
remove(index) |
O(n) | 需要把后面的元素往前搬 |
contains(element) |
O(n) | 线性遍历 |
一句话:
ArrayList:读快(随机访问牛),中间插删慢。适合“读多写少且多是尾部追加”的场景。
二、LinkedList:一条会插队的“双向人链”
2.1 底层结构:双向链表
LinkedList 底层是 双向链表,每个节点长这样:
private static class Node<E> {
E item;
Node<E> next;
Node<E> prev;
}
特点:
- 每个节点知道自己的
prev和next - 内存不连续,通过引用把节点串起来
- 头尾插入删除很快,中间插入/删除只要找到节点,改指针即可
简单示例:
LinkedList<String> list = new LinkedList<>();
list.add("A"); // 尾部插入
list.addFirst("0"); // 头部插入 O(1)
list.addLast("B"); // 尾部插入 O(1)
list.removeFirst(); // 头删 O(1)
list.removeLast(); // 尾删 O(1)
2.2 时间复杂度分析(LinkedList)
重点来了:随机访问其实不行。
| 操作 | 复杂度 | 说明 |
|---|---|---|
get(index) |
O(n) | 需要从头/尾走到 index |
addFirst(element) / addLast |
O(1) | 只改头尾指针 |
removeFirst() / removeLast() |
O(1) | 同上 |
| 在“已知节点处”插入/删除(内部方法) | O(1) | 修改前后指针即可 |
add(index, element) |
O(n) | 先 O(n) 找到节点,再 O(1) 插入 |
remove(index) |
O(n) | 同理 |
contains(element) |
O(n) | 线性遍历 |
也就是说:
- 你不知道节点位置,只给 index:它很慢(O(n))
- 你只在头尾操作:它很快
2.3 ArrayList vs LinkedList 场景对比
我们来搞个“擂台表”:
| 维度 | ArrayList | LinkedList |
|---|---|---|
| 底层结构 | 动态数组 | 双向链表 |
随机访问 get(index) |
O(1) ✅ 很快 | O(n) ❌ 需要遍历 |
尾部追加 add(element) |
均摊 O(1) | O(1)(尾插) |
| 中间插入/删除 | O(n)(搬运数组元素) | O(n) 找到节点,接着 O(1) 插入 |
| 头部插入/删除 | O(n) | O(1) 非常适合 |
| 内存局部性 | 好(数组连续) | 差(节点分散) |
| 典型使用场景 | 查询多、按索引访问多 | 频繁头尾操作、频繁插入删除 |
换句话说:
- 如果你的逻辑经常
get(i)、set(i)、for 循环按下标遍历 —— 优先 ArrayList - 如果你是队列/双端队列,频繁在头尾插入删除 —— 可以考虑 LinkedList(或 ArrayDeque)
三、CopyOnWriteArrayList:读的时候“看旧版”,写的时候“印新版”
现在上场的是偏“并发场景”的大哥:CopyOnWriteArrayList。
名字很直白:“写时复制”。
3.1 写时复制机制
核心思想一句话:
读时不加锁,写时复制一个新数组,把修改后的新数组替换旧数组。
简化版伪代码(非真实源码,只讲原理):
public boolean add(E e) {
final ReentrantLock lock = this.lock;
lock.lock();
try {
Object[] oldArray = array;
int len = oldArray.length;
// 1. 新建一个数组,比原来多 1 个
Object[] newArray = Arrays.copyOf(oldArray, len + 1);
// 2. 把新元素放在最后
newArray[len] = e;
// 3. 用新数组替换旧的
array = newArray;
return true;
} finally {
lock.unlock();
}
}
读取操作:
public E get(int index) {
return (E) array[index]; // 直接读,不加锁
}
所以特性是:
- 读:无锁,非常适合高并发读
- 写:每次都复制整个数组,代价昂贵(O(n) 且创建新数组)
3.2 迭代器的“快照语义”
CopyOnWriteArrayList 的另一个特点是:
它的迭代器遍历的是 创建迭代器那一刻的快照数组。
即:
- 遍历过程中,即使别的线程修改了列表,你也不会
ConcurrentModificationException - 你看到的就是某个时间点的“历史版本”
示例代码:
CopyOnWriteArrayList<String> list = new CopyOnWriteArrayList<>();
list.add("A");
list.add("B");
for (String s : list) {
System.out.println(s);
list.add("C"); // 不会抛 ConcurrentModificationException
}
System.out.println("Size: " + list.size()); // 3 or more,看你加了几次
对比 ArrayList:
List<String> list = new ArrayList<>();
list.add("A");
list.add("B");
for (String s : list) {
System.out.println(s);
list.add("C"); // 这里会抛 ConcurrentModificationException
}
3.3 时间复杂度与代价
| 操作 | 复杂度 | 说明 |
|---|---|---|
读 get(index) |
O(1),无锁 | 从当前数组快照读 |
| 遍历 | O(n),无锁 | 基于快照数组 |
add/remove/set |
O(n) + 分配数组 | 复制旧数组并修改 |
| 并发读写是否安全 | 是 | 读无锁,写有锁 |
优点:
- 读操作极其简单、安全
- 不会
ConcurrentModificationException - 遍历无锁,适合读多写少场景
缺点:
- 写操作非常重:复制整个数组 + GC 压力
- 不适合写多、列表很大的场景
3.4 适用场景
一句非常实在的话:
CopyOnWriteArrayList = “读特别多,写很少”的并发场景神器。
常见使用场景:
- 监听器列表、订阅者列表(偶尔注册/取消,频繁通知)
- 系统配置列表(启动时初始化,运行中偶尔变更,读取很多)
- 白名单/黑名单缓存:偶尔更新、频繁检查
示例:
class EventBus {
private final CopyOnWriteArrayList<Listener> listeners = new CopyOnWriteArrayList<>();
public void register(Listener l) {
listeners.add(l); // 写少
}
public void publish(Event e) {
for (Listener l : listeners) { // 读多
l.onEvent(e);
}
}
}
四、三者总结:到底什么时候选谁?
如果把三者当成“角色卡”,能大概这么选:
-
ArrayList
- 主打:随机访问快,遍历快,内存局部性好
- 场景:大部分是读和尾部追加,比如分页数据、列表展示、缓存数组等
- 并发:非线程安全,多线程要自己加锁或用
Collections.synchronizedList
-
LinkedList
- 主打:头尾插删快
- 场景:作为 队列/双端队列(Deque),如频繁
addFirst/removeFirst - 但在现代 Java 里,很多情况下用
ArrayDeque会更合适
-
CopyOnWriteArrayList
- 主打:并发读多写少,读无锁,遍历安全
- 场景:监听器列表、配置列表、几乎只读的共享集合
- 不适合:写操作频繁、集合超大
… …
文末
好啦,以上就是我这期的全部内容,如果有任何疑问,欢迎下方留言哦,咱们下期见。
… …
学习不分先后,知识不分多少;事无巨细,当以虚心求教;三人行,必有我师焉!!!
wished for you successed !!!
⭐️若喜欢我,就请关注我叭。
⭐️若对您有用,就请点赞叭。
⭐️若有疑问,就请评论留言告诉我叭。
版权声明:本文由作者原创,转载请注明出处,谢谢支持!
- 点赞
- 收藏
- 关注作者
评论(0)