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

集合是 Java 面试和日常开发出场率最高的知识点,没有之一。本文不聊并发容器(ConcurrentHashMap、CopyOnWriteArrayList 的细节已在《Java 并发编程核心》一文中拆解过),而是聚焦单线程场景下的集合骨架:ArrayList 为什么扩容是 1.5 倍?HashMap 为什么容量必须是 2 的幂?链表为什么树化阈值是 8?LinkedHashMap 如何三行代码实现 LRU?把这些问题一次讲透。

一、集合框架全景图

Java 集合框架以两个顶层接口为根:Collection(单元素)和 Map(键值对)。

1
2
3
4
5
6
7
8
9
Iterable
└── Collection
├── List(有序、可重复):ArrayList / LinkedList / Vector
├── Set(不可重复):HashSet / LinkedHashSet / TreeSet
└── Queue(队列):ArrayDeque / PriorityQueue / LinkedList
Map(独立体系)
├── HashMap → LinkedHashMap(有序)
├── TreeMap(排序)
└── Hashtable(遗留,勿用)

记忆骨架:List 管顺序,Set 管唯一,Map 管映射,Queue 管出入。所有非并发集合的线程安全问题都不靠容器自己解决,需要外部同步。

二、ArrayList:动态数组的扩容艺术

2.1 底层结构与关键字段

1
2
3
4
5
6
7
public class ArrayList<E> extends AbstractList<E>
implements List<E>, RandomAccess, Cloneable, java.io.Serializable {

private static final int DEFAULT_CAPACITY = 10; // 默认容量
transient Object[] elementData; // 真正存数据的数组
private int size; // 实际元素个数(≠ 数组长度)
}

注意 sizeelementData.length 是两回事:size 是逻辑长度,数组长度是物理容量。无参构造时数组初始是空数组,第一次 add 才真正分配容量 10——这是懒加载设计。

2.2 扩容机制:为什么是 1.5 倍

1
2
3
4
5
6
7
8
9
private void grow(int minCapacity) {
int oldCapacity = elementData.length;
int newCapacity = oldCapacity + (oldCapacity >> 1); // 1.5 倍
if (newCapacity - minCapacity < 0)
newCapacity = minCapacity;
if (newCapacity - MAX_ARRAY_SIZE > 0)
newCapacity = hugeCapacity(minCapacity);
elementData = Arrays.copyOf(elementData, newCapacity);
}

扩容流程:add → 判断容量够不够 → 不够则 grow新容量 = 旧容量 × 1.5Arrays.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
2
3
4
final void checkForComodification() {
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
}

遍历时删除元素的正确姿势:用迭代器自身的 iterator.remove()(会同步 expectedModCount),或 JDK 8+ 的 list.removeIf(pred),或倒序 for 循环。直接在 foreach 里 list.remove() 必然抛 ConcurrentModificationException(或更糟——不抛但数据错乱)。

三、LinkedList:双向链表的真实定位

1
2
3
4
5
private static class Node<E> {
E item;
Node<E> next;
Node<E> prev;
}

LinkedList 是双向链表,实现了 ListDeque 双接口。但一个经典误区是”随机插入用 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
2
3
4
5
6
7
8
9
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}

static final int hash(Object key) {
int h;
// 扰动:高 16 位异或低 16 位
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

put 一次的完整旅程:

  1. 计算 hash(key)(扰动函数);
  2. 若数组为空,先 resize() 初始化;
  3. (n - 1) & hash 定位桶下标——等价于 hash % n,但要求 n 是 2 的幂,位运算比取模快一个量级;
  4. 桶为空 → 直接新建节点;
  5. 桶非空 → 首节点 key 相同则覆盖;是 TreeNode 走红黑树插入;否则遍历链表尾插,长度达 8 且数组容量 ≥ 64 时树化;
  6. ++size > thresholdresize() 扩容。

扰动函数的意义:直接用 hashCode 低位做下标,若 hashCode 只在高位有差异(如某些对象地址分布),桶会大量碰撞。h ^ (h >>> 16) 把高 16 位”混合”进低位,让散列更均匀——这是用一次异或换更低碰撞率的划算买卖。

4.2 为什么容量必须是 2 的幂

length - 1 的二进制形如 0000 1111,与 hash 相与后下标均匀落在 [0, n-1]。若长度不是 2 的幂,n-1 的二进制出现 0 位,某些下标永远取不到,散列性直接劣化。这也是为什么 tableSizeFor 会把任意初始容量向上取整到最近的 2 的幂:

1
2
3
4
static final int tableSizeFor(int cap) {
int n = -1 >>> Integer.numberOfLeadingZeros(cap - 1);
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}

指定 new HashMap<>(17),实际容量会是 32。

4.3 树化:为什么阈值是 8 和 6

1
2
3
static final int TREEIFY_THRESHOLD = 8;     // 链表 → 红黑树
static final int UNTREEIFY_THRESHOLD = 6; // 红黑树 → 链表
static final int MIN_TREEIFY_CAPACITY = 64; // 树化前提:数组容量达标

两个反直觉的点:

  • 树化还需要数组容量 ≥ 64。容量小时碰撞多是因为”桶太少”而不是”散列差”,此时优先扩容而不是树化。
  • 阈值 8 来源于泊松分布:在理想散列下,单桶链表长度达到 8 的概率约为千万分之六(0.00000006)。正常使用几乎触发不了树化,树化只是应对散列恶意攻击(哈希碰撞 DoS)的兜底。
  • 退化阈值 6 而不是 7:留出缓冲带,防止节点数在 7、8 之间抖动导致链表/树反复转换。

4.4 扩容:JDK 8 的高低位拆分

扩容把数组翻倍,节点要么留在原下标 i,要么去 i + oldCap判断依据只需看 hash 在 oldCap 对应位上是 0 还是 1

1
2
3
4
5
6
7
8
9
if ((e.hash & oldCap) == 0) {
// 低位链:留在原位置
if (loTail == null) loHead = e; else loTail.next = e;
loTail = e;
} else {
// 高位链:移动到 原下标 + oldCap
if (hiTail == null) hiHead = e; else hiTail.next = e;
hiTail = e;
}

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)

JDK 8 修复了头插法环形链表,但 HashMap 从来就不是线程安全的:并发 put 仍会丢数据(两线程同时定位到同一空桶互相覆盖)。并发场景请直接使用 ConcurrentHashMap——其分段锁/CAS+synchronized 的实现已在《Java 并发编程核心》详解,此处不重复。

五、LinkedHashMap:三行代码的 LRU

LinkedHashMap 在 HashMap 的 Node 上加了 before/after 双指针,维护一条贯穿所有节点的双向链表:

1
2
3
static class Entry<K,V> extends HashMap.Node<K,V> {
Entry<K,V> before, after;
}
  • accessOrder = false(默认):按插入顺序迭代;
  • accessOrder = true:按访问顺序迭代,get 一次就把节点挪到链表尾。

配合 removeEldestEntry,一个 20 行的 LRU 缓存就出来了:

1
2
3
4
5
6
7
8
9
10
11
12
13
class LruCache<K, V> extends LinkedHashMap<K, V> {
private final int maxEntries;

LruCache(int maxEntries) {
super(16, 0.75f, true); // accessOrder = true 是灵魂
this.maxEntries = maxEntries;
}

@Override
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
return size() > maxEntries; // 超容量淘汰最久未访问的队头
}
}

面试常问的”手写 LRU”,标准答案就是这个类。注意 accessOrder = true 时 get/put 都是写操作,多线程使用同样要外部加锁。

六、HashSet 与 TreeMap 的底层真相

HashSet 就是包装了 HashMap

1
2
3
4
5
6
public class HashSet<E> ... {
private transient HashMap<E,Object> map;
private static final Object PRESENT = new Object(); // 共享的 value 占位符

public boolean add(E e) { return map.put(e, PRESENT) == null; }
}

所有元素作为 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 条实战避坑
  1. 预估容量new ArrayList<>(1000)new HashMap<>(1024) 能避免反复扩容拷贝;HashMap 注意传入的是初始容量,内部会自动取 2 的幂,想装 1024 条不扩容应传 2048(阈值 = 容量 × 0.75)。
  2. foreach 中增删:会抛 ConcurrentModificationException,用 removeIf 或迭代器 remove
  3. 自定义对象做 key:必须同时重写 equalshashCode,只重写一个会导致”存进去取不出来”。
  4. Arrays.asList 的坑:返回的是定长视图,add/remove 会抛 UnsupportedOperationException;转换后需要可变列表用 new ArrayList<>(Arrays.asList(...))
  5. subList 是视图list.subList(0,5) 与原列表共享数据,结构性修改原列表后 subList 直接失效。
  6. 负载因子不要乱调:默认 0.75 是时间与空间的黄金平衡点,调大省内存但碰撞增多、查找变慢。
  7. key 用包装类优先:Integer、String 等 hashCode 质量高且不可变;可变对象做 key 后改字段,永远取不回 value。
  8. Collections.unmodifiableList 只是视图:底层列表变了它跟着变,真正不可变集合用 JDK 9+ 的 List.of / Map.of

参考阅读:并发容器(ConcurrentHashMap、CopyOnWriteArrayList、BlockingQueue)的原理与 JDK7/8 差异,见本站《Java 并发编程核心:volatile、synchronized、CAS 与 AQS 原理解析》;线程池的参数与执行流程见《Java 线程池详解》。