🚀 Java 巩固进阶 · 第9天

主题:Set 接口深度解析 —— 去重与排序的艺术

📅 进度概览:掌握处理“唯一性”数据的终极武器。
💡 核心价值

  • 数据清洗:自动去除重复数据(如用户标签、权限列表、日志 ID)。
  • 排序需求TreeSet 提供天然的排序能力,无需手动 Collections.sort
  • 性能优化:理解哈希冲突与红黑树代价,避免在大数据量下误用导致性能下降。
  • 面试高频hashCodeequals 契约、HashSet 底层原理、TreeSet 排序陷阱。

一、Set 接口核心特性:唯一性的契约

Set 是 Collection 的子接口,核心特征是 「元素唯一」

  • 无索引:不支持 get(index), add(index, e),只能通过迭代器或增强 for 循环遍历。
  • null 值:允许存一个 null (HashSet/LinkedHashSet),但 TreeSet 不允许 (无法比较 null)。
  • 有序性定义
    • 无序:指不保证插入顺序 (HashSet)。
    • 有序:指按插入顺序 (LinkedHashSet) 或 排序规则 (TreeSet)。

二、HashSet:高性能去重王者

1. 底层原理揭秘

  • 本质HashSetHashMap包装器
  • 存储结构
    • 添加元素 e → 实际调用 map.put(e, PRESENT)
    • PRESENT 是一个静态的 Object 常量,Value 毫无意义,只利用 HashMap 的 Key 唯一性
  • 去重逻辑
    1. 计算 hashCode() 确定桶位置。
    2. 若桶为空,直接存入。
    3. 若桶有值(哈希冲突),遍历链表/红黑树,调用 equals() 比较。
    4. equals() 为 true,视为重复,覆盖 Value (还是 PRESENT) 并返回 false。

2. ⚠️ 核心契约:hashCode() 与 equals()

自定义对象去重必须同时重写这两个方法!

  • 契约
    • a.equals(b) == true,则 a.hashCode() == b.hashCode() 必须成立
    • hashCode() 不同,equals() 一定为 false。
  • 后果:若只重写 equals 未重写 hashCode,两个逻辑相同的对象可能算出不同哈希值,存入不同桶,导致去重失败

3. 🚀 现代写法:Lombok 简化

在 SpringBoot 项目中,我们极少手写 hashCode/equals,而是使用 Lombok。

import lombok.Data;
import lombok.EqualsAndHashCode;
import java.util.Objects;

@Data // 生成 getter, setter, toString, equals, hashCode
@EqualsAndHashCode(of = {"id", "name"}) // 指定仅根据 id 和 name 判断重复
public class User {
    private Long id;
    private String name;
    private String email; // 不参与去重判断
}

(若不使用 Lombok,请使用 IDE 自动生成或 Objects.hash(id, name))

4. 💣 致命陷阱:可变对象作为 Key

严禁将可变字段(如 List, 可修改的 String Builder, 或会被修改的业务对象)作为 HashSet 的 Key,或者在放入 Set 后修改了影响 hashCode 的字段

  • 现象:元素“消失”了(contains 返回 false),但实际上还在集合里,只是找不到桶位置了,造成内存泄漏

三、LinkedHashSet:有序的守护者

1. 原理

  • 继承自 HashSet
  • 底层 = HashMap + 双向链表
  • 链表维护了元素的插入顺序

2. 场景

  • 缓存淘汰:结合访问顺序可实现 LRU (Least Recently Used) 缓存基础。
  • 日志去重:保留第一次出现的时间顺序,去除后续重复日志。
  • 配置加载:读取配置文件列表,去重但保持原有配置优先级顺序。
Set<String> set = new LinkedHashSet<>();
set.add("A"); set.add("B"); set.add("A");
// 遍历结果:A, B (严格保持插入顺序)

四、TreeSet:排序专家

1. 原理

  • 底层 = TreeMap (红黑树)。
  • 元素存储在树节点上,天然有序。
  • 复杂度:增删改查均为 O(log n),性能低于 HashSet (O(1))。

2. 排序两种方式

A. 自然排序 (Comparable)

类实现 Comparable 接口。

public class User implements Comparable<User> {
    private Integer age;
    // ...
    @Override
    public int compareTo(User o) {
        return this.age.compareTo(o.getAge()); // 按年龄升序
    }
}
B. 定制排序 (Comparator)

构造时传入比较器,优先级高于自然排序

// 按年龄降序,年龄相同按 ID 升序
Set<User> set = new TreeSet<>((u1, u2) -> {
    int cmp = u2.getAge().compareTo(u1.getAge()); // 降序
    return cmp != 0 ? cmp : u1.getId().compareTo(u2.getId());
});

3. ⚠️ TreeSet 的特殊陷阱

  • 去重依据:TreeSet 仅依赖 compareTo (或 compare) 的返回值是否为 0 来判断重复,忽略 equals()
  • 风险:若排序规则认为两个对象相等 (返回 0),但 equals 认为不等,后者将被丢弃。
    • 例子:只按“姓名”排序,两个同名不同 ID 的人,第二个会被误判为重复而丢弃。
    • 解决:排序规则必须包含唯一标识(如 ID),确保逻辑唯一的对象不会被误删。

五、SpringBoot 中的去重策略对比

场景 推荐方案 理由
普通去重 (最快) HashSet / Stream.distinct() O(1) 性能,最常用
保留顺序去重 LinkedHashSet 需维持原始次序
排序去重 TreeSet / Stream.sorted().distinct() 需结果有序
流式处理去重 list.stream().distinct().collect(...) 代码简洁,适合链式调用
并发环境去重 ConcurrentSkipListSet 线程安全的 TreeSet 替代品

💡 进阶提示:在 SpringBoot Service 层,如果只是为了临时去重,优先使用 Stream.distinct(),代码可读性更高;如果需要长期维护一个唯一的集合容器,则使用 HashSet


六、🎯 今日实战任务:用户标签管理系统

背景:管理用户的标签系统,需要支持快速去重、保留添加顺序、以及按热度排序。

需求步骤

  1. 定义 Tag
    • 属性:id (Long), name (String), hotScore (Integer, 热度分)。
    • 使用 Lombok (@Data, @EqualsAndHashCode(of="id")) 简化代码。
  2. 功能验证
    • 场景 A (HashSet):创建 HashSet<Tag>,添加 5 个标签(其中 2 个 ID 重复)。验证大小是否为 4。
    • 场景 B (LinkedHashSet):创建 LinkedHashSet<Tag>,添加相同数据。遍历时验证顺序是否与添加顺序一致。
    • 场景 C (TreeSet):创建 TreeSet<Tag>,使用 ComparatorhotScore 降序排序(若分数相同按 ID 升序)。
      • 注意:确保 Comparator 中包含 ID 比较,防止同分不同 ID 的标签被误删。
  3. 陷阱测试 (必做)
    • 创建一个 Tag 对象放入 HashSet
    • 修改该对象的 id 字段(破坏 hashCode)。
    • 尝试 set.contains(tag)set.remove(tag),观察是否还能找到/删除该元素?(预期:找不到,演示内存泄漏风险)。
  4. Stream 替代方案
    • 有一个包含重复 Tag 的 List<Tag>
    • 使用 stream().distinct() 去重,并收集回 List。
    • 对比代码简洁度。

💡 代码提示

// TreeSet 定制排序 (防止同分误删)
Set<Tag> sortedTags = new TreeSet<>((t1, t2) -> {
    int scoreCmp = t2.getHotScore().compareTo(t1.getHotScore()); // 热度降序
    return scoreCmp != 0 ? scoreCmp : t1.getId().compareTo(t2.getId()); // ID 升序兜底
});

📝 第9天 · 核心总结

  1. Set 选型指南
    • 默认HashSet (最快,无序)。
    • 要顺序LinkedHashSet (略慢,保序)。
    • 要排序TreeSet (O(logn),需定义比较规则)。
  2. 去重铁律
    • 自定义对象必须重写 hashCode() + equals()
    • Lombok 是神器,但要清楚它生成的逻辑。
    • TreeSet 特例:去重看 compareTo,务必保证排序规则的唯一性(包含主键)。
  3. 避坑指南
    • 禁止修改已放入 HashSet/TreeSet 对象的“关键比较字段”,否则会导致集合“失忆”(无法查找/删除)。
    • 若对象不可变(Immutable),则是 Set 的最佳搭档。
  4. SpringBoot 实践
    • 权限列表 (Set<String> roles) 必用 Set 去重。
    • 流式数据处理优先 stream().distinct()
    • 高并发排序去重考虑 ConcurrentSkipListSet

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐