Skip to content

Java 集合框架

集合框架用于保存和操作一组对象。它不是简单的“装数据工具”,而是 Java 对常用数据结构的标准封装:动态数组、链表、哈希表、树、队列、并发容器都在集合体系里。

如果要完整理解 HashMap 的 hash 扰动、容量为什么是 2 的幂、put/get/remove/resize 全流程、链表树化、JDK 7/JDK 8 差异、equals/hashCode 和可变 key 风险,建议继续看:HashMap全过程原理

如果要完整理解 ArrayList 的数组结构、add/get/remove、扩容复制、fail-fast、subList、Arrays.asList、初始容量和批量导入优化,建议继续看:ArrayList全过程原理

零基础学习集合时,核心不是背类名,而是回答五个问题:

  1. 数据是否需要按顺序保存。
  2. 数据是否允许重复。
  3. 是否需要按 key 快速查找。
  4. 是否需要排序。
  5. 是否会被多个线程同时修改。

为什么需要集合框架

如果没有集合框架,开发者每次都要自己实现数组扩容、链表节点、哈希冲突、排序树、队列阻塞等逻辑。集合框架把这些通用结构沉淀下来,让业务开发者根据场景选择合适容器。

如果不会选集合,常见后果是:

错误选择后果
大量随机查询却用链表每次都要遍历,性能差
多线程写普通 HashMap数据覆盖、丢失、结构异常
大批量追加不预估容量频繁扩容和数组复制
可变对象当 HashMap key放进去后可能再也查不到
需要去重却用 List业务自己写去重逻辑,容易漏

集合体系

mermaid
flowchart TD
    A["Java集合"] --> B["Collection"]
    A --> C["Map"]
    B --> D["List<br/>有序,可重复"]
    B --> E["Set<br/>去重"]
    B --> F["Queue<br/>队列"]
    D --> G["ArrayList<br/>动态数组"]
    D --> H["LinkedList<br/>双向链表"]
    E --> I["HashSet<br/>基于HashMap"]
    E --> J["TreeSet<br/>排序树"]
    C --> K["HashMap<br/>哈希表"]
    C --> L["TreeMap<br/>排序Map"]
    C --> M["ConcurrentHashMap<br/>并发Map"]

List

List 有序、可重复,适合保存一批按顺序处理的数据。

ArrayList

ArrayList 底层是数组。数组的特点是内存连续,可以通过下标直接定位元素,所以随机访问很快。

mermaid
flowchart TD
    A["ArrayList对象"] --> B["elementData数组"]
    B --> C["0: 张三"]
    B --> D["1: 李四"]
    B --> E["2: 王五"]
    A --> F["size=3"]

ArrayList 查询为什么快

list.get(2) 本质上是访问数组下标:

java
String value = list.get(2);

数组可以根据起始地址和下标直接算出元素位置,不需要从头遍历,所以按下标查询通常是 O(1)。

为什么ArrayList扩容影响性能

ArrayList 的数组长度固定,容量不够时不能在原数组上直接“变长”,而是创建一个更大的新数组,再把旧元素复制过去。

mermaid
flowchart TD
    A["add元素"] --> B{"数组容量够吗"}
    B -- "够" --> C["直接写入 elementData[size]"]
    C --> D["size++"]
    B -- "不够" --> E["创建更大数组"]
    E --> F["复制旧元素"]
    F --> G["写入新元素"]
    G --> D

为什么这样设计:数组访问快,但数组长度固定;为了兼顾访问性能和动态增长,ArrayList 用“平时数组访问,容量不够时扩容复制”的方式折中。

如果不会这个原理,可能在大批量导入时这样写:

java
List<String> rows = new ArrayList<>();
for (int i = 0; i < 100000; i++) {
    rows.add("row-" + i);
}

当数量可预估时,应该指定初始容量,减少扩容次数:

java
List<String> rows = new ArrayList<>(100000);
for (int i = 0; i < 100000; i++) {
    rows.add("row-" + i);
}

ArrayList 插入删除为什么慢

中间插入或删除时,后面的元素需要整体移动。

java
List<String> names = new ArrayList<>(List.of("A", "B", "C", "D"));
names.remove(1); // 删除 B 后,C 和 D 要前移

所以 ArrayList 适合随机访问和尾部追加,不适合大量中间插入删除。

LinkedList

LinkedList 底层是双向链表。每个节点保存前驱、后继和值。

mermaid
flowchart TD
    A["Node A"] --> B["Node B"]
    B --> C["Node C"]
    C --> B
    B --> A

链表插入删除节点本身不需要移动数组,但定位节点要从头或尾遍历。实际业务里 ArrayList 更常用,不要因为“链表插入删除快”就盲目使用 LinkedList。很多时候真正的成本在“找到要插入/删除的位置”。

Set

Set 不允许重复元素。

HashSet

HashSet 底层基于 HashMap,元素会作为 HashMap 的 key 保存。判断重复依赖 hashCodeequals

java
Set<String> ids = new HashSet<>();
ids.add("P001");
ids.add("P001");
System.out.println(ids.size()); // 1

TreeSet

TreeSet 底层是排序树,元素会按自然顺序或 Comparator 排序。它适合需要排序和去重的场景,但元素必须可比较。

Map

Map 保存键值对,适合根据 key 快速找到 value。

HashMap

HashMap 是最常用的 Map 实现。JDK 8 中底层结构是数组 + 链表 + 红黑树。

mermaid
flowchart TD
    A["HashMap"] --> B["table数组"]
    B --> C["桶0"]
    B --> D["桶1: Node链表"]
    B --> E["桶2: 红黑树"]
    D --> F["key1,value1"]
    D --> G["key2,value2"]

核心原理:HashMap 为什么快

HashMap 快的核心是:通过 hash 把 key 映射到数组下标,理想情况下只需要定位一个桶,而不用扫描全部数据。

mermaid
flowchart TD
    A["put/get key"] --> B["计算 key.hashCode"]
    B --> C["扰动计算 hash"]
    C --> D["用 (n - 1) & hash 定位桶下标"]
    D --> E{"桶里是否有节点"}
    E -- "没有" --> F["直接插入或返回空"]
    E -- "有" --> G["比较 hash 和 equals"]
    G --> H["找到则更新/返回"]
    G --> I["找不到则追加到链表或红黑树"]

HashMap 为什么容量通常是 2 的幂

如果数组长度 n 是 2 的幂,那么 (n - 1) & hash 可以快速计算下标,效果类似取模,但通常更快。

java
int index = (table.length - 1) & hash;

为什么这样设计:

  1. & 运算比 % 取模更轻。
  2. 当容量是 2 的幂时,低位分布能均匀参与下标计算。
  3. 扩容翻倍后,节点位置只会有两种结果:留在原位置,或移动到 原位置 + oldCap

扩容时不需要重新做完整取模判断:

mermaid
flowchart TD
    A["旧容量 oldCap=16"] --> B["扩容为 newCap=32"]
    C["某个节点旧下标 i"] --> D{"hash & oldCap 是否为0"}
    D -- "是" --> E["仍在 i"]
    D -- "否" --> F["移动到 i + oldCap"]

HashMap put 流程

mermaid
flowchart TD
    A["put(key,value)"] --> B{"table是否初始化"}
    B -- "否" --> C["resize 初始化数组"]
    B -- "是" --> D["计算桶下标"]
    C --> D
    D --> E{"桶是否为空"}
    E -- "是" --> F["创建新Node放入桶"]
    E -- "否" --> G{"第一个节点key是否相同"}
    G -- "是" --> H["覆盖旧value"]
    G -- "否" --> I{"桶是树还是链表"}
    I -- "链表" --> J["遍历链表,找到则覆盖,找不到则尾插"]
    I -- "红黑树" --> K["按树规则插入或覆盖"]
    J --> L{"链表长度是否达到树化阈值"}
    L -- "是" --> M{"数组容量是否至少64"}
    M -- "是" --> N["链表转红黑树"]
    M -- "否" --> O["优先扩容"]
    F --> P["size++,检查是否超过阈值"]
    H --> P
    K --> P
    N --> P
    O --> P

为什么树化前还要判断容量:如果数组太小,冲突多可能是容量不足导致的,优先扩容比转红黑树更合适。红黑树能降低极端冲突下的查询成本,但维护成本也更高,不应该过早使用。

HashMap get 流程

mermaid
flowchart TD
    A["get(key)"] --> B["计算 hash 和桶下标"]
    B --> C{"桶是否为空"}
    C -- "是" --> D["返回 null"]
    C -- "否" --> E{"首节点是否匹配"}
    E -- "是" --> F["返回 value"]
    E -- "否" --> G{"链表还是红黑树"}
    G -- "链表" --> H["逐个比较 hash 和 equals"]
    G -- "红黑树" --> I["按树查找"]
    H --> J["找到返回,找不到 null"]
    I --> J

HashMap remove 流程

remove 不是简单把 value 置空,而是要先定位桶,再在链表或红黑树中找到目标节点,最后维护桶内结构。

mermaid
flowchart TD
    A["remove(key)"] --> B["计算 hash 和桶下标"]
    B --> C{"桶是否为空"}
    C -- "是" --> D["返回 null"]
    C -- "否" --> E{"首节点是否匹配"}
    E -- "是" --> F["删除首节点"]
    E -- "否" --> G{"链表还是红黑树"}
    G -- "链表" --> H["遍历并断开目标节点"]
    G -- "红黑树" --> I["按树删除并调整结构"]
    F --> J["size--,返回旧 value"]
    H --> J
    I --> J

为什么删除后不一定缩容:HashMap 主要为插入和查询性能设计,删除时如果频繁缩容,会在数据波动场景里不断扩容、缩容,成本很高。所以 HashMap 删除元素后不会自动把数组缩小。

threshold、loadFactor 和 resize

HashMap 里有几个变量必须区分:

变量含义
table.length桶数组长度,也就是容量
size已经存入的 key-value 数量
loadFactor负载因子,默认 0.75
threshold扩容阈值,通常是 capacity * loadFactor

默认情况下,容量 16、负载因子 0.75,所以阈值是 12。第 13 个元素成功插入后,size > threshold,触发扩容。

注意:threshold 是整张表的扩容阈值,不是某个桶的容量。某个桶里链表很长,可能触发树化检查;整张表元素数量超过阈值,才触发全表 resize。

指定初始容量为什么仍会变成 2 的幂

java
Map<String, String> map = new HashMap<>(10);

你传入的是 10,但 HashMap 内部会调整成大于等于 10 的最小 2 的幂,也就是 16。这样后续仍然能用 (n - 1) & hash 计算下标。

如果已知会放 1000 条数据,不建议直接写 new HashMap<>(1000) 就结束,因为负载因子是 0.75。为了避免中途扩容,可以按 预期元素数 / 负载因子 估算容量:

java
int expectedSize = 1000;
int capacity = (int) (expectedSize / 0.75F) + 1;
Map<String, String> map = new HashMap<>(capacity);

resize 时链表怎么迁移

HashMap 扩容不是把每个节点完全重新取模。因为容量翻倍后,只新增了一个参与下标计算的二进制位,所以旧桶里的节点会被拆成两组:

  1. hash & oldCap == 0:留在原下标。
  2. hash & oldCap != 0:移动到 原下标 + oldCap
mermaid
flowchart TD
    A["旧桶链表"] --> B{"hash & oldCap"}
    B -- "0" --> C["低位链表 lo<br/>仍放 oldIndex"]
    B -- "非0" --> D["高位链表 hi<br/>放 oldIndex + oldCap"]
    C --> E["新数组 oldIndex"]
    D --> F["新数组 oldIndex + oldCap"]

一句话概括 resize:数组翻倍后,根据 hash 新增参与运算的那一位,把原桶拆成低位组和高位组再挂回去。

树化和反树化

JDK 8 中,HashMap 为了防止极端哈希冲突导致链表过长,引入了红黑树。

关键阈值常见这样记:

常量含义
TREEIFY_THRESHOLD = 8链表长度达到 8 时触发树化检查
MIN_TREEIFY_CAPACITY = 64数组容量至少 64 才真正树化
UNTREEIFY_THRESHOLD = 6扩容拆树后某组节点过少时可退回链表

注意:链表长度达到 8 不是立刻树化,而是先检查数组容量。如果数组容量小于 64,HashMap 更倾向于先扩容。

为什么先扩容:冲突多可能是数组太小导致的。先扩容能把节点分散到更多桶里,成本通常比维护红黑树更低。只有容量已经足够大、冲突仍然严重时,才说明可能是 hash 分布差或恶意冲突,此时树化才更合适。

mermaid
flowchart TD
    A["桶内链表变长"] --> B{"长度是否达到8"}
    B -- "否" --> C["继续链表"]
    B -- "是" --> D{"table容量是否 >= 64"}
    D -- "否" --> E["优先 resize"]
    D -- "是" --> F["链表转红黑树"]
    F --> G["极端冲突下查询从 O(n) 降到 O(log n)"]

反树化也不是“节点少一点就立刻退回”。常见场景是 resize 拆树时,低位组或高位组节点数小于等于 6,就可能退回链表。这样可以避免节点很少时还维护红黑树的额外成本。

JDK 7 和 JDK 8 的面试差异

HashMap 经典追问里经常会问 JDK 7 和 JDK 8 的差异:

维度JDK 7JDK 8
冲突结构数组 + 链表数组 + 链表 + 红黑树
插入方式链表头插法链表尾插法
并发 resize 风险头插法在并发扩容时可能形成环链表仍然线程不安全,但迁移逻辑优化
极端冲突查询链表 O(n)树化后 O(log n)

JDK 7 头插法为什么容易出问题:并发 resize 时,两个线程同时迁移同一条链表,头插会改变节点顺序。如果线程交错执行,节点的 next 指针可能被改成环形结构,后续 get 遍历链表时可能死循环。

JDK 8 改成尾插并引入红黑树,降低了这种经典环链表问题和极端冲突查询成本。但这不代表 JDK 8 的 HashMap 可以并发写。没有并发控制的结构修改仍然可能数据丢失或状态异常。

HashMap 为什么线程不安全

HashMap 的 put、resize、链表/树结构修改没有并发保护。多个线程同时写可能出现:

  1. 覆盖更新,某个线程写入丢失。
  2. size 统计不准确。
  3. resize 迁移时结构异常。
  4. 遍历时抛出并发修改异常。

多线程写应使用 ConcurrentHashMap,或者在外层明确加锁。

equals和hashCode

如果对象要作为 HashMap 的 key 或放入 HashSet,需要正确重写 equalshashCode

规则:

  1. equals 相等,hashCode 必须相等。
  2. hashCode 相等,equals 不一定相等,因为可能哈希冲突。
  3. 重写 equals 通常必须重写 hashCode

为什么这条规则重要:HashMap 先用 hashCode 定位桶,再用 equals 判断是不是同一个 key。如果两个对象 equals 相等但 hashCode 不同,它们会落到不同桶里,HashMap 根本不会在正确位置比较 equals

错误 Demo:只重写 equals

java
import java.util.HashSet;
import java.util.Objects;
import java.util.Set;

class PatientKey {
    private final String patientId;
    private final String visitNo;

    PatientKey(String patientId, String visitNo) {
        this.patientId = patientId;
        this.visitNo = visitNo;
    }

    @Override
    public boolean equals(Object obj) {
        if (this == obj) {
            return true;
        }
        if (!(obj instanceof PatientKey other)) {
            return false;
        }
        return Objects.equals(patientId, other.patientId)
                && Objects.equals(visitNo, other.visitNo);
    }
}

public class BadHashCodeDemo {
    public static void main(String[] args) {
        Set<PatientKey> set = new HashSet<>();
        set.add(new PatientKey("P001", "V001"));
        set.add(new PatientKey("P001", "V001"));
        System.out.println(set.size()); // 可能是2,去重失败
    }
}

正确写法:

java
@Override
public int hashCode() {
    return Objects.hash(patientId, visitNo);
}

为什么HashMap的key要不可变

如果 key 放入 Map 后,参与 equals/hashCode 的字段发生变化,后续可能无法再找到这个 key。

java
Map<User, String> map = new HashMap<>();
User user = new User(1L, "Tom");
map.put(user, "value");

user.setId(2L); // 如果 id 参与 hashCode,桶位置已经变了
System.out.println(map.get(user)); // 可能取不到

为什么会这样:放入时 HashMap 按旧 hash 定位桶,修改字段后再查询会按新 hash 定位另一个桶,旧数据还在原桶里,自然查不到。

ConcurrentHashMap

ConcurrentHashMap 是线程安全 Map,适合并发读写场景。它不是简单给整个 Map 加一把大锁,而是通过 volatile、CAS、桶级 synchronized、协助扩容等机制降低锁粒度。

详细原理看 并发集合与队列

遍历方式和 Iterator

集合常见遍历方式有三种:

java
List<String> names = new ArrayList<>();
names.add("A");
names.add("B");
names.add("C");

for (int i = 0; i < names.size(); i++) {
    System.out.println(names.get(i));
}

for (String name : names) {
    System.out.println(name);
}

Iterator<String> iterator = names.iterator();
while (iterator.hasNext()) {
    System.out.println(iterator.next());
}

增强 for 的底层本质也是 Iterator。下面两段在语义上很接近:

java
for (String name : names) {
    System.out.println(name);
}
java
Iterator<String> iterator = names.iterator();
while (iterator.hasNext()) {
    String name = iterator.next();
    System.out.println(name);
}

遍历时删除元素为什么容易出错

错误示例:

java
List<String> names = new ArrayList<>();
names.add("A");
names.add("B");
names.add("C");

for (String name : names) {
    if ("B".equals(name)) {
        names.remove(name); // 可能抛 ConcurrentModificationException
    }
}

原因是增强 for 使用 Iterator 遍历,而你绕过 Iterator 直接修改了集合结构。Iterator 创建时会记录集合的结构修改次数,遍历过程中发现次数不一致,就会尽力抛出 ConcurrentModificationException

正确写法:

java
Iterator<String> iterator = names.iterator();
while (iterator.hasNext()) {
    String name = iterator.next();
    if ("B".equals(name)) {
        iterator.remove();
    }
}

Java 8 也可以用:

java
names.removeIf(name -> "B".equals(name));

fail-fast 不是线程安全机制

fail-fast 是一种“尽力而为”的并发修改检测,不保证一定能发现所有并发问题。它的目标是尽早暴露错误,而不是让集合变得线程安全。

mermaid
flowchart TD
    A["创建 Iterator"] --> B["记录 expectedModCount"]
    B --> C["遍历 next"]
    C --> D{"expectedModCount 是否等于 modCount"}
    D -- "是" --> E["继续遍历"]
    D -- "否" --> F["抛 ConcurrentModificationException"]

如果多个线程会同时修改集合,应使用并发容器、加锁,或者设计为线程内局部集合再合并。

排序:Comparable 和 Comparator

排序经常出现在订单列表、账单列表、任务优先级、报表展示中。

Comparable:对象自己会比较

Comparable 表示对象有自然顺序。

java
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

class Order implements Comparable<Order> {
    private final Long id;
    private final int priority;

    Order(Long id, int priority) {
        this.id = id;
        this.priority = priority;
    }

    public Long getId() {
        return id;
    }

    @Override
    public int compareTo(Order other) {
        return Integer.compare(this.priority, other.priority);
    }
}

public class ComparableDemo {
    public static void main(String[] args) {
        List<Order> orders = new ArrayList<>();
        orders.add(new Order(1L, 3));
        orders.add(new Order(2L, 1));
        Collections.sort(orders);
    }
}

Comparator:外部定义比较规则

Comparator 更灵活,同一个对象可以按不同规则排序。

java
orders.sort((a, b) -> Long.compare(a.getId(), b.getId()));

Java 8 推荐:

java
orders.sort(Comparator.comparing(Order::getId));

如果字段可能为 null,要显式处理:

java
orders.sort(Comparator.comparing(
        Order::getId,
        Comparator.nullsLast(Long::compareTo)
));

排序规则必须稳定

比较器要满足基本规则:自反、传递、一致。不要写这种危险比较器:

java
orders.sort((a, b) -> a.getId() > b.getId() ? 1 : -1);

如果两个 id 相等,它也返回 -1,违反比较规则,可能导致排序结果异常。正确写法用 Long.compareComparator.comparing

Collections 工具类

Collections 提供集合工具方法。

方法作用
sort排序
binarySearch二分查找
reverse反转
shuffle打乱
unmodifiableList返回只读视图
synchronizedList返回同步包装集合

unmodifiableList 是只读视图,不是深不可变

java
List<String> source = new ArrayList<>();
source.add("A");

List<String> view = Collections.unmodifiableList(source);
// view.add("B"); // UnsupportedOperationException

source.add("B");
System.out.println(view); // [A, B]

unmodifiableList 只是不允许通过 view 修改,但源集合变化,view 也会变化。它不是深拷贝,也不是深不可变。

如果想返回稳定快照:

java
List<String> snapshot = Collections.unmodifiableList(new ArrayList<>(source));

如果元素对象本身是可变的,还要考虑元素对象是否也需要防御性拷贝。

synchronizedList 的边界

java
List<String> list = Collections.synchronizedList(new ArrayList<>());

它会给单个方法加同步,但复合操作仍然要自己加锁。

java
synchronized (list) {
    Iterator<String> iterator = list.iterator();
    while (iterator.hasNext()) {
        System.out.println(iterator.next());
    }
}

生产并发场景更常用 ConcurrentHashMapCopyOnWriteArrayListBlockingQueue 等专门并发集合,而不是简单包装普通集合。

null 规则

不同集合对 null 的支持不同,面试和线上排查都常见。

集合null 支持
ArrayList允许多个 null
HashSet允许一个 null
HashMap允许一个 null key,允许多个 null value
TreeSet通常不建议放 null,排序比较会有问题
TreeMapkey 通常不能为 null,value 可为 null
ConcurrentHashMapkey 和 value 都不允许 null

ConcurrentHashMap 为什么不允许 null?并发场景下 get(key) == null 必须明确表示 key 不存在。如果允许 null value,就无法区分“不存在”和“存在但值为 null”。虽然可以再调用 containsKey,但并发环境下两次调用之间状态可能已经被其他线程修改。

Queue 和 Deque

Queue 表示队列,常见语义是先进先出。Deque 是双端队列,两端都可以插入和删除。

java
Queue<String> queue = new ArrayDeque<>();
queue.offer("A");
queue.offer("B");
System.out.println(queue.poll()); // A

常用 API:

API队列为空或满时行为
offer插入失败返回 false
poll队列空返回 null
peek查看队头,空返回 null
add插入失败抛异常
remove队列空抛异常
element查看队头,空抛异常

业务代码更常用 offer/poll/peek,因为它们通过返回值表达失败,不会因为空队列直接抛异常。

Deque 可以当栈用,推荐替代老的 Stack

java
Deque<String> stack = new ArrayDeque<>();
stack.push("A");
stack.push("B");
System.out.println(stack.pop()); // B

不同集合怎么选

mermaid
flowchart TD
    A["选择集合"] --> B{"是否 key-value"}
    B -- "是" --> C{"是否并发写"}
    C -- "是" --> D["ConcurrentHashMap"]
    C -- "否" --> E{"是否需要按 key 排序"}
    E -- "是" --> F["TreeMap"]
    E -- "否" --> G["HashMap"]
    B -- "否" --> H{"是否需要去重"}
    H -- "是" --> I{"是否需要排序"}
    I -- "是" --> J["TreeSet"]
    I -- "否" --> K["HashSet"]
    H -- "否" --> L{"是否主要按下标访问"}
    L -- "是" --> M["ArrayList"]
    L -- "否" --> N{"是否队列语义"}
    N -- "是" --> O["ArrayDeque或BlockingQueue"]
    N -- "否" --> P["优先ArrayList,必要时再换"]

不要上来就问“哪个集合性能最好”。应该先问业务语义:是否去重、是否排序、是否 key-value、是否并发、是否队列。

选择建议

场景推荐
有序可重复列表ArrayList
频繁按下标读取ArrayList
去重HashSet
键值映射HashMap
排序集合TreeSet / TreeMap
并发键值映射ConcurrentHashMap
普通队列ArrayDeque
生产者消费者BlockingQueue

商业 Demo:采集记录去重

医疗采集平台中,一条采集记录可能用患者 ID、就诊号、报告号确定唯一性。

java
import java.util.HashSet;
import java.util.Objects;
import java.util.Set;

class CollectKey {
    private final String patientId;
    private final String visitNo;
    private final String reportNo;

    CollectKey(String patientId, String visitNo, String reportNo) {
        this.patientId = patientId;
        this.visitNo = visitNo;
        this.reportNo = reportNo;
    }

    @Override
    public boolean equals(Object obj) {
        if (this == obj) {
            return true;
        }
        if (!(obj instanceof CollectKey)) {
            return false;
        }
        CollectKey other = (CollectKey) obj;
        return Objects.equals(patientId, other.patientId)
                && Objects.equals(visitNo, other.visitNo)
                && Objects.equals(reportNo, other.reportNo);
    }

    @Override
    public int hashCode() {
        return Objects.hash(patientId, visitNo, reportNo);
    }
}

public class CollectDeduplicateDemo {
    public static void main(String[] args) {
        Set<CollectKey> exists = new HashSet<>();
        exists.add(new CollectKey("P001", "V001", "R001"));

        CollectKey incoming = new CollectKey("P001", "V001", "R001");
        if (exists.contains(incoming)) {
            System.out.println("重复采集,跳过");
        } else {
            System.out.println("新记录,入库");
        }
    }
}

这类 key 对象必须保持不可变,并且正确实现 equalshashCode。真实项目中要明确唯一键由哪些字段组成,否则会出现重复入库或误判重复。JDK 16 之后可以用 record 简化这类不可变 key,但 JDK 8 项目仍然要按上面的方式手写。

常见风险和排查

现象可能原因排查方向
HashSet 去重失败equals/hashCode 写错打印 key 字段和 hash,检查是否同时重写
HashMap 查不到刚放入的数据key 的参与 hash 字段被修改key 改成不可变对象
批量导入慢ArrayList 多次扩容或 HashMap rehash预估容量
多线程统计结果不准普通 HashMap 并发写换 ConcurrentHashMap 或加锁
内存占用高集合长期缓存未清理看引用链和缓存淘汰策略

本章小结

集合框架的核心是数据结构选择。ArrayList 用数组换快速随机访问,HashMap 用哈希定位换快速查找,HashSet 依赖 HashMap 去重,ConcurrentHashMap 为并发写入做了专门设计。学集合不能只背“底层是什么”,要知道为什么这样设计,以及选错会造成什么后果。