集合框架是 Java 使用率最高的库之一,也是面试与性能调优的常客。本文深入 JDK 源码,讲清每个容器的数据结构、扩容时机、线程安全性,并给出高并发场景的选型结论——记住"它为什么这样设计",远比记住"怎么用"值钱。
一、List 家族:ArrayList 与 LinkedList
1.1 ArrayList:动态数组
底层是 Object[] elementData,默认容量 10,扩容按 1.5 倍(oldCapacity + (oldCapacity >> 1)),扩容 = 新建数组 + System.arraycopy 拷贝。
// 扩容核心逻辑(JDK17 简化)
private Object[] grow(int minCapacity) {
int oldCapacity = elementData.length;
int newCapacity = oldCapacity + (oldCapacity >> 1); // 1.5 倍
return elementData = Arrays.copyOf(elementData, newCapacity);
}
复杂度对照:
| 操作 | 平均 | 最坏 |
|---|---|---|
| 尾部 add | O(1) 摊还 | O(n)(扩容时) |
| 指定位置插入/删除 | O(n) | O(n) |
| 随机访问 get(i) | O(1) | O(1) |
| 按值查找 | O(n) | O(n) |
// 已知容量时务必预分配,避免反复扩容拷贝
List<String> list = new ArrayList<>(10_000);
1.2 LinkedList:双向链表
底层是 Node<E> 双向链表。每个操作都要遍历定位(index 小于 size/2 从头找,否则从尾找),所以随机访问是 O(n),而头尾插入删除是 O(1)。JDK 中 LinkedList 同时实现 List 和 Deque。
// 何时用 LinkedList?
// 1. 大量头尾操作(它同时是 Deque)
// 2. 需要高效从两端增删的队列场景
// 实际工程:ArrayList 占据 95% 场景,链表应用面其实很窄
一句话总结: ArrayList 是"随机访问利器 + 尾部追加友好",LinkedList 是"两端增删友好 + 随机访问拉胯";绝大多数场景 ArrayList 已足够,别被"链表更快"的直觉误导。
二、HashMap:数组 + 链表 + 红黑树
2.1 数据结构
HashMap 底层:
数组 table[](桶)
每个桶可以是:链表(冲突少时)→ 红黑树(冲突≥8 时)
默认容量 16、负载因子 0.75
实际使用 hash 的高低位混合:(h ^ (h >>> 16)),减少碰撞
// 扰动函数:让高位参与低位计算,降低碰撞
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
2.2 扩容机制
- 触发条件:
size > capacity * loadFactor(默认 16*0.75=12 时扩容)。 - 扩容策略:翻倍(容量 « 1),并 rehash——但利用"容量是 2 的幂",元素新位置要么不变,要么 = 原位置 + 旧容量,通过位运算快速判断。
- 树化阈值 8,退化阈值 6:冲突链表长度到 8 且容量≥64 转红黑树;删除后小于 6 退回链表。
// put 流程(伪代码)
final V putVal(int hash, K key, V value, ...) {
if (table 为空) resize();
int i = (n - 1) & hash; // 定位桶:容量-1 与 hash
if (桶为空) 直接放入新节点;
else {
if (桶头 key 相同) 覆盖;
else if (桶头是树节点) 走红黑树插入;
else 遍历链表,尾部插入,若长度 ≥ 8 且容量够则树化;
}
if (++size > threshold) resize();
}
2.3 关键参数速记
| 参数 | 默认 | 含义 |
|---|---|---|
| 初始容量 | 16 | 容量始终取 2 的幂 |
| 负载因子 | 0.75 | 时间/空间平衡 |
| 树化阈值 | 8 | 链表→红黑树 |
| 退化阈值 | 6 | 红黑树→链表(防抖动) |
| 树化最小容量 | 64 | 容量不足 64 先扩容不树化 |
一句话总结: HashMap = 数组散列 + 冲突解决(链表→红黑树)+ 2 的幂扩容;知道"0.75、8、6、64"这几个魔数,就读懂了它的时间和空间权衡。
三、ConcurrentHashMap:并发容器的正确打开方式
3.1 演进
| JDK 版本 | 结构 | 并发控制 |
|---|---|---|
| JDK7 | 分段锁(Segment) | 默认 16 个 Segment 各自锁 |
| JDK8+ | 数组 + 链表/红黑树 | CAS + synchronized 锁桶头 |
JDK8 放弃分段锁,改用更细粒度的锁单个桶头节点 + CAS,并发度从"16 段"提升到"每桶独立"。
// JDK8 put:桶空 → CAS 直接放入;桶非空 → synchronized 锁桶头再操作
final V putVal(K key, V value, boolean onlyIfAbsent) {
...
for (Node<K,V>[] tab = table;;) {
if (桶为空 && casTabAt(tab, i, null, node)) // CAS 成功即插入
break;
else if (tab[i] == MOVED) // 扩容中,帮助迁移
helpTransfer(tab, node);
else {
synchronized (tab[i]) { // 锁桶头
... 链表/树插入
}
}
}
}
3.2 正确姿势
- size 是近似值:高并发下
size()需要扫描或求和,可能是近似值,别用做精确判断。 - 不支持 null key/value:避免并发下的二义性。
- 扩容是并发协作:多线程可"帮助"迁移,减少单线程阻塞。
// 并发累加正确用法:用 LongAdder 而不是并发加计数
ConcurrentHashMap<String, LongAdder> counts = new ConcurrentHashMap<>();
counts.computeIfAbsent("key", k -> new LongAdder()).increment();
一句话总结: JDK8 的 ConcurrentHashMap = “CAS + 锁桶头”,把锁粒度压到单桶;并发读不用锁,并发写只锁冲突桶——这就是它能做到高吞吐的根源。
四、CopyOnWriteArrayList 与并发 Set
4.1 写时复制
CopyOnWriteArrayList:
读:直接读 volatile 数组引用,无锁
写:复制整个数组 → 修改 → 替换引用
适用:读多写极少的场景(如监听器列表、缓存黑名单)
代价:每次写 O(n) 拷贝;弱一致迭代器
// 经典使用:缓存订阅者列表
private final CopyOnWriteArrayList<Listener> listeners = new CopyOnWriteArrayList<>();
public void addListener(Listener l) { listeners.add(l); } // 写时复制
public void notifyAll(Event e) {
for (Listener l : listeners) l.onEvent(e); // 无锁遍历
}
4.2 并发 Set
ConcurrentHashMap.newKeySet()→ 用并发 Map 实现的 Set。CopyOnWriteArraySet→ 基于 COW List,适合小集合读多写少。Collections.synchronizedSet(new HashSet<>())→ 整表锁,性能差,不推荐高并发。
一句话总结: 写时复制用"空间换读性能"——适合"写极少、读极多"的场景;反之写多就要选分段/锁桶的容器,别把 COW 用成常态。
五、队列家族:Queue/Deque/BlockingQueue
5.1 数据结构与实现
| 接口 | 代表实现 | 特点 |
|---|---|---|
| Queue | LinkedList、ArrayDeque | FIFO 基础队列 |
| Deque | ArrayDeque、LinkedList | 双端,头尾都能操作 |
| BlockingQueue | ArrayBlockingQueue、LinkedBlockingQueue | 带阻塞语义,用于生产者消费者 |
| 并发无锁队列 | ConcurrentLinkedQueue | CAS 实现,无界非阻塞 |
// 阻塞队列四组 API:失败策略不同
// add/remove/element → 抛异常
// offer/poll/peek → 返回 false/null
// put/take → 永久阻塞
// offer/poll(timeout) → 限时阻塞
ArrayBlockingQueue<String> q = new ArrayBlockingQueue<>(100);
boolean ok = q.offer("item", 2, TimeUnit.SECONDS); // 限时入队
String v = q.poll(2, TimeUnit.SECONDS); // 限时出队
5.2 选型对照
| 需求 | 推荐 |
|---|---|
| 单生产者单消费者 | ArrayDeque / ArrayBlockingQueue(数组更省空间) |
| 多生产者多消费者、需要容量限制 | LinkedBlockingQueue 或 ArrayBlockingQueue |
| 无界高吞吐 | ConcurrentLinkedQueue |
| 延迟执行 / 优先级 | DelayQueue / PriorityQueue + Blocking |
| 批量 + 并发 | 参考 Disruptor(环形缓冲,非 JDK) |
一句话总结: 队列选型先问三件事:有界还是无界?单还是多消费者?要不要阻塞? 三问定了,BlockingQueue 家族的实现自然浮出水面。
六、迭代器与 fail-fast
fail-fast:迭代过程中检测到集合被结构性修改(add/remove/clear),
立即抛 ConcurrentModificationException。
实现:每个集合维护 modCount,迭代器比较 expectedModCount。
List<String> list = new ArrayList<>(List.of("a", "b", "c"));
Iterator<String> it = list.iterator();
it.next();
list.add("d"); // 结构性修改
it.next(); // 抛 ConcurrentModificationException
// 正确删除:用迭代器自己的 remove,而不是集合的 remove
Iterator<String> it = list.iterator();
while (it.hasNext()) {
if (it.next().equals("a")) it.remove(); // 更新 expectedModCount
}
一句话总结: fail-fast 是"检测到竞争就报错"的快速失败设计,不是并发安全保证;并发遍历该用并发容器的弱一致迭代器或加锁。
七、集合性能与工程选型指南
7.1 使用率最高的选型表
| 需求 | 首选 |
|---|---|
| 有序、可重复、随机访问 | ArrayList |
| 唯一性 | HashSet(无序)/ TreeSet(有序)/ LinkedHashSet(插入序) |
| Key-Value | HashMap / LinkedHashMap / TreeMap |
| 线程安全 Map | ConcurrentHashMap |
| 线程安全 List | CopyOnWriteArrayList(读多写少) |
| 生产者消费者 | BlockingQueue 系 |
| 先进后出 | ArrayDeque(不要用 Stack) |
7.2 工程建议
- 预分配容量:已知数量时给 ArrayList/HashMap 指定初始容量,避免多次扩容。
- HashMap 容量设为元素数 / 0.75:
new HashMap<>(expectedSize * 4 / 3 + 1)。 - Map 遍历选
entrySet而非先 keys 再 get(少一次查找)。 - 不要用
Hashtable/Vector/Stack:全表同步,历史遗留。 Collections.emptyList()代替new ArrayList<>()返回空时零分配。
一句话总结: 集合选型顺序是:线程安全需求 → 有序性 → 唯一性 → 容量预分配;大部分场景 HashMap + ArrayList + ConcurrentHashMap 三件套即可覆盖。
八、易踩的坑
| 坑 | 现象 | 对策 |
|---|---|---|
| 迭代时集合增删 | ConcurrentModificationException | 迭代器 remove / 并发容器 |
| HashMap 初始容量给 0 | 反复扩容 | 给足预期容量 |
| 可变对象作 key | hash 变化导致"找不到" | key 用不可变对象 |
| 高并发用 HashMap | 死循环/数据丢失 | 用 ConcurrentHashMap |
| COW 用在写多场景 | 每次全量拷贝 | 写多改用并发写容器 |
| LinkedList 当常规 List 用 | 随机访问 O(n) | 默认 ArrayList |
九、总结
| 容器 | 数据结构 | 线程安全 | 典型场景 |
|---|---|---|---|
| ArrayList | 动态数组 | 否 | 随机访问为主 |
| LinkedList | 双向链表 | 否 | 头尾频繁增删 |
| HashMap | 数组+链表/红黑树 | 否 | 通用 KV |
| ConcurrentHashMap | 数组+链表/红黑树 | 是(桶锁) | 高并发 KV |
| CopyOnWriteArrayList | 写时复制数组 | 是(读无锁) | 读多写少 |
| BlockingQueue | 队列+阻塞 | 是 | 生产者消费者 |
一句话记住:集合框架的核心是两个决策轴——数据结构(数组 vs 链表 vs 树 vs 哈希)与并发策略(不加锁 vs 整表锁 vs 桶锁 vs 写时复制)。把这两轴想清楚,任何集合问题都能在 30 秒内给出结论。
延伸阅读
- Java 并发编程与 JUC 包完全指南 — AQS、CAS、锁与并发容器协同使用
- Java 性能优化:从代码到 JVM 的全链路调优 — 集合与数据结构的性能基准
- Java 17+ 核心语法深度指南 — Stream API 与集合的高阶配合
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。