1. 现实问题:重复数据究竟该不该被删除
文章收藏夹通常要保留用户点击的顺序,但同一篇文章不能重复显示。直接用 List 会保留重复,直接换成 Set 又可能丢失顺序。集合不是“装东西的盒子”,它把顺序、重复、定位和判等规则带进了业务。
更隐蔽的问题是:Set 如何知道两个文章对象是同一篇?如果没有正确实现 equals 和 hashCode,你可能添加了两个 id 相同的对象,却误以为 Set 已经去重。先定义业务身份,再选集合实现。
2. 最小可运行示例:按 id 去重且保留首次顺序
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
public class BookmarkDemo {
record Article(long id, String title) {}
public static void main(String[] args) {
List<Article> clicks = List.of(
new Article(7, "集合判等"),
new Article(3, "JDBC 参数"),
new Article(7, "集合判等(重复点击)"));
Set<Long> uniqueIds = new LinkedHashSet<>();
List<Article> visible = new ArrayList<>();
for (Article article : clicks) {
if (uniqueIds.add(article.id())) visible.add(article);
}
System.out.println(visible);
}
}
这里选择按 id 去重,而不是按标题。LinkedHashSet 的元素是 Long id,Article 仍保留第一次出现的展示对象;如果直接把 Article 放进 Set,记录类会按全部组件判等,标题不同就不会被去重。集合的元素类型和判等字段必须与需求一致。
3. 调用链与对象变化
List.of 创建一个不可变列表,for-each 逐个拿到 Article 引用。uniqueIds.add 先根据 Long 的哈希定位,再用 equals 确认是否已经存在;第一次返回 true,id 7 第二次返回 false。visible 是新的可变 ArrayList,只保存第一次通过的引用,没有复制 Article 对象。
如果把 Article 改成普通类并覆盖 equals/hashCode,Set 会对对象本身执行同一链路。hashCode 不同可以直接判定“不相等”,hashCode 相同还要调用 equals,因此两者必须使用同样的身份字段。对象进入 Set 后不要修改参与哈希的字段,否则它可能再也找不到自己。
4. 为什么这样设计
List 关注顺序和索引,Set 关注唯一性,Map 关注键到值的定位。HashSet 平均查找快但不承诺遍历顺序,LinkedHashSet 多维护一条链接换取插入顺序,TreeSet 按比较器排序并要求元素具备可比较关系。选择集合时先列出访问动作:追加、按位置读、去重、范围查询还是排序。
记录类自动生成基于组件的 equals/hashCode,适合值对象;实体对象常按稳定数据库 id 判等,但创建前后 id 可能为空,这需要显式设计。不要用 == 判断两个包装对象的业务相等性,也不要把 HashSet 的当前顺序当作接口协议。
5. 项目落点:在边界处明确集合语义
文章标签可以用 LinkedHashSet<String> 先清洗去重,再转换为不可变 List 返回 API。分页结果通常按 List 返回,权限集合适合 Set,按文章 id 批量加载时用 Map 建索引。Repository 方法名和返回类型要让调用方知道是否有序、是否可能重复。
练习:实现 mergeBookmarks(List<Article> oldItems, List<Article> newItems),要求旧顺序优先、同 id 保留最新标题;为重复 id、空列表和 null 元素写测试。再比较一个 record Article 与手写 equals/hashCode 的行为,确认它们的身份规则是否一致。
6. 易错排查
- Set 没去重:检查元素是否按业务身份实现 equals/hashCode,或改为先放 id。
- HashSet 遍历顺序变化:顺序没有被承诺;需要稳定展示就选 LinkedHashSet 或显式排序。
- 修改后
contains返回 false:修改了参与 hashCode 的字段;不要在集合中修改键身份。 List.of添加元素失败:它返回不可变列表,需要new ArrayList<>(...)建立可变副本。
7. 一页复习
先写集合契约:是否有序、是否允许重复、用什么键定位、是否需要排序。判等必须成对设计 equals/hashCode,且集合中的身份字段不能随意改变。List、Set、Map 不是互相替换的语法糖,它们分别承担不同的数据关系。
把收藏夹的输入和输出写成状态表:第一次收到 article id 7,uniqueIds 从空变成 {7},visible 增加一个 Article;再收到 id 3,集合变成 {7,3};第三次收到同 id 7,集合与 visible 都不再增加,但原对象标题是否更新要由业务决定。若需求是“最新标题覆盖”,就不能只靠 Set 去重,应在 Map 中按 id 保存最新对象,再用有序 id 列表展示。
判等契约还要考虑对象生命周期。新建文章可能没有数据库 id,草稿保存后才有 id;这时用 null id 判等会把所有新对象混在一起,使用标题又会把同名文章错误合并。可以在持久化前使用请求级临时 key,持久化后使用稳定 id,或者明确规定实体只在获得 id 后进入 Set。值对象和实体的判等规则不应复制一份又一份。
项目落点可以是 application/bookmark 的合并服务,domain/article 的 Article 值对象,web 层只接收 List。服务返回不可变 List,避免 Controller 或缓存改动内部结果;数据库批量查询时把 id Set 传给 Repository,Repository 再按排序要求返回行。排序、唯一性和最新值是三个不同问题,文件和方法名应分别表达。
排错时先检查集合实现,再检查元素判等,最后检查对象是否在加入后被修改。contains 失败可能是 hashCode 改变,也可能是你构造了标题不同但 id 相同的记录;输出顺序变化可能来自 HashSet,而不是数据库随机。练习是实现旧收藏和新收藏合并,保留旧顺序、覆盖最新标题,并为 null id、重复 id、空列表写四个测试。
还可以比较三个集合的对象状态:List 保存三个 Article 引用并保留重复,HashSet 根据哈希只保留判等的一份但不承诺顺序,LinkedHashSet 多保存链接所以能回放首次出现顺序。若把对象属性改成参与判等的字段,集合内部的桶位置可能与原来不一致;集合元素应尽量使用不可变值对象或稳定 id。
数据访问层常见的做法是先从请求 DTO 得到 List<Long>,在 Service 里去重成 LinkedHashSet<Long>,Repository 再把它转换为安全的批量参数。返回给 API 时按照 SQL 明确的排序重建 List。文件落点可以是 application/bookmark/BookmarkMergeService,而不是在 Controller 里临时用 Set;这样规则能被单测和缓存复用。
排查“重复”要先问重复发生在哪一层:数据库关联重复、Mapper 映射重复、List 追加重复,还是前端渲染重复。equals 正确却仍有重复,可能是业务身份字段没有纳入;equals 相等但 HashSet 保留两份,检查 hashCode;顺序稳定但内容旧,检查是否保留了第一对象而需求要求最新对象。练习写出四层样例并逐层打印 id。
集合选择的验收输出应同时包含顺序、数量和判等结果。接口若承诺按首次点击展示,就在测试里锁定顺序;若只承诺唯一 id,就不要让调用方依赖 HashSet 的偶然遍历顺序。
验证清单:用 id 为 7、3、7 的三次输入检查数量、顺序和最新标题,分别运行 List、HashSet、LinkedHashSet 和 Map 方案,记录它们保留的对象;再在加入集合后修改参与 hashCode 的字段,确认为什么 contains 可能失效。把 null id、重复 id、空列表和数据库重复行分开测试,因为它们的根因分别在身份生成、集合判等、输入边界和查询映射。复盘时明确 API 承诺的是“唯一”“有序”还是“最新”,三个词不能用一个集合类型自动代替。
练习复盘:为 mergeBookmarks 写一份四列表格,列出输入 id、旧状态、合并后状态、返回顺序;再把结果通过 List.copyOf 固化,尝试从 Controller 修改返回值。若修改失败但缓存中的对象仍变化,继续检查元素本身是否可变。最终把去重放在 application 层、查询放在 persistence 层、排序写进 SQL 或服务契约,避免在页面渲染阶段才发现集合语义已经丢失。
进阶附录:复杂度和不可变返回
ArrayList 尾部追加通常是摊销 O(1),中间插入和删除需要移动元素;HashSet 平均 O(1),但哈希冲突和错误容量会影响表现。服务方法返回集合时,可以使用 List.copyOf 或 Set.copyOf 固化快照,避免调用方无意改动内部状态。
本课按「Java 21 集合框架、对象判等与哈希契约」的学习范围组织,正文与示例均为本站原创整理。