Java 集合技术深度精讲


一、ArrayList 深度解析

1.1 本质:一个会自动长大的数组

类比:ArrayList 就像一个可以自动扩容的书架。一开始给你 10 个格子,放满了就换一个 15 个格子的新书架,把旧书搬过去。

// 核心字段(JDK8 源码简化版)
public class ArrayList<E> {
    transient Object[] elementData;  // 存数据的数组
    private int size;                // 实际元素个数(注意不是数组长度)
}

1.2 扩容机制

初始状态(无参构造):elementData = {}(空数组)

第一次 add:分配默认容量 10

容量不够时:newCapacity = oldCapacity + (oldCapacity >> 1)
          即 新容量 = 旧容量 × 1.5(向下取整)

调用 Arrays.copyOf 将旧数组拷贝到新数组

扩容过程源码关键逻辑

private void grow(int minCapacity) {
    int oldCapacity = elementData.length;
    // 核心:右移一位 = 除以2,所以新容量 = 旧容量 * 1.5
    int newCapacity = oldCapacity + (oldCapacity >> 1);
    if (newCapacity - minCapacity < 0)
        newCapacity = minCapacity;
    if (newCapacity - MAX_ARRAY_SIZE > 0)
        newCapacity = hugeCapacity(minCapacity);
    // 数组拷贝,这步开销最大
    elementData = Arrays.copyOf(elementData, newCapacity);
}

为什么是 1.5 倍?

  • 太小(如 1.25 倍):扩容太频繁,频繁数组拷贝
  • 太大(如 2 倍):浪费内存。且数学上 2 倍扩容会导致新数组总是大于之前所有数组之和,无法复用之前释放的内存空间
  • 1.5 倍是时间和空间的折中

一句话总结:ArrayList 的扩容 = 分配 1.5 倍新数组 + System.arraycopy。预知大小时务必指定初始容量。

1.3 删除元素的代价

删除 index=2 的元素:

[a][b][c][d][e][null][null]

   删除这个元素后,后面的全部左移

[a][b][d][e][null][null][null]
      ↑---↑ System.arraycopy 移动

时间复杂度:O(n),因为要移动后续元素

1.4 fast-fail 机制

// ArrayList 内部有一个 modCount 字段,每次结构性修改(add/remove)都会 modCount++
// Iterator 创建时记录 expectedModCount = modCount
// 每次 next() 都会检查 modCount != expectedModCount → 抛 ConcurrentModificationException

// 这就是为什么遍历时不能直接调用 list.remove()
// 但可以用 iterator.remove(),因为它会同步更新 expectedModCount

二、LinkedList 深度解析

2.1 本质:双向链表

     ┌──────────────────────────────────────┐
     │              LinkedList               │
     │  first ──→ [prev|A|next] ←──→ ...    │
     │  last  ──→ [prev|Z|next]             │
     │  size = N                             │
     └──────────────────────────────────────┘

每个节点(Node):
    ┌──────┬──────┬──────┐
    │ prev │ item │ next │
    └──────┴──────┴──────┘
private static class Node<E> {
    E item;
    Node<E> next;
    Node<E> prev;
}

2.2 为什么实际项目中几乎不用 LinkedList?

对比项ArrayListLinkedList
随机访问 get(i)O(1) 直接算偏移O(n) 从头/尾遍历
尾部添加均摊 O(1)O(1)
中间插入O(n) 移动元素O(1) 插入 + O(n) 定位
内存占用紧凑,CPU 缓存友好每个节点多两个指针 + 内存不连续
迭代性能连续内存,预取命中率高内存跳跃,缓存行频繁失效

关键洞察:LinkedList 理论上中间插入是 O(1),但定位到那个位置需要 O(n)。加上 CPU 缓存对连续内存的偏爱,ArrayList 在绝大多数场景下都更快。

一句话总结:LinkedList 的唯一优势场景是”已经持有节点引用,频繁在已知位置插删”——但 Java 的 LinkedList 并不暴露节点引用,所以这个优势也用不上。


三、HashMap 深度解析(重中之重)

3.1 数据结构演进

JDK7:数组 + 链表
┌───┬───┬───┬───┬───┬───┬───┬───┐
│ 0 │ 1 │ 2 │ 3 │ 4 │ 5 │ 6 │ 7 │  ← 数组(桶)
└───┴─┬─┴───┴───┴─┬─┴───┴───┴───┘
      │            │
      ↓            ↓
    [K1,V1]     [K5,V5]
      ↓            ↓
    [K2,V2]     [K6,V6]     ← 链表(解决哈希冲突)

    [K3,V3]

JDK8:数组 + 链表 + 红黑树
┌───┬───┬───┬───┬───┬───┬───┬───┐
│ 0 │ 1 │ 2 │ 3 │ 4 │ 5 │ 6 │ 7 │
└───┴─┬─┴───┴───┴─┬─┴───┴───┴───┘
      │            │
      ↓            ↓
    [K1,V1]      ┌───┐
      ↓          │红黑│  ← 链表长度 ≥ 8 且数组长度 ≥ 64 时,转为红黑树
    [K2,V2]      │ 树 │     查找从 O(n) 降为 O(log n)
                 └───┘

3.2 hash 扰动函数

// JDK8 的 hash 方法
static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
// 高 16 位与低 16 位异或,让高位也参与索引计算
// 因为桶索引 = hash & (n-1),当 n 较小时只有低位参与
// 不扰动的话,高位不同但低位相同的 key 会全部冲突

// 定位桶的公式:
// index = (n - 1) & hash
// 等价于 hash % n(当 n 是 2 的幂时)
// 位运算比取模快得多,这就是为什么 HashMap 容量必须是 2 的幂

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

假设 n = 16(二进制 10000)
n - 1 = 15(二进制 01111)

hash & (n-1) 相当于取 hash 的低 4 位
这比 hash % n 快得多(一条 AND 指令 vs 除法指令)

3.3 put 流程(JDK8)

put(key, value)


计算 hash = hash(key)


table 为空?──是──→ resize() 初始化(默认 16)
    │ 否

计算桶位置 i = (n-1) & hash


桶为空?──是──→ 直接放入新节点
    │ 否

桶的第一个节点 key 相同?──是──→ 覆盖 value
    │ 否

是红黑树节点?──是──→ 走红黑树的插入逻辑
    │ 否

遍历链表:
  ├─ 找到相同 key → 覆盖 value
  └─ 到达链尾 → 尾插法插入新节点


     链表长度 ≥ 8?
       ├─ 是 → 数组长度 ≥ 64?
       │        ├─ 是 → 链表转红黑树(treeifyBin)
       │        └─ 否 → resize() 扩容(优先扩容而非树化)
       └─ 否 → 结束


++size > threshold?──是──→ resize() 扩容

  结束

⚠️ JDK7 是头插法,JDK8 改为尾插法。头插法在多线程 resize 时会形成环形链表,导致死循环。这是经典面试点。

3.4 扩容机制(resize)

扩容条件:size > capacity × loadFactor(默认 16 × 0.75 = 12)

扩容过程:
1. 新建 2 倍大小的数组
2. 遍历旧数组的每个桶:
   - 桶里只有一个节点 → 重新计算位置放入新数组
   - 桶里是链表 → 拆分为两条链表(低位链表和高位链表)
   - 桶里是红黑树 → 拆分为两棵树(可能退化为链表)

关键优化(JDK8):
旧桶中的元素要么在新数组的【原位置】,要么在【原位置 + 旧容量】
判断方式:(hash & oldCap) == 0 → 原位置,否则 → 原位置 + oldCap
这样不需要重新计算 hash,一次位运算搞定
示例:oldCap = 16, newCap = 32

某元素 hash = 0b...0_1010  → 原位置 index = 10
        hash & oldCap(16) = hash & 0b10000 = 0 → 留在 index=10

某元素 hash = 0b...1_1010  → 原位置 index = 10
        hash & oldCap(16) = hash & 0b10000 = 16 → 移到 index=10+16=26

3.5 树化与退化

链表 → 红黑树的条件(同时满足):
  1. 链表长度 ≥ 8(TREEIFY_THRESHOLD)
  2. 数组长度 ≥ 64(MIN_TREEIFY_CAPACITY)
  如果链表长度 ≥ 8 但数组长度 < 64,优先扩容而非树化

红黑树 → 链表的条件:
  扩容时,红黑树拆分后节点数 ≤ 6(UNTREEIFY_THRESHOLD)时退化为链表

为什么是 8?
  理想情况下(hash 均匀分布),桶中元素个数服从泊松分布
  长度达到 8 的概率只有 0.00000006(六千万分之一)
  8 是一个在极端情况下才触发的安全阀值,既不浪费树的开销,又能防止链表过长

为什么退化阈值是 6 不是 8?
  避免在 7-8 之间频繁树化和退化(抖动),中间留了缓冲

3.6 线程不安全的具体表现

1.【JDK7 死循环】
  多线程同时 resize → 头插法导致链表形成环 → get() 死循环 → CPU 100%

2.【JDK8 数据丢失】
  线程A 和线程B 同时 put,hash 到同一个空桶
  线程A 判断桶为空,挂起
  线程B 判断桶为空,写入节点
  线程A 恢复,直接写入 → 覆盖了线程B的节点 → 数据丢失

3.【size 不准确】
  size++ 不是原子操作(读-改-写三步),并发下计数出错

一句话总结:HashMap 是面试考得最多的集合类。核心记住:数组+链表+红黑树、扰动函数、2的幂容量、1.5倍不对是1倍(翻倍)扩容、8和64树化阈值、尾插法。


四、ConcurrentHashMap 深度解析

4.1 JDK7:分段锁(Segment)

ConcurrentHashMap (JDK7)
┌─────────────────────────────────────────┐
│  Segment[](默认 16 个段)                │
│  ┌──────┐ ┌──────┐     ┌──────┐        │
│  │Seg[0]│ │Seg[1]│ ... │Seg[15]│        │
│  │(Lock)│ │(Lock)│     │(Lock) │        │
│  └──┬───┘ └──┬───┘     └──┬────┘        │
│     ↓        ↓            ↓              │
│  [HashEntry数组]  [HashEntry数组]  ...   │
│     ↓        ↓                           │
│   链表      链表                          │
└─────────────────────────────────────────┘

特点:
- 每个 Segment 是一把 ReentrantLock
- 不同 Segment 的操作互不干扰 → 最多支持 16 个线程并发写
- 先定位 Segment,再定位桶
- 锁粒度:Segment 级别

4.2 JDK8:CAS + synchronized

ConcurrentHashMap (JDK8)
┌──────────────────────────────────────┐
│  Node[](和 HashMap 类似的数组)       │
│  ┌───┬───┬───┬───┬───┬───┬───┬───┐  │
│  │ 0 │ 1 │ 2 │ 3 │ 4 │ 5 │ 6 │ 7 │  │
│  └─┬─┴───┴─┬─┴───┴───┴───┴───┴───┘  │
│    │        │                         │
│    ↓        ↓                         │
│  CAS写入  synchronized(头节点) {      │
│  (空桶)     链表/红黑树操作            │
│            }                          │
└──────────────────────────────────────┘

put 流程:
1. 计算 hash
2. 桶为空 → CAS 写入(无锁)
3. 桶不为空 → synchronized(桶的头节点) → 链表/红黑树操作
4. 链表长度 ≥ 8 → 树化(和 HashMap 类似)

锁粒度:桶级别(比 Segment 更细,并发度更高)

4.3 size() 的实现

// ConcurrentHashMap 的 size() 没那么简单
// 不能简单用一个 volatile int size,因为 put/remove 时 CAS 更新会有激烈竞争

// JDK8 方案:类似 LongAdder 的分段计数
// baseCount + CounterCell[]
//
// 低竞争:CAS 更新 baseCount
// 高竞争:分散到 CounterCell[] 数组的不同槽
// size() = baseCount + Σ CounterCell[i].value
//
// 这也是为什么 ConcurrentHashMap 的 size() 是一个"估算值"
// 它不保证在并发修改时返回精确值

一句话总结:JDK7 分段锁、JDK8 CAS+synchronized 锁桶头节点。JDK8 并发度等于桶的数量,远大于 JDK7 的 16。


五、TreeMap 与 LinkedHashMap

5.1 TreeMap:红黑树实现的有序 Map

TreeMap 底层是红黑树,所有操作 O(log n)

         ┌───[5]───┐          红黑树特点:
         │  (黑)   │          1. 节点非红即黑
      ┌─[3]─┐   ┌─[7]─┐     2. 根节点是黑色
      │(红)  │   │(红)  │     3. 红色节点的子节点必须是黑色
    [1]    [4]  [6]    [9]   4. 任意节点到叶子的黑色节点数相同
    (黑)   (黑) (黑)   (黑)  5. 保证最长路径 ≤ 2倍最短路径

适用场景:
- 需要按 key 排序遍历(自然排序或自定义 Comparator)
- 需要范围查询:headMap(toKey), tailMap(fromKey), subMap(from, to)
- 需要获取最大/最小 key:firstKey(), lastKey()
// 实际场景:按分数排名
TreeMap<Integer, List<String>> rankMap = new TreeMap<>(Comparator.reverseOrder());
rankMap.computeIfAbsent(95, k -> new ArrayList<>()).add("张三");
rankMap.computeIfAbsent(88, k -> new ArrayList<>()).add("李四");
rankMap.computeIfAbsent(95, k -> new ArrayList<>()).add("王五");
// 遍历时自动按分数降序:{95=[张三, 王五], 88=[李四]}

// 范围查询:获取 80-90 分的
SortedMap<Integer, List<String>> sub = rankMap.subMap(80, 91);

5.2 LinkedHashMap:保持顺序的 HashMap

LinkedHashMap = HashMap + 双向链表

HashMap 的数组结构:
┌───┬───┬───┬───┐
│ 0 │ 1 │ 2 │ 3 │
└─┬─┴───┴─┬─┴─┬─┘
  A        B   C

双向链表(按插入顺序串联):
head ←→ A ←→ B ←→ C ←→ tail

遍历 LinkedHashMap 时走的是链表,所以顺序 = 插入顺序
如果 accessOrder=true,每次 get/put 会把元素移到链尾 → LRU

一句话总结:TreeMap 用红黑树保证 key 有序,适合范围查询。LinkedHashMap 用双向链表保持插入/访问顺序,适合做 LRU。


六、HashSet 的真相

// HashSet 源码(简化版)
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;
    }

    public boolean contains(Object o) {
        return map.containsKey(o);
    }

    public boolean remove(Object o) {
        return map.remove(o) == PRESENT;
    }
}

就这么简单

  • HashSet 就是一个只用了 key 的 HashMap
  • 去重原理 = HashMap 的 key 不可重复 = hashCode() + equals()
  • LinkedHashSet = LinkedHashMap 的 key
  • TreeSet = TreeMap 的 key

一句话总结:Set 家族是 Map 家族的马甲。理解了 HashMap 就理解了 HashSet。


七、PriorityQueue:最小堆

7.1 数据结构

PriorityQueue 底层是一个数组,逻辑上是完全二叉树(最小堆)

数组:[1, 3, 2, 7, 5, 4, 6]

逻辑结构(最小堆):
           1
         /   \
        3     2
       / \   / \
      7   5 4   6

父子关系(0-based):
  parent(i) = (i - 1) / 2
  left(i)   = 2 * i + 1
  right(i)  = 2 * i + 2

堆顶(index=0)始终是最小值

7.2 核心操作

offer(插入):
1. 放到数组末尾
2. siftUp:与父节点比较,小于父节点就交换,直到满足堆性质
时间复杂度:O(log n)

poll(取出堆顶):
1. 取出 array[0]
2. 把数组末尾元素移到 array[0]
3. siftDown:与较小的子节点比较,大于子节点就交换,直到满足堆性质
时间复杂度:O(log n)

peek(查看堆顶):O(1)

一句话总结:PriorityQueue = 数组实现的最小堆。插入和取出都是 O(log n),适合 TopK 和优先级调度。


八、fail-fast vs fail-safe

特性fail-fastfail-safe
代表ArrayList, HashMapCopyOnWriteArrayList, ConcurrentHashMap
检测机制modCount 检查无检查(操作副本或分段)
遍历中修改抛 ConcurrentModificationException不抛异常
一致性强一致(不允许脏读)弱一致(可能读到旧数据)
性能无额外开销写时复制或分段锁有开销
// fail-fast 示例
List<String> list = new ArrayList<>(List.of("a", "b", "c"));
for (String s : list) {
    list.remove(s); // 💥 ConcurrentModificationException
}

// fail-safe 示例
List<String> list = new CopyOnWriteArrayList<>(List.of("a", "b", "c"));
for (String s : list) {
    list.remove(s); // ✅ 不报错,遍历的是创建 Iterator 时的快照
}

一句话总结:fail-fast 是”立即报错”策略,fail-safe 是”读旧数据也行”策略。选哪个取决于你要一致性还是可用性。


九、Comparable vs Comparator

// Comparable:类自身实现,定义"自然排序"
// "我知道我自己怎么排"
public class Student implements Comparable<Student> {
    private int score;

    @Override
    public int compareTo(Student other) {
        return Integer.compare(this.score, other.score); // 按分数升序
    }
}
Collections.sort(students); // 直接排

// Comparator:外部传入,定义"定制排序"
// "我告诉你怎么排"
students.sort(Comparator.comparing(Student::getScore).reversed()); // 按分数降序

// 两者关系:
// Comparable 是"默认排序规则",写在类里面,一个类只能有一种
// Comparator 是"临时排序规则",写在外面,可以有无限种
// TreeMap/TreeSet 构造时可传 Comparator,没传就用 key 的 Comparable

设计原则

  • 如果排序规则是实体的”天然属性”(如 Integer 的大小),实现 Comparable
  • 如果排序规则多变或不属于实体本身(如按姓名排、按年龄排),传 Comparator

一句话总结:Comparable 是”我自己会排”,Comparator 是”你告诉我怎么排”。实际项目中 Comparator(Lambda 写法)用得更多。


十、集合框架的设计思想

10.1 接口与实现分离

List(接口)→ 定义了"有序集合"的行为规范
  ├── ArrayList(实现)→ 用数组实现
  ├── LinkedList(实现)→ 用链表实现
  └── CopyOnWriteArrayList(实现)→ 写时复制实现

调用方只依赖接口:
List<String> list = new ArrayList<>();  // ✅ 面向接口编程
ArrayList<String> list = new ArrayList<>();  // ❌ 绑定了具体实现

10.2 为什么 Map 不继承 Collection?

Collection 表示"一组元素",每次操作一个元素
Map 表示"键值对映射",每次操作一对元素

它们的核心抽象不同:
- Collection.add(element) — 加一个东西
- Map.put(key, value) — 建立一个映射关系

硬要让 Map 继承 Collection 会破坏接口语义
但 Map 提供了三种 Collection 视图:keySet(), values(), entrySet()

10.3 迭代器模式

// 为什么用 Iterator 而不是直接暴露内部结构?
// 因为不同集合内部结构不同(数组、链表、树)
// Iterator 提供了统一的遍历方式,屏蔽了底层差异

// for-each 语法糖的本质就是 Iterator:
for (String s : list) { ... }
// 编译后等价于:
Iterator<String> it = list.iterator();
while (it.hasNext()) {
    String s = it.next();
    ...
}

10.4 时间复杂度全景

操作ArrayListLinkedListHashMapTreeMapHashSetTreeSet
随机访问O(1)O(n)
头部插入O(n)O(1)
尾部插入O(1)*O(1)
查找O(n)O(n)O(1)O(log n)O(1)O(log n)
插入O(1)O(log n)O(1)O(log n)
删除O(n)O(1)**O(1)O(log n)O(1)O(log n)

* 均摊 O(1),扩容时 O(n) ** 已持有节点引用时 O(1),否则查找仍需 O(n)