Java 集合框架核心:ArrayList、HashMap 源码解析与选型避坑

Java 集合框架核心:ArrayList、HashMap 源码解析与选型避坑
神经蛙集合是 Java 面试和日常开发出场率最高的知识点,没有之一。本文不聊并发容器(ConcurrentHashMap、CopyOnWriteArrayList 的细节已在《Java 并发编程核心》一文中拆解过),而是聚焦单线程场景下的集合骨架:ArrayList 为什么扩容是 1.5 倍?HashMap 为什么容量必须是 2 的幂?链表为什么树化阈值是 8?LinkedHashMap 如何三行代码实现 LRU?把这些问题一次讲透。
一、集合框架全景图
Java 集合框架以两个顶层接口为根:Collection(单元素)和 Map(键值对)。
1 | Iterable |
二、ArrayList:动态数组的扩容艺术
2.1 底层结构与关键字段
1 | public class ArrayList<E> extends AbstractList<E> |
注意 size 和 elementData.length 是两回事:size 是逻辑长度,数组长度是物理容量。无参构造时数组初始是空数组,第一次 add 才真正分配容量 10——这是懒加载设计。
2.2 扩容机制:为什么是 1.5 倍
1 | private void grow(int minCapacity) { |
扩容流程:add → 判断容量够不够 → 不够则 grow → 新容量 = 旧容量 × 1.5 → Arrays.copyOf 整体拷贝到新数组。
| 扩容策略 | 典型代表 | 思路 | 代价 |
|---|---|---|---|
| 1.5 倍 | ArrayList | 温和增长,空间利用率高 | 扩容次数比 2 倍多 |
| 2 倍 | HashMap | 便于位运算取模与高低位拆分 | 空间浪费略多 |
1.5 倍是空间与拷贝次数的折中:扩太慢(如 1.1 倍)拷贝频繁,扩太快(如 2 倍)浪费内存。oldCapacity >> 1 用位移代替乘法,也是源码的小巧思。
2.3 modCount 与 fail-fast
ArrayList 内部维护一个 modCount(继承自 AbstractList),任何结构化修改(add/remove)都会自增。迭代器创建时会保存 expectedModCount,每次 next() 前校验:
1 | final void checkForComodification() { |
三、LinkedList:双向链表的真实定位
1 | private static class Node<E> { |
LinkedList 是双向链表,实现了 List 和 Deque 双接口。但一个经典误区是”随机插入用 LinkedList 更快”——实际上:
| 操作 | ArrayList | LinkedList |
|---|---|---|
| 尾部插入 | 均摊 O(1)(可能触发扩容) | O(1) |
| 中间插入(已定位下标) | O(n) 挪元素 | O(n) 定位 + O(1) 插入 |
| 随机访问 get(i) | O(1) | O(n),且会判断 i 在前半还是后半选择从头/尾遍历 |
| 内存布局 | 连续,CPU 缓存友好 | 节点离散,每节点多 2 个指针开销 |
中间插入时 LinkedList 先要 node(i) 走链定位,同样是 O(n),且 CPU 缓存对连续数组极不友好。绝大多数场景 ArrayList 都优于 LinkedList;需要双端队列时用 ArrayDeque 而不是 LinkedList。
四、HashMap:源码的重头戏
4.1 put 全流程
1 | public V put(K key, V value) { |
put 一次的完整旅程:
- 计算
hash(key)(扰动函数); - 若数组为空,先
resize()初始化; (n - 1) & hash定位桶下标——等价于 hash % n,但要求 n 是 2 的幂,位运算比取模快一个量级;- 桶为空 → 直接新建节点;
- 桶非空 → 首节点 key 相同则覆盖;是 TreeNode 走红黑树插入;否则遍历链表尾插,长度达 8 且数组容量 ≥ 64 时树化;
++size > threshold→resize()扩容。
扰动函数的意义:直接用 hashCode 低位做下标,若 hashCode 只在高位有差异(如某些对象地址分布),桶会大量碰撞。h ^ (h >>> 16) 把高 16 位”混合”进低位,让散列更均匀——这是用一次异或换更低碰撞率的划算买卖。
4.2 为什么容量必须是 2 的幂
length - 1 的二进制形如 0000 1111,与 hash 相与后下标均匀落在 [0, n-1]。若长度不是 2 的幂,n-1 的二进制出现 0 位,某些下标永远取不到,散列性直接劣化。这也是为什么 tableSizeFor 会把任意初始容量向上取整到最近的 2 的幂:
1 | static final int tableSizeFor(int cap) { |
指定 new HashMap<>(17),实际容量会是 32。
4.3 树化:为什么阈值是 8 和 6
1 | static final int TREEIFY_THRESHOLD = 8; // 链表 → 红黑树 |
两个反直觉的点:
- 树化还需要数组容量 ≥ 64。容量小时碰撞多是因为”桶太少”而不是”散列差”,此时优先扩容而不是树化。
- 阈值 8 来源于泊松分布:在理想散列下,单桶链表长度达到 8 的概率约为千万分之六(
0.00000006)。正常使用几乎触发不了树化,树化只是应对散列恶意攻击(哈希碰撞 DoS)的兜底。 - 退化阈值 6 而不是 7:留出缓冲带,防止节点数在 7、8 之间抖动导致链表/树反复转换。
4.4 扩容:JDK 8 的高低位拆分
扩容把数组翻倍,节点要么留在原下标 i,要么去 i + oldCap。判断依据只需看 hash 在 oldCap 对应位上是 0 还是 1:
1 | if ((e.hash & oldCap) == 0) { |
JDK 8 不需要重新计算每个元素的 hash,一次与运算完成分流,且链表保持原相对顺序——这是 JDK 8 相对 JDK 7 最大的性能改进之一。
4.5 JDK 7 vs JDK 8 关键差异
| 维度 | JDK 7 | JDK 8 |
|---|---|---|
| 结构 | 数组 + 链表 | 数组 + 链表 + 红黑树 |
| 插入方式 | 头插法 | 尾插法 |
| 扩容后顺序 | 链表逆序 | 保持原序 |
| 并发扩容风险 | 可能形成环形链表,get 时 CPU 100% | 环形链问题消除,但仍非线程安全 |
| hash 计算 | 4 次扰动 | 1 次扰动(h ^ h>>>16) |
五、LinkedHashMap:三行代码的 LRU
LinkedHashMap 在 HashMap 的 Node 上加了 before/after 双指针,维护一条贯穿所有节点的双向链表:
1 | static class Entry<K,V> extends HashMap.Node<K,V> { |
accessOrder = false(默认):按插入顺序迭代;accessOrder = true:按访问顺序迭代,get 一次就把节点挪到链表尾。
配合 removeEldestEntry,一个 20 行的 LRU 缓存就出来了:
1 | class LruCache<K, V> extends LinkedHashMap<K, V> { |
六、HashSet 与 TreeMap 的底层真相
HashSet 就是包装了 HashMap:
1 | public class HashSet<E> ... { |
所有元素作为 key 存入,value 用同一个哑对象填充。因此 LinkedHashSet = LinkedHashMap 的 key 视图,TreeSet = TreeMap 的 key 视图——一套代码三个容器。
TreeMap 底层是红黑树,按 key 排序,提供 firstKey/ceilingKey/floorKey/subMap 等范围 API。使用它要求 key 可排序:实现 Comparable 或构造时传入 Comparator,否则直接 ClassCastException。
七、集合选型速查表
| 需求场景 | 推荐 | 理由 |
|---|---|---|
| 随机访问、尾部增删 | ArrayList | 下标 O(1),缓存友好 |
| 频繁头部增删 / 双端队列 | ArrayDeque | 比 LinkedList 更省内存更快 |
| 去重、无序 | HashSet | HashMap 的 key 视图 |
| 去重、保持插入顺序 | LinkedHashSet | 多维护一条链表 |
| 排序、范围查询 | TreeMap / TreeSet | 红黑树,O(log n) |
| 键值缓存 + 淘汰策略 | LinkedHashMap(accessOrder=true) | 天然 LRU |
| 键值映射(默认选择) | HashMap | 综合性能最优 |
| 并发场景 | ConcurrentHashMap / CopyOnWriteArrayList | 见并发篇,勿用 Hashtable |
八、高频避坑清单
点开查看 8 条实战避坑
- 预估容量:
new ArrayList<>(1000)、new HashMap<>(1024)能避免反复扩容拷贝;HashMap 注意传入的是初始容量,内部会自动取 2 的幂,想装 1024 条不扩容应传 2048(阈值 = 容量 × 0.75)。 - foreach 中增删:会抛 ConcurrentModificationException,用
removeIf或迭代器remove。 - 自定义对象做 key:必须同时重写
equals和hashCode,只重写一个会导致”存进去取不出来”。 - Arrays.asList 的坑:返回的是定长视图,add/remove 会抛 UnsupportedOperationException;转换后需要可变列表用
new ArrayList<>(Arrays.asList(...))。 - subList 是视图:
list.subList(0,5)与原列表共享数据,结构性修改原列表后 subList 直接失效。 - 负载因子不要乱调:默认 0.75 是时间与空间的黄金平衡点,调大省内存但碰撞增多、查找变慢。
- key 用包装类优先:Integer、String 等 hashCode 质量高且不可变;可变对象做 key 后改字段,永远取不回 value。
- Collections.unmodifiableList 只是视图:底层列表变了它跟着变,真正不可变集合用 JDK 9+ 的
List.of / Map.of。
参考阅读:并发容器(ConcurrentHashMap、CopyOnWriteArrayList、BlockingQueue)的原理与 JDK7/8 差异,见本站《Java 并发编程核心:volatile、synchronized、CAS 与 AQS 原理解析》;线程池的参数与执行流程见《Java 线程池详解》。















