第2章 集合框架深度:HashMap 原理与并发集合
本系列是《Java 高级用法》篇,承接《Java 基础入门》的 21 章内容。基础篇里你已经会用 ArrayList、HashMap 存数据了,本章要回答"它们底层长什么样、为什么会扩容、HashMap 为什么快、多线程下为什么不能用 HashMap"这些进阶问题。面向小白,术语首次出现都会解释。
背景介绍: 集合是 Java 程序里最常用的"容器",但很多人只知其然:默认容量是多少?add 一万个元素要扩容几次?HashMap 的 key 怎么定位到"桶"?为什么"JDK 8 起链表超 8 转红黑树"?多线程下 HashMap 为什么会出问题、ConcurrentHashMap 又是怎么解决的?这些原理决定数据量上来后代码是"飞快"还是"卡死",也是面试必考内容。本章把原理讲透,并给出 JDK 8 / 11 / 17 三个版本下集合 API 的差异(如 List.of 只有 JDK 11/17 才有)。
阅读提示 :标注【完整程序】的代码可直接编译运行;其余片段放入 main 方法即可。示例尽量覆盖边界值(0、负数、最大值、空集合、null),并用
// 输出:...标注每种情况的结果。

一、ArrayList 扩容机制
ArrayList :底层是一个 Object[] 数组,随机访问快(按下标取值 O(1))、中间插入/删除慢(要搬动元素),默认容量是 10。
扩容 :数组满了还要 add 时,ArrayList 新建一个数组,新容量 = 旧容量 + 旧容量 / 2,也就是 1.5 倍 (oldCapacity + (oldCapacity >> 1)),再把旧元素全部复制过去。
java
// 【完整程序】模拟 ArrayList 扩容:默认容量 10,满了按 1.5 倍扩容
public class ArrayListGrowDemo {
public static void main(String[] args) {
int capacity = 10; // 默认容量
int size = 0;
int growCount = 0;
for (int i = 0; i < 10000; i++) { // 模拟 add 10000 个元素
if (size == capacity) { // 满了,触发扩容
capacity = capacity + (capacity >> 1); // 新容量 = 旧容量 + 旧容量/2 = 1.5 倍
growCount++;
}
size++;
}
System.out.println("最终容量:" + capacity); // 输出:14053
System.out.println("扩容次数:" + growCount); // 输出:18
// 容量序列:10,15,22,33,49,73,109,163,244,366,549,823,1234,1851,2776,4164,6246,9369,14053
// 能预估大小就指定初始容量:new ArrayList<>(10000),一次扩容都不发生
}
}
java
// 【完整程序】ensureCapacity 提前扩容、trimToSize 裁剪容量
import java.util.ArrayList;
public class CapacityApiDemo {
public static void main(String[] args) {
ArrayList<Integer> list = new ArrayList<>(100);
list.ensureCapacity(1000); // 提前扩容到 1000,避免 add 过程中多次扩容
for (int i = 0; i < 5; i++) { list.add(i); }
list.trimToSize(); // 把容量裁剪到实际元素个数,省内存
System.out.println(list.size()); // 输出:5(元素个数不受影响)
// 边界:0 个元素时 trimToSize 也安全,不报错
new ArrayList<Integer>().trimToSize();
}
}
为什么是 1.5 倍? 太小(如 1.25 倍)扩容频繁、总复制成本高;太大(如 2 倍)浪费内存。1.5 倍是空间与时间的折中,JDK 8 及之后版本都是如此。扩容一次的代价 = 复制当前所有元素 ,所以"预估大小 → 指定初始容量"是 ArrayList 最重要的一条性能经验。边界与出错场景 :get(index) 越界抛 IndexOutOfBoundsException;add(0, x) 头部插入要把所有元素后移一位,O(n)。面试常问:add 10000 个元素默认扩容几次?答:18 次(容量按 1.5 倍从 10 涨到 14053);指定初始容量 10000 后 0 次。
二、LinkedList:双向链表

LinkedList :底层是双向链表 ,每个节点(Node)存元素本身、指向前一个节点的引用 prev、指向后一个节点的引用 next。因此它头尾插入/删除快(O(1)),按下标随机访问慢(O(n))------要沿着链一个个找。
java
// 【完整程序】LinkedList 的常用方法与边界情况
import java.util.LinkedList;
public class LinkedListDemo {
public static void main(String[] args) {
LinkedList<String> list = new LinkedList<>();
list.addFirst("头"); // 头部插入 O(1)
list.addLast("尾"); // 尾部插入 O(1)
list.add("中间"); // 默认加到尾部
System.out.println(list); // 输出:[头, 尾, 中间]
System.out.println(list.get(1)); // 输出:尾(按下标访问 O(n))
// 栈/队列风格的 API:LinkedList 实现了 Deque(push/pop/peek/offer/poll)
list.push("栈顶"); // 等价 addFirst
System.out.println(list.pop()); // 输出:栈顶(等价 removeFirst)
list.offer("队尾"); // 等价 addLast
System.out.println(list.poll()); // 输出:头(取出并删除;空链表 poll/peek 返回 null 不抛异常)
// 边界:空链表 get/removeFirst 会抛异常,peek/poll 返回 null
LinkedList<Integer> empty = new LinkedList<>();
System.out.println(empty.peek()); // 输出:null
// empty.get(0); // ❌ IndexOutOfBoundsException
// empty.removeFirst(); // ❌ NoSuchElementException
}
}
使用建议 :频繁在头部/中间插入删除用 LinkedList;频繁按下标取值用 ArrayList。日常开发 ArrayList 用得更多,因为"遍历 + 按索引读"是主流。特别注意 :用 get(i) 在循环里遍历 LinkedList 是 O(n²) 级别的灾难(见易错点 9),一定要用迭代器或 for-each。
三、HashMap 底层原理(本章重点)
HashMap :以"键值对"存数据,核心是哈希(hash) :通过 key 的 hashCode 计算数组下标(桶,bucket ),把键值对存进去;查找时用同样计算直接定位,理想情况增删查都是 O(1)。
数据结构 :JDK 8 起,HashMap = 数组 + 链表 + 红黑树。数组是主体,每个桶要么为空,要么挂着一条链表;链表太长时转红黑树。
hash 怎么算? 三步:取 hashCode → 高 16 位与低 16 位异或(扰动)→ 与"长度-1"做与运算得到桶下标(数组长度是 2 的幂,hash & (n-1) 等价于 hash % n 但更快):
java
// 【完整程序】手算一遍"hello"怎么进桶(模拟 HashMap 的寻址过程)
public class HashLocateDemo {
static int locate(String key, int capacity) {
int h = key.hashCode();
int hash = h ^ (h >>> 16); // 扰动:让高位也参与定位
return hash & (capacity - 1); // 与运算得到桶下标
}
public static void main(String[] args) {
System.out.println("hello".hashCode()); // 输出:99162322
System.out.println(locate("hello", 16)); // 输出:2(默认容量 16 时,"hello" 落在 2 号桶)
System.out.println(locate("hello", 32)); // 输出:18(扩容到 32 后位置变了------这就是扩容要重算下标的原因)
System.out.println(locate("world", 16)); // 输出:11
System.out.println(locate("java", 16)); // 输出:10
}
}
put 流程 (JDK 8 起):① 算 hash 和桶下标;② 桶为空直接放入;③ 桶不为空,遍历链表:key 相同(equals 判断)则覆盖旧值 ,否则在链表尾部插入 ;④ 链表长度超过 8 且数组长度 ≥ 64 时转红黑树 (查询从 O(n) 优化到 O(log n));⑤ 元素个数超过"容量 × 加载因子"时扩容为 2 倍,所有元素重新计算下标搬过去。
get 流程:算桶下标 → 桶空返回 null → 红黑树按树查找 → 链表逐个 equals 比较,找到返回 value,否则 null。
java
// 【完整程序】HashMap 的增删查与各种边界情况
import java.util.HashMap;
public class HashMapDemo {
public static void main(String[] args) {
HashMap<String, Integer> m = new HashMap<>();
// put:返回旧值(没有则返回 null)
System.out.println(m.put("a", 1)); // 输出:null(第一次放入,没有旧值)
System.out.println(m.put("a", 2)); // 输出:1(key 相同,覆盖旧值,返回旧值 1)
System.out.println(m.get("a")); // 输出:2
System.out.println(m.size()); // 输出:1(覆盖不算新增)
// 更多边界
m.put("b", 3);
System.out.println(m.get("不存在")); // 输出:null(没有这个 key)
System.out.println(m.getOrDefault("不存在", -1)); // 输出:-1(取不到给默认值)
System.out.println(m.containsKey("a")); // 输出:true
// remove:返回被删的值
System.out.println(m.remove("a")); // 输出:2
System.out.println(m.remove("a")); // 输出:null(再删就没有了)
// null key:HashMap 允许一个 null key(放在 0 号桶)
m.put(null, 100);
System.out.println(m.get(null)); // 输出:100
m.put(null, 200); // null key 再次 put 也是覆盖
System.out.println(m.size()); // 输出:2(b 和 null)
// 遍历:顺序不保证(不要依赖遍历顺序)
m.put("x", 1); m.put("y", 2); m.put("z", 3);
System.out.println(m.keySet()); // 输出:顺序不确定(如 [b, null, z, x, y])
}
}
加载因子 0.75 是什么意思? 默认容量 16、加载因子 0.75,阈值为 16 × 0.75 = 12:元素个数超过 12(第 13 次 put)时扩容到 32。0.75 是空间与查询速度的平衡点:调小(如 0.5)冲突少但费内存,调大(如 1.0)省内存但冲突多、变慢。
java
// 指定容量和加载因子
HashMap<String, Integer> map = new HashMap<>(32, 0.75f);
// 注意:传入的容量会被"补"成 2 的幂(见扩展知识 2),如 32 本就是 2 的幂
为什么链表超 8 转红黑树? 正常情况下 hashCode 分布均匀,链表很短;遇到 hashCode 设计很烂的对象或恶意构造的冲突数据,链表会很长,查询退化成 O(n)。红黑树把查询降到 O(log n),防住"最坏情况"。树化阈值 8,树节点降到 6 以下又变回链表,避免频繁转换。注意一个细节:链表长度到 8 时不是立刻树化------如果此时数组长度还不到 64,HashMap 会先扩容(把链表拆散)而不是直接树化。
面试常问:
- Q:HashMap 为什么快?A:靠 hashCode 直接定位桶,理想 O(1);hashCode 均匀是关键。
- Q:为什么用
hash ^ (hash >>> 16)扰动?A:数组长度小时,只有低几位参与定位,扰动让高 16 位也参与,减少冲突(详见扩展知识 2)。 - Q:为什么 HashMap 不允许(也没有)同步?A:设计为单线程使用;并发场景用 ConcurrentHashMap。
四、HashSet 底层:其实是个 HashMap
HashSet :去重集合,底层就是一个 HashMap ------元素作为 key 存进去,value 统一用一个固定占位对象(PRESENT)。所以去重规则 = HashMap 的 key 规则:先比 hashCode,再比 equals。
java
// 【完整程序】HashSet 的 add 返回值与去重规则
import java.util.HashSet;
public class HashSetDemo {
public static void main(String[] args) {
HashSet<String> set = new HashSet<>();
System.out.println(set.add("a")); // 输出:true(第一次加入)
System.out.println(set.add("a")); // 输出:false(重复,不加入)
set.add("b");
System.out.println(set.size()); // 输出:2
// 边界:null 可以放入 HashSet(HashMap 允许 null key)
set.add(null);
System.out.println(set.contains(null)); // 输出:true
set.add(null); // 重复 null,不加入
System.out.println(set.size()); // 输出:3
// 遍历顺序不保证
for (String s : set) {
System.out.print(s + " "); // 输出:顺序不确定(如 a b null)
}
System.out.println();
// remove / contains 都按 hashCode + equals 工作
set.remove("a");
System.out.println(set.contains("a")); // 输出:false
}
}
java
// 【完整程序】自定义对象去重必须重写 equals + hashCode
import java.util.HashSet;
public class PointSetDemo {
static class Point { // ❌ 没重写 equals/hashCode
int x, y;
Point(int x, int y) { this.x = x; this.y = y; }
}
public static void main(String[] args) {
HashSet<Point> set = new HashSet<>();
set.add(new Point(1, 1));
set.add(new Point(1, 1));
System.out.println(set.size()); // 输出:2(没重写时是两个不同对象,不去重)
// 重写 equals + hashCode 后(扩展知识 3 有完整写法),
// new Point(1,1) 与 new Point(1,1) 会被视为相等,size 变成 1
}
}
五、TreeMap 与 TreeSet:自动排序
TreeMap :底层是红黑树 ,key 会自动排序 。两种规则:key 实现 Comparable(自然排序),或构造时传入 Comparator 比较器。注意:TreeMap 用 compareTo/compare 的结果判断 key 是否相等,而不是 equals。
java
// 【完整程序】TreeMap 的排序规则与常用方法
import java.util.TreeMap;
public class TreeMapDemo {
public static void main(String[] args) {
TreeMap<Integer, String> tree = new TreeMap<>(); // Integer 自带自然排序
tree.put(3, "c"); tree.put(1, "a"); tree.put(2, "b");
System.out.println(tree.keySet()); // 输出:[1, 2, 3](自动升序)
// 排序相关 API
System.out.println(tree.firstKey()); // 输出:1(最小 key)
System.out.println(tree.lastKey()); // 输出:3(最大 key)
System.out.println(tree.floorKey(2)); // 输出:2(<= 2 的最大 key)
System.out.println(tree.ceilingKey(2));// 输出:2(>= 2 的最小 key)
System.out.println(tree.subMap(1, true, 3, false)); // 输出:{1=a, 2=b}([1,3) 区间)
// 自定义比较器:按字符串长度排序
TreeMap<String, Integer> byLen = new TreeMap<>((x, y) -> x.length() - y.length());
byLen.put("aaa", 1); byLen.put("b", 2); byLen.put("cc", 3);
System.out.println(byLen.keySet()); // 输出:[b, cc, aaa](按长度升序)
// 边界:null key 不允许!TreeMap 会抛 NullPointerException
// tree.put(null, "x"); // ❌ 运行时报 NullPointerException
// tree.get(null); // ❌ 同样抛(要比较大小,null 没法比)
}
}
java
// 【完整程序】自定义类实现 Comparable 才能放进 TreeSet(自然排序)
import java.util.TreeSet;
public class TreeSetDemo {
static class Student implements Comparable<Student> {
int score;
Student(int score) { this.score = score; }
@Override public int compareTo(Student o) { return this.score - o.score; }
@Override public String toString() { return String.valueOf(score); }
}
public static void main(String[] args) {
TreeSet<Student> ts = new TreeSet<>();
ts.add(new Student(90)); ts.add(new Student(60)); ts.add(new Student(80));
System.out.println(ts); // 输出:[60, 80, 90](按 score 升序)
// 注意:TreeSet 按 compareTo 判重------两个 score 相同的对象会被视为"同一个"
ts.add(new Student(90)); // 与已有的 90 比较为 0,视为重复
System.out.println(ts.size()); // 输出:3(没有新增)
// 不实现 Comparable 也不传 Comparator 会怎样?
// TreeSet<Object> bad = new TreeSet<>(); bad.add(new Object());
// ❌ 运行时报 ClassCastException(Object 无法比较)
}
}
使用建议:需要"取最小/最大""按范围查询""有序遍历"时用 TreeMap/TreeSet;其余场景 HashMap/HashSet 更快(O(1) vs O(log n))。
六、LinkedHashMap:保持插入顺序
LinkedHashMap :HashMap 的"有序版",额外用一条双向链表记录插入顺序 (默认),遍历时"先插的先出来"。构造参数设为 true 变成访问顺序(LRU 缓存的基础)。
java
// 【完整程序】插入顺序 vs 访问顺序
import java.util.LinkedHashMap;
public class LinkedHashMapDemo {
public static void main(String[] args) {
// 默认:插入顺序
LinkedHashMap<String, Integer> map = new LinkedHashMap<>();
map.put("a", 1); map.put("b", 2); map.put("c", 3);
for (String k : map.keySet()) {
System.out.print(k + " "); // 输出:a b c(保持插入顺序)
}
System.out.println();
// 普通 HashMap 不保证顺序(对比三、的遍历输出)
// 访问顺序:第三个参数 true
LinkedHashMap<String, Integer> access = new LinkedHashMap<>(16, 0.75f, true);
access.put("a", 1); access.put("b", 2); access.put("c", 3);
access.get("a"); // 访问了 a,a 挪到"最新"
for (String k : access.keySet()) {
System.out.print(k + " "); // 输出:b c a(a 被访问后排到最后)
}
System.out.println();
}
}
java
// 访问顺序 + removeEldestEntry = 最简单的 LRU 缓存(完整可运行)
import java.util.LinkedHashMap;
import java.util.Map;
public class LruCacheDemo {
public static void main(String[] args) {
LinkedHashMap<String, Integer> cache = new LinkedHashMap<>(16, 0.75f, true) {
@Override
protected boolean removeEldestEntry(Map.Entry<String, Integer> eldest) {
return size() > 3; // 超过 3 个淘汰最久没被访问的
}
};
cache.put("a", 1); cache.put("b", 2); cache.put("c", 3);
System.out.println(cache.keySet()); // 输出:[a, b, c]
cache.get("a"); // a 变成"最新"
cache.put("d", 4); // 最久没用的 b 被移除
System.out.println(cache.keySet()); // 输出:[c, a, d]
cache.get("c"); // c 变成最新
cache.put("e", 5); // 最久没用的 a 被移除
System.out.println(cache.keySet()); // 输出:[d, c, e]
}
}
业务场景:内存缓存、最近浏览记录、限流窗口等"容量有限、淘汰最旧"的需求,直接用 LinkedHashMap 这一招就够了,不必引第三方库。
七、ConcurrentHashMap:多线程下的 HashMap
问题 :HashMap 不是线程安全的,多线程同时 put 会丢数据(两个线程同时写同一个桶,后者覆盖前者;并发扩容时还可能把链表改出环、get 死循环),所以并发写不能用 HashMap 。Hashtable 和 Collections.synchronizedMap 虽然安全,但把整张表锁住,并发性能差。
JDK 8 起的 ConcurrentHashMap :改用 CAS + synchronized ------桶为空时用 CAS(Compare And Swap,比较并交换,一种无锁的原子操作) 直接写入;桶非空时只对该桶的头节点加 synchronized 锁,其他桶不受影响。锁粒度从"整张表"细化到"单个桶",并发度大幅提升。
java
// 【完整程序】4 个线程并发累加:ConcurrentHashMap 不丢数据
import java.util.concurrent.ConcurrentHashMap;
public class ConcurrentAddDemo {
public static void main(String[] args) throws InterruptedException {
ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
Thread[] ts = new Thread[4];
for (int t = 0; t < 4; t++) {
ts[t] = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
// compute 是原子的"读-改-写":并发下不会丢累加
map.compute("count", (k, v) -> v == null ? 1 : v + 1);
}
});
ts[t].start();
}
for (Thread th : ts) { th.join(); } // 等所有线程结束
System.out.println(map.get("count")); // 输出:4000(4 × 1000,一个不丢)
// 对比:换成普通 HashMap 用同样写法,结果几乎必然小于 4000(丢数据)
}
}
java
// 【完整程序】其他原子方法:merge / putIfAbsent / computeIfAbsent
import java.util.concurrent.ConcurrentHashMap;
public class ConcurrentMethodsDemo {
public static void main(String[] args) {
ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
map.merge("score", 1, Integer::sum); // key 不存在则放 1,存在则相加
map.merge("score", 1, Integer::sum);
map.merge("score", 1, Integer::sum);
System.out.println(map.get("score")); // 输出:3
System.out.println(map.putIfAbsent("score", 100)); // 输出:3(已存在,不覆盖,返回旧值)
System.out.println(map.putIfAbsent("new", 100)); // 输出:null(不存在,放入 100)
System.out.println(map.get("new")); // 输出:100
map.computeIfAbsent("lazy", k -> k.length()); // 不存在才计算
map.computeIfAbsent("lazy", k -> k.length()); // 已存在,不再计算
System.out.println(map.get("lazy")); // 输出:4
}
}
java
// 细节:ConcurrentHashMap 不允许 null 的 key 和 value
ConcurrentHashMap<String, Integer> cm = new ConcurrentHashMap<>();
// cm.put(null, 1); // ❌ 抛 NullPointerException
// cm.put("k", null); // ❌ 也抛 NullPointerException(value 同样不允许 null)
HashMap<String, Integer> hm = new HashMap<>();
hm.put(null, 1); // ✅ HashMap 允许一个 null key
// 原因:并发场景下 null 有歧义------"取不到"和"值就是 null"无法区分,干脆禁止
使用建议 :单线程或只读用 HashMap;多线程并发读写用 ConcurrentHashMap;不要再写 new Hashtable<>()。面试常问:JDK 8 的 ConcurrentHashMap 为什么比 Hashtable 快?A:Hashtable 锁整张表,CHM 用 CAS(空桶)加桶级锁(非空桶),并发粒度细得多。
八、fail-fast 与 fail-safe
fail-fast(快速失败) :大多数集合(ArrayList、HashMap 等)的迭代器记录修改计数 modCount,一旦检测到遍历过程中集合被结构性修改 (add/remove 等),立刻抛 ConcurrentModificationException(并发修改异常)------尽早暴露问题而不是掩盖错误。
java
// 【完整程序】fail-fast 触发与不触发的边界
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Iterator;
public class FailFastDemo {
public static void main(String[] args) {
// 场景一:遍历中删除"非末尾"元素 -> 抛异常
ArrayList<String> list = new ArrayList<>(Arrays.asList("a", "b", "c", "d"));
try {
for (String s : list) {
if (s.equals("b")) { list.remove(s); } // ❌ 结构性修改
}
} catch (java.util.ConcurrentModificationException e) {
System.out.println("捕获 ConcurrentModificationException"); // 输出:...
}
// 场景二:删除"最后一个"元素有时不抛(迭代器检查时机问题,实现细节,别依赖)
ArrayList<String> list2 = new ArrayList<>(Arrays.asList("a", "b", "c"));
for (String s : list2) {
if (s.equals("c")) { list2.remove(s); } // 碰巧不抛
}
System.out.println(list2); // 输出:[a, b]
// 场景三:正确姿势------用迭代器的 remove
ArrayList<String> list3 = new ArrayList<>(Arrays.asList("a", "b", "c"));
Iterator<String> it = list3.iterator();
while (it.hasNext()) {
if (it.next().equals("b")) { it.remove(); } // ✅ 迭代器自己的 remove 安全
}
System.out.println(list3); // 输出:[a, c]
// 场景四:JDK 8 起的 removeIf,一行搞定
ArrayList<String> list4 = new ArrayList<>(Arrays.asList("a", "b", "c"));
list4.removeIf(s -> s.equals("b"));
System.out.println(list4); // 输出:[a, c]
}
}
fail-safe(安全失败) :CopyOnWriteArrayList、ConcurrentHashMap 等并发集合的迭代器基于快照 或弱一致机制,遍历时修改不抛异常,但不保证看到最新数据------牺牲"实时一致性"换"并发可用"。
java
// 【完整程序】CopyOnWriteArrayList:遍历时删除不抛异常
import java.util.Arrays;
import java.util.concurrent.CopyOnWriteArrayList;
public class CopyOnWriteDemo {
public static void main(String[] args) {
CopyOnWriteArrayList<String> list = new CopyOnWriteArrayList<>();
list.addAll(Arrays.asList("a", "b", "c"));
for (String s : list) {
if (s.equals("b")) { list.remove(s); } // ✅ 不抛异常
}
System.out.println(list); // 输出:[a, c]
// 注意:每次写都会复制整个数组,写频繁时不合适;
// 迭代器看到的是"开始遍历那一刻"的快照,不保证反映最新修改
}
}
九、Collections 工具类
Collections :操作集合的静态工具类:sort(排序)、reverse(反转)、shuffle(打乱)、max/min、frequency(统计出现次数)、unmodifiableXxx(不可修改视图)、synchronizedXxx(加锁包装)。
java
// 【完整程序】Collections 常用静态方法
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
public class CollectionsDemo {
public static void main(String[] args) {
List<Integer> nums = new ArrayList<>(Arrays.asList(3, 1, 2));
Collections.sort(nums); // 升序排序
System.out.println(nums); // 输出:[1, 2, 3]
Collections.reverse(nums); // 反转
System.out.println(nums); // 输出:[3, 2, 1]
Collections.shuffle(nums); // 随机打乱
System.out.println(nums); // 输出:每次不同(如 [1, 3, 2])
System.out.println(Collections.max(nums)); // 输出:3(打乱后仍能找到最大)
System.out.println(Collections.min(nums)); // 输出:1
// 边界:空集合与 null 元素
List<Integer> empty = new ArrayList<>();
// Collections.max(empty); // ❌ 运行时报 NoSuchElementException(空集合没有最大值)
List<String> withNull = new ArrayList<>(Arrays.asList("a", null, "b"));
// Collections.sort(withNull); // ❌ 运行时报 NullPointerException(排序要比较,null 没法比)
// 不可修改视图:修改会抛异常
List<Integer> unmod = Collections.unmodifiableList(nums);
// unmod.add(4); // ❌ 运行时报 UnsupportedOperationException
// 注意:unmod 只是"视图",底层 nums 变了它也会变(不是拷贝)
nums.add(100);
System.out.println(unmod); // 输出:包含 100(视图跟随底层变化)
}
}
java
// 【完整程序】sort 自定义比较器
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
public class SortComparatorDemo {
public static void main(String[] args) {
List<String> names = new ArrayList<>(Arrays.asList("b", "cc", "a"));
Collections.sort(names); // 默认字典序
System.out.println(names); // 输出:[a, b, cc]
Collections.sort(names, (x, y) -> y.length() - x.length()); // 按长度降序
System.out.println(names); // 输出:[cc, b, a]
}
}
扩展知识
1. 红黑树是什么
红黑树是一种自平衡的二叉查找树:节点分红/黑两色,通过颜色约束保证树高约 2 倍 log2(n),查找、插入、删除都是 O(log n)。不用会手写它,但要知道:HashMap 链表过长时用红黑树"救场",TreeMap/TreeSet 就是一棵红黑树。
打个比方:普通二叉查找树像"一直往右排队"的队伍------数据恰好按顺序插入时树就退化成一条链,查找变成 O(n);红黑树像"会自己整顿纪律的队伍",每次插入后自动旋转、变色,保证队伍不会歪,高度永远在 log2(n) 量级。
| 结构 | 查找 | 插入 | 删除 | 说明 |
|---|---|---|---|---|
| 数组(ArrayList) | O(1) 按下标 | O(n) 中间插 | O(n) | 顺序存储 |
| 链表(LinkedList) | O(n) | O(1) 头尾 | O(1) 头尾 | 链式存储 |
| 哈希表(HashMap) | O(1) 平均 | O(1) 平均 | O(1) 平均 | 靠 hashCode 定位 |
| 红黑树(TreeMap) | O(log n) | O(log n) | O(log n) | 自动平衡 |
java
// 红黑树就在你手边:TreeMap 就是一棵红黑树
import java.util.TreeMap;
public class RbTreeDemo {
public static void main(String[] args) {
TreeMap<Integer, String> tree = new TreeMap<>();
for (int i = 1; i <= 10; i++) { tree.put(i, "v" + i); } // 故意按顺序插入
// 即便数据有序,红黑树也会自动旋转保持平衡,不会退化成链表
System.out.println(tree.firstKey()); // 输出:1
System.out.println(tree.lastKey()); // 输出:10
System.out.println(tree.size()); // 输出:10
}
}
2. 为什么 HashMap 容量总是 2 的幂
下标用位运算 hash & (n - 1),等价于 hash % n,但只有 n 是 2 的幂 时,n - 1 的二进制才是全 1,& 的结果才均匀。无论传什么初始容量,HashMap 内部都会"补"成 2 的幂(如传 1000 补成 1024,传 17 补成 32)。
java
// 【完整程序】验证 2 的幂与位运算的关系
public class PowerOfTwoDemo {
public static void main(String[] args) {
// n 是 2 的幂时:n-1 的二进制全是 1,& 的结果等价于 % n,且分布均匀
int n = 16; // 2^4,n-1 = 15 = 0b1111
System.out.println(15 & (n - 1)); // 输出:15
System.out.println(31 & (n - 1)); // 输出:15(0b11111 & 0b1111 = 0b1111,低位截断)
// n 不是 2 的幂(比如 15)时:n-1 = 14 = 0b1110,最低位永远是 0,
// 所有 hash 的桶下标最低位恒为 0,一半桶永远空着(浪费+冲突集中)
System.out.println(13 & 14); // 输出:12(0b1101 & 0b1110 = 0b1100,下标永远是偶数)
// 扰动函数的意义:容量小(如 16)时,只有 hash 低 4 位参与定位,
// hashCode 高位全浪费;hash ^ (hash >>> 16) 把高位"混"进低位,减少冲突
int h = "hello".hashCode(); // 99162322
int mixed = h ^ (h >>> 16); // 扰动后的 hash
System.out.println(mixed & 15); // 输出:2(扰动后 hello 落 2 号桶)
}
}
面试常问 :为什么用 & (n-1) 而不是 % n?A:位运算比取模快;而且 n 是 2 的幂时两者数学上等价(对非负 hash)。
3. 自定义对象做 key 的正确姿势
作为 key 必须同时重写 hashCode 和 equals 。只重写 equals,两个内容相同的对象 hashCode 不同,get 时按不同桶查找,永远取不到。另外 key 放入集合后 hashCode 不能变,尽量用不可变对象(String、Integer 等)做 key。
java
// 【完整程序】只重写 equals vs equals + hashCode 成对重写
import java.util.HashMap;
import java.util.Objects;
public class CustomKeyDemo {
static class BadKey { // ❌ 只重写 equals
String name;
BadKey(String n) { name = n; }
@Override public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof BadKey)) return false;
return name.equals(((BadKey) o).name);
}
}
static class GoodKey { // ✅ 成对重写
String name;
GoodKey(String n) { name = n; }
@Override public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof GoodKey)) return false;
return name.equals(((GoodKey) o).name);
}
@Override public int hashCode() { return Objects.hash(name); }
}
public static void main(String[] args) {
HashMap<BadKey, String> bad = new HashMap<>();
bad.put(new BadKey("a"), "v1");
System.out.println(bad.get(new BadKey("a"))); // 输出:null(hashCode 不同,落不同桶)
HashMap<GoodKey, String> good = new HashMap<>();
good.put(new GoodKey("a"), "v1");
System.out.println(good.get(new GoodKey("a"))); // 输出:v1(同桶 + equals 命中)
}
}
规则记忆:equals 相等 ⟹ hashCode 必须相等(这是契约);hashCode 相等不要求 equals 相等(允许冲突)。只满足前者,集合行为就乱套。
4. 哈希冲突的两种解决思路
HashMap 用链地址法 (冲突元素挂在同一个桶的链表上);另一种是开放寻址法(冲突就往后找空位),ThreadLocalMap 就是它。
java
// 【完整程序】开放寻址法(线性探测)最小实现
public class OpenAddressingDemo {
static class Table {
String[] slots = new String[8];
private int hash(String key) {
return Math.abs(key.hashCode()) % slots.length; // 取模定位
}
void put(String key, String value) {
int i = hash(key);
while (slots[i] != null) { // 遇到冲突:往后找空位(线性探测)
i = (i + 1) % slots.length; // 到末尾就绕回开头
}
slots[i] = value;
}
String get(String key) {
int i = hash(key);
while (slots[i] != null) { // 从定位点开始找
if (slots[i].equals(key)) { return slots[i]; }
i = (i + 1) % slots.length;
}
return null; // 遇到空位还没找到 = 不存在
}
}
public static void main(String[] args) {
Table t = new Table();
t.put("a", "A"); t.put("b", "B"); // 若 a、b 哈希相同,b 会放到 a 后面的空位
System.out.println(t.get("a")); // 输出:A
System.out.println(t.get("b")); // 输出:B
System.out.println(t.get("zzz")); // 输出:null(不存在)
}
}
对比:链地址法在冲突多时退化成链表查询(HashMap 用红黑树兜底);开放寻址法在负载高时探测链变长、且删除麻烦(要打标记)。HashMap 选链地址法 + 树化,是工程上的综合最优解。
5. WeakHashMap:弱引用的缓存
WeakHashMap :key 用的是弱引用,当 key 不再被其他地方强引用时,GC 会回收它,对应的条目自动消失。适合做"内存紧张时可以丢弃"的缓存。
java
// 【完整程序】WeakHashMap 的自动清理
import java.util.WeakHashMap;
public class WeakHashMapDemo {
public static void main(String[] args) throws InterruptedException {
WeakHashMap<String, byte[]> cache = new WeakHashMap<>();
String key = new String("config"); // 必须 new:字面量被常量池强引用,永远不会被回收
cache.put(key, new byte[1024 * 1024]);
System.out.println(cache.size()); // 输出:1
key = null; // 只剩 WeakHashMap 里的弱引用
System.gc(); // 提示 JVM 执行 GC
Thread.sleep(100); // 给 GC 一点时间
System.out.println(cache.size()); // 输出:0(key 被回收,条目自动消失)
// 说明:System.gc() 只是"建议",极端环境下可能仍是 1;原理演示足够
}
}
注意:value 若被强引用链回 key(如 value 里存了 key),会形成"value → key"的强引用,导致 key 永远不被回收------这是 WeakHashMap 的经典坑,缓存 value 不要反向引用 key。
6. Arrays.asList 的陷阱
Arrays.asList 返回的不是普通 ArrayList,而是一个固定大小的数组视图:可以改元素、不能增删,且与底层数组共享数据。
java
// 【完整程序】Arrays.asList 的三个坑
import java.util.Arrays;
import java.util.ArrayList;
import java.util.List;
public class AsListDemo {
public static void main(String[] args) {
// 坑一:不能增删
List<String> list = Arrays.asList("a", "b");
// list.add("c"); // ❌ 运行时报 UnsupportedOperationException
// list.remove("a"); // ❌ 同样报错
list.set(0, "x"); // ✅ 可以修改元素
System.out.println(list); // 输出:[x, b]
// 坑二:与数组共享数据
String[] arr = {"a", "b"};
List<String> shared = Arrays.asList(arr);
arr[0] = "zz";
System.out.println(shared); // 输出:[zz, b](改数组,List 跟着变)
// 坑三:基本类型数组的坑------int[] 被当成一个元素
int[] ints = {1, 2, 3};
List<int[]> wrong = Arrays.asList(ints);
System.out.println(wrong.size()); // 输出:1(不是 3!整个数组是一个元素)
// 正确:用包装类型 Integer[],或 Arrays.stream(ints).boxed().collect(Collectors.toList())
// 想要能增删的列表:
List<String> mutable = new ArrayList<>(Arrays.asList("a", "b"));
mutable.add("c");
System.out.println(mutable); // 输出:[a, b, c]
}
}
易错点
1. 遍历集合时直接删除元素
java
// ❌
for (String s : list) {
if (s.equals("b")) { list.remove(s); } // 抛 ConcurrentModificationException
}
// ✅ 用迭代器的 remove,或 JDK 8 起 list.removeIf(s -> s.equals("b"))
Iterator<String> it = list.iterator();
while (it.hasNext()) { if (it.next().equals("b")) { it.remove(); } }
一句话:for-each 遍历时不能结构性增删元素,要用迭代器的 remove 或 removeIf。(注意:碰巧删"最后一个"元素可能不抛异常,但那是实现细节,不要依赖。)
2. 只重写 equals 不重写 hashCode
java
// ❌
class User {
String name;
public boolean equals(Object o) { /* 比较 name */ } // hashCode 缺失,集合行为错乱
}
// ✅ equals 和 hashCode 成对重写
class User {
String name;
public boolean equals(Object o) { /* 比较 name */ }
public int hashCode() { return name.hashCode(); }
}
一句话:HashMap/HashSet 先比 hashCode 再比 equals,两者必须成对重写。
3. 用可变对象做 HashMap 的 key
java
// ❌
List<Integer> key = new ArrayList<>(); // 可变的 key
map.put(key, "v");
key.add(1); // hashCode 变了,原值永远取不到
// ✅ 用 String、Integer 等不可变对象做 key
一句话:key 放入集合后 hashCode 不能变,否则会"丢数据"。(不只是 List,任何"放入后再修改会导致 hashCode 变化"的对象都不能做 key。)
4. 多线程下直接用 HashMap
java
// ❌ 多线程同时 put 同一个 HashMap:丢数据,甚至并发扩容时链表成环、get 死循环
HashMap<String, Integer> map = new HashMap<>();
// ✅ 用 ConcurrentHashMap
ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
一句话:并发写必须用 ConcurrentHashMap,HashMap 只适合单线程。
5. 以为 List.of() 返回的集合可以修改
java
// ❌
List<String> list = List.of("a"); // 需 JDK 11/17 才能运行
list.add("b"); // 抛 UnsupportedOperationException
// ✅ 需要可变就包一层:List<String> list = new ArrayList<>(List.of("a"));
一句话:List.of/Set.of/Map.of 返回不可变集合,只能读不能写(连修改元素都不行)。
6. 对 Set 用下标操作
java
// ❌
Set<String> set = new HashSet<>();
set.get(0); // 编译错误:Set 没有 get(int)
// ✅ Set 没有下标,用迭代器或 for-each 遍历
一句话:Set 不是 List,没有顺序和下标,只能遍历访问。(LinkedHashSet 保持插入顺序但同样没有下标。)
7. 把 null 塞进 ConcurrentHashMap / TreeMap
java
// ❌
ConcurrentHashMap<String, Integer> cm = new ConcurrentHashMap<>();
cm.put(null, 1); // 抛 NullPointerException
TreeMap<String, Integer> tm = new TreeMap<>();
tm.put(null, 1); // 抛 NullPointerException(null 没法参与大小比较)
// ✅ HashMap 允许一个 null key;并发/排序场景的 Map 都不允许 null key
一句话:HashMap 允许 null key,ConcurrentHashMap 和 TreeMap 都不允许;ConcurrentHashMap 连 null value 也禁止。
8. 初始容量没按"除以 0.75"给,导致意外扩容
java
// ❌ 以为装 1000 个不扩容,实际容量 1024、阈值 768,塞 1000 个扩容 1 次
new HashMap<>(1000);
// ✅ 想要装 N 个不扩容:N / 0.75 + 1
new HashMap<>((int) (1000 / 0.75) + 1); // 1334 -> 实际容量 2048,阈值 1536 > 1000
一句话:HashMap 的"实际容量"是补成 2 的幂后的值,扩容看阈值(容量 × 0.75),不是看初始参数。
9. 用 get(i) 循环遍历 LinkedList
java
// ❌ 每次 get(i) 都从头部开始找,整体 O(n²),10 万元素就是天文数字
LinkedList<Integer> ll = new LinkedList<>();
for (int i = 0; i < 100000; i++) { ll.add(i); }
for (int i = 0; i < ll.size(); i++) { int x = ll.get(i); } // 极慢
// ✅ for-each / 迭代器:一次遍历 O(n)
for (int x : ll) { }
一句话:LinkedList 按下标访问是 O(n),循环里用 get(i) 是 O(n²);要按下标读就用 ArrayList。
10. 比较器把不同元素判成"相等",TreeSet/TreeMap 丢数据
java
// ❌ 只按长度比较:长度相同的字符串被视为"同一个",后面的被丢弃
TreeSet<String> set = new TreeSet<>((a, b) -> a.length() - b.length());
set.add("aa"); set.add("bb"); set.add("c");
System.out.println(set.size()); // 输出:2("aa" 和 "bb" 长度相同,bb 被丢掉)
// ✅ 长度相同再按字典序兜底,保证"只有真正相同才算相等"
TreeSet<String> ok = new TreeSet<>((a, b) -> a.length() != b.length()
? a.length() - b.length() : a.compareTo(b));
ok.add("aa"); ok.add("bb"); ok.add("c");
System.out.println(ok.size()); // 输出:3
一句话:TreeMap/TreeSet 用比较器结果判断相等(compare 返回 0 即视为同一元素),比较器必须能区分所有不同元素。
JDK 8 / 11 / 17 版本调用方式
核心集合行为三版本一致 :ArrayList 的 1.5 倍扩容、HashMap 的"数组 + 链表 + 红黑树"、加载因子 0.75、hash 扰动算法(h ^ (h >>> 16))、ConcurrentHashMap 的 CAS + 桶级 synchronized,在 JDK 8、11、17 中都一样,业务代码写法无差异。
差异一:不可变集合工厂方法 。List.of、Set.of、Map.of 是 JDK 11 新增(需 JDK 11/17 才能运行),JDK 8 没有:
java
// 需 JDK 11/17 才能运行
List<String> l = List.of("a", "b");
Set<Integer> s = Set.of(1, 2, 3);
Map<String, Integer> m = Map.of("k1", 1, "k2", 2); // 最多 10 对键值
// 边界:List.of 不允许 null(List.of("a", null) 抛 NullPointerException)
// List.of 按传入顺序迭代;Set.of/Map.of 顺序不保证;都是"完全不可变"(不能增删也不能改元素)
// JDK 8 对应写法:可变集合 + 不可修改视图(注意 unmodifiableXxx 只是视图,不是拷贝)
List<String> l8 = Collections.unmodifiableList(Arrays.asList("a", "b"));
Set<Integer> s8 = Collections.unmodifiableSet(new HashSet<>(Arrays.asList(1, 2, 3)));
Map<String, Integer> m8 = Collections.unmodifiableMap(new HashMap<>());
差异二:HashMap 的红黑树是 JDK 8 起引入。JDK 8 之前链表超长只能硬扛(查询退化成 O(n));JDK 8 起链表长度超 8 且数组长度 ≥ 64 转红黑树,JDK 11、17 延续。属于内部实现细节,业务代码三版本写法一致。
差异三 :Collection.toArray(IntFunction)(如 list.toArray(String[]::new))是 JDK 11 新增(需 JDK 11/17 才能运行);JDK 8 只能写 list.toArray(new String[0])。Map.getOrDefault、putIfAbsent、compute、computeIfAbsent、merge、removeIf 等默认方法 JDK 8 就有,三版本通用。Optional.isEmpty() 需 JDK 11/17,JDK 8 用 !isPresent()。注意:list.toArray()(不带参数)返回 Object[],直接强转 String[] 会抛 ClassCastException。
版本差异小结 :三版本下核心集合的行为与默认值完全一致 (扩容、树化、加载因子、并发实现),业务代码几乎不需要为版本做分支;需要分支的只有:List.of/Set.of/Map.of、toArray(IntFunction)、Optional.isEmpty()(均需 JDK 11/17)。兼容 JDK 8 时统一用"可变集合 + Collections.unmodifiableXxx"和"toArray(new T[0]) + !isPresent()"。
小结

- ArrayList 底层是数组,默认容量 10,满了按 1.5 倍扩容;能预估大小就指定初始容量。
- LinkedList 是双向链表,头尾操作 O(1),按下标访问 O(n);用 get(i) 循环遍历是 O(n²),按场景选用。
- HashMap = 数组 + 链表 + 红黑树,桶下标用
hash & (n-1)计算(容量必须是 2 的幂);加载因子 0.75,元素数超过"容量 × 0.75"扩容为 2 倍。 - JDK 8 起链表超 8 且数组 ≥ 64 转红黑树,查询从 O(n) 优化到 O(log n);降到 6 以下变回链表。
- HashSet 底层就是 HashMap(元素做 key,value 是固定占位对象);自定义对象做 key 必须成对重写 equals + hashCode,且 key 要不可变。
- TreeMap/TreeSet 用红黑树自动排序(Comparable 或 Comparator),用 compare 结果判重;LinkedHashMap 保持插入顺序,配合访问顺序可做 LRU 缓存。
- 多线程并发写必须用 ConcurrentHashMap(JDK 8 起 CAS + 桶级 synchronized),不能直接用 HashMap;ConcurrentHashMap/TreeMap 不允许 null key。
- fail-fast 集合遍历中修改抛 ConcurrentModificationException;CopyOnWriteArrayList 等 fail-safe 不抛但不保证实时可见。
List.of/Map.of/Set.of返回不可变集合且需 JDK 11/17,JDK 8 用Collections.unmodifiableXxx代替;toArray(String[]::new)需 JDK 11/17,JDK 8 用toArray(new String[0])。- 初始容量要按"目标个数 / 0.75 + 1"给,否则会意外扩容;比较器把不同元素判成相等会让 TreeSet/TreeMap 丢数据。
下一篇:第3章 IO 流深入:字节流、字符流与序列化(待发布)