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全过程原理。
零基础学习集合时,核心不是背类名,而是回答五个问题:
- 数据是否需要按顺序保存。
- 数据是否允许重复。
- 是否需要按 key 快速查找。
- 是否需要排序。
- 是否会被多个线程同时修改。
为什么需要集合框架
如果没有集合框架,开发者每次都要自己实现数组扩容、链表节点、哈希冲突、排序树、队列阻塞等逻辑。集合框架把这些通用结构沉淀下来,让业务开发者根据场景选择合适容器。
如果不会选集合,常见后果是:
| 错误选择 | 后果 |
|---|---|
| 大量随机查询却用链表 | 每次都要遍历,性能差 |
| 多线程写普通 HashMap | 数据覆盖、丢失、结构异常 |
| 大批量追加不预估容量 | 频繁扩容和数组复制 |
| 可变对象当 HashMap key | 放进去后可能再也查不到 |
| 需要去重却用 List | 业务自己写去重逻辑,容易漏 |
集合体系
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 底层是数组。数组的特点是内存连续,可以通过下标直接定位元素,所以随机访问很快。
flowchart TD
A["ArrayList对象"] --> B["elementData数组"]
B --> C["0: 张三"]
B --> D["1: 李四"]
B --> E["2: 王五"]
A --> F["size=3"]ArrayList 查询为什么快
list.get(2) 本质上是访问数组下标:
String value = list.get(2);数组可以根据起始地址和下标直接算出元素位置,不需要从头遍历,所以按下标查询通常是 O(1)。
为什么ArrayList扩容影响性能
ArrayList 的数组长度固定,容量不够时不能在原数组上直接“变长”,而是创建一个更大的新数组,再把旧元素复制过去。
flowchart TD
A["add元素"] --> B{"数组容量够吗"}
B -- "够" --> C["直接写入 elementData[size]"]
C --> D["size++"]
B -- "不够" --> E["创建更大数组"]
E --> F["复制旧元素"]
F --> G["写入新元素"]
G --> D为什么这样设计:数组访问快,但数组长度固定;为了兼顾访问性能和动态增长,ArrayList 用“平时数组访问,容量不够时扩容复制”的方式折中。
如果不会这个原理,可能在大批量导入时这样写:
List<String> rows = new ArrayList<>();
for (int i = 0; i < 100000; i++) {
rows.add("row-" + i);
}当数量可预估时,应该指定初始容量,减少扩容次数:
List<String> rows = new ArrayList<>(100000);
for (int i = 0; i < 100000; i++) {
rows.add("row-" + i);
}ArrayList 插入删除为什么慢
中间插入或删除时,后面的元素需要整体移动。
List<String> names = new ArrayList<>(List.of("A", "B", "C", "D"));
names.remove(1); // 删除 B 后,C 和 D 要前移所以 ArrayList 适合随机访问和尾部追加,不适合大量中间插入删除。
LinkedList
LinkedList 底层是双向链表。每个节点保存前驱、后继和值。
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 保存。判断重复依赖 hashCode 和 equals。
Set<String> ids = new HashSet<>();
ids.add("P001");
ids.add("P001");
System.out.println(ids.size()); // 1TreeSet
TreeSet 底层是排序树,元素会按自然顺序或 Comparator 排序。它适合需要排序和去重的场景,但元素必须可比较。
Map
Map 保存键值对,适合根据 key 快速找到 value。
HashMap
HashMap 是最常用的 Map 实现。JDK 8 中底层结构是数组 + 链表 + 红黑树。
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 映射到数组下标,理想情况下只需要定位一个桶,而不用扫描全部数据。
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 可以快速计算下标,效果类似取模,但通常更快。
int index = (table.length - 1) & hash;为什么这样设计:
&运算比%取模更轻。- 当容量是 2 的幂时,低位分布能均匀参与下标计算。
- 扩容翻倍后,节点位置只会有两种结果:留在原位置,或移动到
原位置 + oldCap。
扩容时不需要重新做完整取模判断:
flowchart TD
A["旧容量 oldCap=16"] --> B["扩容为 newCap=32"]
C["某个节点旧下标 i"] --> D{"hash & oldCap 是否为0"}
D -- "是" --> E["仍在 i"]
D -- "否" --> F["移动到 i + oldCap"]HashMap put 流程
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 流程
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 --> JHashMap remove 流程
remove 不是简单把 value 置空,而是要先定位桶,再在链表或红黑树中找到目标节点,最后维护桶内结构。
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 的幂
Map<String, String> map = new HashMap<>(10);你传入的是 10,但 HashMap 内部会调整成大于等于 10 的最小 2 的幂,也就是 16。这样后续仍然能用 (n - 1) & hash 计算下标。
如果已知会放 1000 条数据,不建议直接写 new HashMap<>(1000) 就结束,因为负载因子是 0.75。为了避免中途扩容,可以按 预期元素数 / 负载因子 估算容量:
int expectedSize = 1000;
int capacity = (int) (expectedSize / 0.75F) + 1;
Map<String, String> map = new HashMap<>(capacity);resize 时链表怎么迁移
HashMap 扩容不是把每个节点完全重新取模。因为容量翻倍后,只新增了一个参与下标计算的二进制位,所以旧桶里的节点会被拆成两组:
hash & oldCap == 0:留在原下标。hash & oldCap != 0:移动到原下标 + oldCap。
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 分布差或恶意冲突,此时树化才更合适。
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 7 | JDK 8 |
|---|---|---|
| 冲突结构 | 数组 + 链表 | 数组 + 链表 + 红黑树 |
| 插入方式 | 链表头插法 | 链表尾插法 |
| 并发 resize 风险 | 头插法在并发扩容时可能形成环链表 | 仍然线程不安全,但迁移逻辑优化 |
| 极端冲突查询 | 链表 O(n) | 树化后 O(log n) |
JDK 7 头插法为什么容易出问题:并发 resize 时,两个线程同时迁移同一条链表,头插会改变节点顺序。如果线程交错执行,节点的 next 指针可能被改成环形结构,后续 get 遍历链表时可能死循环。
JDK 8 改成尾插并引入红黑树,降低了这种经典环链表问题和极端冲突查询成本。但这不代表 JDK 8 的 HashMap 可以并发写。没有并发控制的结构修改仍然可能数据丢失或状态异常。
HashMap 为什么线程不安全
HashMap 的 put、resize、链表/树结构修改没有并发保护。多个线程同时写可能出现:
- 覆盖更新,某个线程写入丢失。
- size 统计不准确。
- resize 迁移时结构异常。
- 遍历时抛出并发修改异常。
多线程写应使用 ConcurrentHashMap,或者在外层明确加锁。
equals和hashCode
如果对象要作为 HashMap 的 key 或放入 HashSet,需要正确重写 equals 和 hashCode。
规则:
equals相等,hashCode必须相等。hashCode相等,equals不一定相等,因为可能哈希冲突。- 重写
equals通常必须重写hashCode。
为什么这条规则重要:HashMap 先用 hashCode 定位桶,再用 equals 判断是不是同一个 key。如果两个对象 equals 相等但 hashCode 不同,它们会落到不同桶里,HashMap 根本不会在正确位置比较 equals。
错误 Demo:只重写 equals
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,去重失败
}
}正确写法:
@Override
public int hashCode() {
return Objects.hash(patientId, visitNo);
}为什么HashMap的key要不可变
如果 key 放入 Map 后,参与 equals/hashCode 的字段发生变化,后续可能无法再找到这个 key。
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
集合常见遍历方式有三种:
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。下面两段在语义上很接近:
for (String name : names) {
System.out.println(name);
}Iterator<String> iterator = names.iterator();
while (iterator.hasNext()) {
String name = iterator.next();
System.out.println(name);
}遍历时删除元素为什么容易出错
错误示例:
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。
正确写法:
Iterator<String> iterator = names.iterator();
while (iterator.hasNext()) {
String name = iterator.next();
if ("B".equals(name)) {
iterator.remove();
}
}Java 8 也可以用:
names.removeIf(name -> "B".equals(name));fail-fast 不是线程安全机制
fail-fast 是一种“尽力而为”的并发修改检测,不保证一定能发现所有并发问题。它的目标是尽早暴露错误,而不是让集合变得线程安全。
flowchart TD
A["创建 Iterator"] --> B["记录 expectedModCount"]
B --> C["遍历 next"]
C --> D{"expectedModCount 是否等于 modCount"}
D -- "是" --> E["继续遍历"]
D -- "否" --> F["抛 ConcurrentModificationException"]如果多个线程会同时修改集合,应使用并发容器、加锁,或者设计为线程内局部集合再合并。
排序:Comparable 和 Comparator
排序经常出现在订单列表、账单列表、任务优先级、报表展示中。
Comparable:对象自己会比较
Comparable 表示对象有自然顺序。
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 更灵活,同一个对象可以按不同规则排序。
orders.sort((a, b) -> Long.compare(a.getId(), b.getId()));Java 8 推荐:
orders.sort(Comparator.comparing(Order::getId));如果字段可能为 null,要显式处理:
orders.sort(Comparator.comparing(
Order::getId,
Comparator.nullsLast(Long::compareTo)
));排序规则必须稳定
比较器要满足基本规则:自反、传递、一致。不要写这种危险比较器:
orders.sort((a, b) -> a.getId() > b.getId() ? 1 : -1);如果两个 id 相等,它也返回 -1,违反比较规则,可能导致排序结果异常。正确写法用 Long.compare 或 Comparator.comparing。
Collections 工具类
Collections 提供集合工具方法。
| 方法 | 作用 |
|---|---|
sort | 排序 |
binarySearch | 二分查找 |
reverse | 反转 |
shuffle | 打乱 |
unmodifiableList | 返回只读视图 |
synchronizedList | 返回同步包装集合 |
unmodifiableList 是只读视图,不是深不可变
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 也会变化。它不是深拷贝,也不是深不可变。
如果想返回稳定快照:
List<String> snapshot = Collections.unmodifiableList(new ArrayList<>(source));如果元素对象本身是可变的,还要考虑元素对象是否也需要防御性拷贝。
synchronizedList 的边界
List<String> list = Collections.synchronizedList(new ArrayList<>());它会给单个方法加同步,但复合操作仍然要自己加锁。
synchronized (list) {
Iterator<String> iterator = list.iterator();
while (iterator.hasNext()) {
System.out.println(iterator.next());
}
}生产并发场景更常用 ConcurrentHashMap、CopyOnWriteArrayList、BlockingQueue 等专门并发集合,而不是简单包装普通集合。
null 规则
不同集合对 null 的支持不同,面试和线上排查都常见。
| 集合 | null 支持 |
|---|---|
ArrayList | 允许多个 null |
HashSet | 允许一个 null |
HashMap | 允许一个 null key,允许多个 null value |
TreeSet | 通常不建议放 null,排序比较会有问题 |
TreeMap | key 通常不能为 null,value 可为 null |
ConcurrentHashMap | key 和 value 都不允许 null |
ConcurrentHashMap 为什么不允许 null?并发场景下 get(key) == null 必须明确表示 key 不存在。如果允许 null value,就无法区分“不存在”和“存在但值为 null”。虽然可以再调用 containsKey,但并发环境下两次调用之间状态可能已经被其他线程修改。
Queue 和 Deque
Queue 表示队列,常见语义是先进先出。Deque 是双端队列,两端都可以插入和删除。
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:
Deque<String> stack = new ArrayDeque<>();
stack.push("A");
stack.push("B");
System.out.println(stack.pop()); // B不同集合怎么选
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、就诊号、报告号确定唯一性。
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 对象必须保持不可变,并且正确实现 equals 和 hashCode。真实项目中要明确唯一键由哪些字段组成,否则会出现重复入库或误判重复。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 为并发写入做了专门设计。学集合不能只背“底层是什么”,要知道为什么这样设计,以及选错会造成什么后果。
