第2章 集合框架深度:HashMap 原理与并发集合

第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) 越界抛 IndexOutOfBoundsExceptionadd(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 死循环),所以并发写不能用 HashMapHashtableCollections.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(安全失败)CopyOnWriteArrayListConcurrentHashMap 等并发集合的迭代器基于快照 或弱一致机制,遍历时修改不抛异常,但不保证看到最新数据------牺牲"实时一致性"换"并发可用"。

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/minfrequency(统计出现次数)、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.ofSet.ofMap.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.getOrDefaultputIfAbsentcomputecomputeIfAbsentmergeremoveIf 等默认方法 JDK 8 就有,三版本通用。Optional.isEmpty() 需 JDK 11/17,JDK 8 用 !isPresent()。注意:list.toArray()(不带参数)返回 Object[],直接强转 String[] 会抛 ClassCastException

版本差异小结 :三版本下核心集合的行为与默认值完全一致 (扩容、树化、加载因子、并发实现),业务代码几乎不需要为版本做分支;需要分支的只有:List.of/Set.of/Map.oftoArray(IntFunction)Optional.isEmpty()(均需 JDK 11/17)。兼容 JDK 8 时统一用"可变集合 + Collections.unmodifiableXxx"和"toArray(new T[0]) + !isPresent()"。

小结

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

下一篇:第3章 IO 流深入:字节流、字符流与序列化(待发布)

相关推荐
FreeTinker1 小时前
Java文件服务器的技术选型与实现路径:从嵌入式工具到企业级系统
java·服务器·开发语言
ly76891 小时前
Java 21 到 Java 26 新特性详解:虚拟线程、模式匹配、结构化并发、HTTP/3 与 AOT
java·jvm
我星期八休息1 小时前
Linux I/O多路转接—epoll
java·linux·运维·服务器·开发语言·jvm·算法
学长毕业设计2 小时前
基于springboot的云南中草药知识普及管理网站的设计与开发(源码+文档+讲解视频)
java·spring boot·后端
大模型码小白2 小时前
AI 提示词专栏:使用系统指令(System Prompt)实现全局约束
java·大数据·运维·开发语言·人工智能·python·prompt
cxoptics2 小时前
蓝宝石的轴向在窗片和偏振片中起的作用
java
wuminyu2 小时前
Java FFM处理网络读写事件源码剖析
java·linux·c语言·jvm·c++
小江的记录本2 小时前
【AI Agent】《2026年9月 AI Agent全栈开发技术选型专项面试宝典》
java·spring boot·python·spring·ai·面试·ai编程
梦梦代码精3 小时前
《LikeShop全产品技术硬核拆解:ThinkPHP8+Vue3+UniApp架构,私有化部署与二次开发实战指南》
java·低代码·系统架构·php·开源软件