Set 这个东西,跟我一开始想的不太一样

学完 List 之后,我自然就去看 Set 了。当时我心想,不就是跟 List 差不多嘛,都是存东西的容器。

结果翻开文档一看,第一句话就给我整不会了:"Set 不允许包含重复元素。"

我想了想,那 List 允许重复对吧?那这俩到底啥时候用 List、啥时候用 Set?更让我困惑的是,同样是 Set,还有 HashSet、LinkedHashSet、TreeSet 三种,它们到底有什么区别?

我花了不少时间一个一个搞清楚的。这篇就按我当时的学习顺序来写。

开始之前先说一句:Set 的学习路径跟 List 不太一样。List 的学习重点在"不同的实现怎么选"(ArrayList 还是 LinkedList),而 Set 的学习重点在"三个 Set 的去重逻辑有什么区别"。因为去重是 Set 的核心能力,理解每种 Set 怎么判断"重复",就理解了它的大部分设计思路。

List 和 Set,到底差在哪

要说清楚 Set,得先把它跟 List 放在一起比。我当初列了一张表,对比来看就清楚多了。

List 是有序的。这个"有序"指的是"添加顺序"------你先加"A"再加"B",取出来的时候"A"在前,"B"在后。而且 List 允许重复,你加三个"A"进去,它就存三个"A"。还能通过下标直接访问,list.get(0) 拿第一个。

Set 的核心就两个字:唯一。同一个元素你加多少次,它都只存一份。没有下标,不能通过索引访问,只能遍历。至于"有序"还是"无序",得看具体是哪个 Set 实现。

我当时写了个小例子试了一下:

java 复制代码
List<String> list = new ArrayList<>();
list.add("apple");
list.add("banana");
list.add("apple");  // List 允许重复
System.out.println(list);  // [apple, banana, apple]

Set<String> set = new HashSet<>();
set.add("apple");
set.add("banana");
set.add("apple");  // Set 不会加进去
System.out.println(set);  // [banana, apple] 顺序也不一定

看到输出结果的时候,我大概明白了。List 像购物车,同样的商品你可以加好几份。Set 像"已购清单",每个商品只出现一次。

后来在实际写代码的时候我慢慢形成了自己的判断:如果你需要按顺序存东西,而且可能要按索引取某个位置的元素,用 List。如果你只是想把一堆数据存起来过滤掉重复的,用 Set。比如从用户输入里收集标签,你不想让用户重复提交同一个标签,那用 Set 就天然去重了,不用自己写判重逻辑。

还有一个细节我当时觉得挺有意思的。List 能存多个 null,list.add(null) 可以来好几次。但 Set 最多只能存一个 null(HashSet 和 LinkedHashSet 是这样),TreeSet 连 null 都不让存,因为排序的时候拿 null 去比较会抛空指针。

在我当时看来,List 和 Set 的选择其实可以简化成一句话:要不要重复? 要重复用 List,不要重复用 Set。如果你的场景里数据天然不会有重复(比如从数据库查出来的主键列表),那用 List 还是 Set 差别不大,看你要不要下标访问。但如果数据可能有重复而你不想自己写去重逻辑,Set 就是最自然的选择。

HashSet------最常用的那个 Set

学 Set 的时候,我第一个接触的就是 HashSet。当时我最想知道的是:它怎么知道一个元素已经存在了?

后来看了原理才知道,HashSet 底层用的是 HashMap。你没看错,Set 的底层是个 Map。

怎么做到的?HashSet 存元素的时候,实际上是把元素作为 HashMap 的 key 存进去,value 统一用一个叫 PRESENT 的常量占位。这个 PRESENT 就是个 new Object(),没啥特别的,就是占个位。

java 复制代码
// HashSet 的 add 方法大概长这样
private static final Object PRESENT = new Object();

public boolean add(E e) {
    return map.put(e, PRESENT) == null;
}

所以往 HashSet 里加一个 "apple",实际上是在底层的 HashMap 里放了一对 ("apple" -> PRESENT)。因为 HashMap 的 key 不能重复,所以 HashSet 自然也就不会重复了。

知道这个之后,我对"Set 底层是 Map"这句话的理解就深了一层。同时也明白了为什么 HashSet 的元素要重写 hashCode 和 equals 方法------因为 HashMap 判断 key 是否重复,靠的就是这两个方法。

我当时还特意写了一段代码来验证重复的判断过程:

java 复制代码
public class Person {
    String name;
    int age;
    
    Person(String name, int age) {
        this.name = name;
        this.age = age;
    }
    
    // 故意不重写 hashCode 和 equals
}

public static void main(String[] args) {
    Set<Person> set = new HashSet<>();
    set.add(new Person("张三", 20));
    set.add(new Person("张三", 20));  // 这两个"张三"会被当成不同的人
    System.out.println(set.size());  // 输出 2,不是 1
}

不重写 hashCode 和 equals 的话,即使两个对象的字段值一模一样,HashSet 也把它们当成两个不同的元素。因为 Object 默认的 hashCode 是根据内存地址算的,两个 new 出来的对象地址肯定不一样。

后来我把正确的写法补上之后,去重就正常了:

java 复制代码
public class Person {
    String name;
    int age;
    
    Person(String name, int age) {
        this.name = name;
        this.age = age;
    }
    
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Person person = (Person) o;
        return age == person.age && Objects.equals(name, person.name);
    }
    
    @Override
    public int hashCode() {
        return Objects.hash(name, age);
    }
}

加上之后,两个字段一模一样的 Person 对象就有了相同的 hashCode 和 equals 为 true 的判断,HashSet 就能正确去重了。

这个坑我后来在代码里遇到过。当时从数据库里查了一批用户数据放到 HashSet 里去重,结果发现去重失败了。排查了半天才意识到是没重写 hashCode 和 equals。加上之后就好了。

另外,因为 HashSet 底层是 HashMap,所以 HashMap 的性能特点 HashSet 也有。比如初始容量和负载因子的概念也是一样的。如果你提前知道要存大量数据,可以在创建 HashSet 的时候指定初始容量,减少扩容次数。不过一般来说用默认值就行了,只有数据量特别大的时候才需要关注这个。

HashSet 的查找过程

既然底层是 HashMap,那 HashSet 判断元素重复的流程就跟 HashMap 判断 key 是否存在的流程一样:

  1. 先算元素的 hashCode,定位到数组的某个桶
  2. 如果桶是空的,说明没有重复元素,直接放进去
  3. 如果桶里有元素,再用 equals 挨个比一遍
  4. 有任何一个 equals 返回 true,就说明重复了,不放了

这个过程我第一次理解的时候花了点功夫。关键是搞明白 hashCode 和 equals 的分工:hashCode 先筛一遍,equals 最后拍板。 hashCode 一样的不一定就是同一个元素(可能哈希冲突),但 hashCode 不一样的一定不是同一个元素。

所以重写的时候,原则上 hashCode 和 equals 要一起重写,保证逻辑一致。不然可能出现两个对象 equals 返回 true,但 hashCode 不一样------这样就违背了"相等的对象必须有相同的 hashCode"这个约定。HashMap 和 HashSet 的行为就会变得很奇怪。比如你放进去一个对象之后,用另一个 equals 相等的对象去查,可能因为 hashCode 不同而定位到不同的桶,查不到。这种 bug 非常隐蔽,我当时排查了很久才找到原因。

LinkedHashSet------既能去重,又能记住顺序

HashSet 有个问题:它不保证顺序。你加元素的顺序跟遍历出来的顺序可能完全不一样。

有次我写一个功能需要把用户最近搜索的关键词去重,同时保留搜索的时间顺序。用 HashSet 的话,顺序就丢了。后来找到 LinkedHashSet,正好解决这个问题。

LinkedHashSet 继承自 HashSet,底层用的是 LinkedHashMap。它比 HashSet 多了什么?多了一条双向链表。这条链表把插入的每个元素按顺序串起来了。所以遍历的时候,输出的顺序就是你插入的顺序。

内部实现上,LinkedHashSet 的构造方法调用了 HashSet 的一个特殊构造器,这个构造器会初始化一个 LinkedHashMap 而不是普通的 HashMap。LinkedHashMap 在 HashMap 的基础上给每个节点增加了 before 和 after 两个指针,指向插入顺序中的前一个和后一个节点。这个设计我以前觉得挺巧妙的------在完全不改变哈希表结构的前提下,用一条额外的链表就把顺序记录下来了。

java 复制代码
LinkedHashSet<String> set = new LinkedHashSet<>();
set.add("Java");
set.add("Python");
set.add("JavaScript");
set.add("Java");  // 重复,不会加进去
System.out.println(set);  // [Java, Python, JavaScript] 保持插入顺序

注意,它保持的是插入顺序,不是"按值排序"。Java 在最前面不是因为字母顺序,是因为它最先被加进去。

我当时有个疑问:为什么 LinkedHashSet 既能去重又能保持顺序,那 HashSet 还有什么用?答案是性能。LinkedHashSet 因为要多维护一条链表,插入和删除的时候多了一点开销。虽然这个开销通常不大,但如果数据量特别大而且对顺序没要求,HashSet 更合适。

TreeSet------自动排序的 Set

TreeSet 是三种 Set 里最特别的一个。它不靠 hashCode 和 equals 来判断重复,而是靠比较器。

TreeSet 底层是 TreeMap(红黑树),元素插入的时候会按照某种规则自动排序。这个规则可以有两种方式指定。

方式一:元素实现 Comparable 接口

如果元素本身实现了 Comparable 接口,TreeSet 就直接用它的 compareTo 方法来排序和去重。

java 复制代码
TreeSet<Integer> numbers = new TreeSet<>();
numbers.add(5);
numbers.add(1);
numbers.add(3);
numbers.add(5);  // 重复,加不进去
System.out.println(numbers);  // [1, 3, 5] 自动升序

Integer 和 String 这些常见的类都实现了 Comparable,所以直接用就行。

自定义对象如果想放到 TreeSet 里,就得自己实现 Comparable:

java 复制代码
public class Student implements Comparable<Student> {
    String name;
    int score;
    
    // ... 构造方法啥的
    
    @Override
    public int compareTo(Student other) {
        return this.score - other.score;  // 按分数升序
    }
}

// 这样就能放进 TreeSet 了
TreeSet<Student> students = new TreeSet<>();

方式二:传入 Comparator

如果你不想改类本身,或者想用跟类默认排序不一样的规则,可以在创建 TreeSet 的时候传一个 Comparator:

java 复制代码
TreeSet<Student> students = new TreeSet<>((s1, s2) -> s2.score - s1.score);
// 按分数降序

我当时觉得这种方式更灵活,尤其是对已有的类做排序的时候,不用去动原来的代码。

两种方式各有适用场景。如果类是你自己写的,而且这个类有一种"天然的排序"(比如学生按分数排),那实现 Comparable 更合适,因为排序逻辑跟类本身绑定在一起,不会忘掉。如果同一个类在不同的地方有不同的排序需求(比如有时候按分数排、有时候按年龄排),那就用 Comparator,每次创建 TreeSet 的时候指定不同的规则。

TreeSet 的去重逻辑跟 HashSet 不一样

这个是我后来踩坑才发现的。

HashSet 判断重复靠 hashCode + equals。但 TreeSet 完全不看这两个方法,它只看 compareTo 或 compare 的返回值。如果返回值是 0,就认为是同一个元素,不会加进去。

这意味着什么?如果你的 compareTo 方法只比较了分数,那两个名字不同但分数相同的学生,在 TreeSet 里会被当成同一个人。

java 复制代码
public class Student implements Comparable<Student> {
    String name;
    int score;
    
    @Override
    public int compareTo(Student other) {
        return this.score - other.score;  // 只看分数
    }
}

TreeSet<Student> set = new TreeSet<>();
set.add(new Student("张三", 80));
set.add(new Student("李四", 80));  // 加不进去了!因为分数一样
System.out.println(set.size());  // 输出 1

我第一次遇到这个问题的时候真的很懵,心想我明明加的是两个不同的人,为什么 TreeSet 只存了一个?后来才意识到 TreeSet 的去重逻辑跟 HashSet 不一样。如果你的比较器只覆盖了部分字段,那"相等"的范围就比你想象的大。

所以用 TreeSet 的时候,一定要想清楚:什么样的两个元素算"相同"?这个定义要体现在 compareTo 或 compare 里。如果分数相同但名字不同也算不同的人,那比较逻辑就得把名字也加进去。

另外,因为 TreeSet 底层是红黑树,它提供了一些 HashSet 没有的有用方法。比如 first() 取最小元素,last() 取最大元素,headSet(to) 取比某个值小的所有元素,tailSet(from) 取比某个值大的所有元素。这些在做范围查询的时候特别好用。

还有一个容易踩的坑:compareTo 里直接用减法可能溢出。this.score - other.score 这种写法在 score 是负数或者差值超过 Integer 范围的时候会出问题。更稳妥的做法是用 Integer.compare(this.score, other.score) 或者 Integer.compareTo。虽然日常写代码遇到溢出的概率不大,但知道了心里踏实一些。

"有序"的 Set,到底是什么意思

学完这三个 Set 实现之后,我有一段时间对"有序"这个概念很混乱。因为 LinkedHashSet 和 TreeSet 都说自己是"有序"的,但它们的有序完全不是一回事。

后来我是这么理解的:

LinkedHashSet 的有序是"按插入顺序"。 你先加什么,遍历的时候就先出什么。跟元素本身的值大小没关系。

TreeSet 的有序是"按值排序"。 不管你怎么加,存进去之后再遍历,元素一定是按从小到大(或者你指定的 Comparator 规则)的顺序输出。

这两个"有序"面对的是完全不同的需求。你想保留操作顺序(比如用户操作日志的去重展示),用 LinkedHashSet。你需要元素自动排序(比如成绩排行榜的去重),用 TreeSet。

我当时拿两个例子对比了一下,思路就清楚了:

java 复制代码
// LinkedHashSet------保留插入顺序
LinkedHashSet<String> lhs = new LinkedHashSet<>();
lhs.add("C");
lhs.add("A");
lhs.add("B");
System.out.println(lhs);  // [C, A, B]

// TreeSet------按值排序
TreeSet<String> ts = new TreeSet<>();
ts.add("C");
ts.add("A");
ts.add("B");
System.out.println(ts);  // [A, B, C]

同样的添加顺序,两个 Set 输出的结果完全不一样。

日常开发中常用的 Set 操作

学完这三种 Set 之后,我经常遇到的一个需求是在 Set 和 List 之间互相转换。比如从数据库查到的数据有重复,先用 Set 去重,再用 List 保持后续操作方便。

Set 转 List 很简单,把 Set 扔到 ArrayList 的构造方法里就行:

java 复制代码
Set<String> set = new HashSet<>();
// ... 加一些元素
List<String> list = new ArrayList<>(set);

反过来,List 转 Set 也是同样的方式,天然就去重了:

java 复制代码
List<String> list = Arrays.asList("a", "b", "a", "c");
Set<String> set = new HashSet<>(list);  // [a, b, c],重复的去掉了

如果你既要去重又要保留原来的顺序,可以用 LinkedHashSet:

java 复制代码
List<String> list = Arrays.asList("c", "a", "b", "a", "c");
Set<String> set = new LinkedHashSet<>(list);  // [c, a, b],去重且保持首次出现的顺序

这个技巧我后来写代码的时候经常用,尤其是在处理用户输入或者外部数据的时候。

还有一个常用的操作是判断某个元素在不在 Set 里,用 contains 方法。HashSet 的 contains 跟 HashMap 的 get 一样快,O(1) 就能出结果。这个特性在某些场景下比 List 的 contains 好用得多------List 的 contains 是 O(n),要挨个比一遍。

实际开发中怎么选

学完之后,我给自己整理了一个选择思路。

如果只需要去重,不需要顺序,用 HashSet。这是最常用的情况,性能也最好。HashSet 的查找、插入和删除操作在理想情况下都是 O(1),跟 ArrayList 的随机访问一样快。

如果需要去重同时保留添加顺序,用 LinkedHashSet。适合"保留操作痕迹"的场景,比如关键词历史记录、操作日志。它的操作跟 HashSet 基本相同,只是多维护了一条链表,大部分操作仍然是 O(1),但常数项稍大一点。

如果需要去重同时元素要自动排序,用 TreeSet。适合排行榜、按序展示的去重数据。不过要注意 TreeSet 的插入和查找是 O(log n),比 HashSet 的 O(1) 慢一些。如果数据量很大而且不需要排序,不要因为 TreeSet 看起来很酷就用它。

还有一点容易忽略的是线程安全。HashSet、LinkedHashSet 和 TreeSet 都不是线程安全的。如果多个线程同时读写同一个 Set,需要自己在外面加锁,或者用 Collections.synchronizedSet 包装一下。Java 也提供了 CopyOnWriteArraySet 作为线程安全的 Set 实现,它的底层是 CopyOnWriteArrayList,适合读多写少的场景。

另外还有一个选择标准:数据量。如果数据量特别大,HashSet 的性能优势会更明显。TreeSet 的插入成本比 HashSet 高,因为红黑树的平衡操作有开销,每次插入和删除都可能触发节点旋转和变色。LinkedHashSet 介于两者之间,它的插入和 HashSet 一样快,只是多了一点维护链表的指针操作。

还有一个不太起眼但实际用得上的区别:HashSet 的迭代顺序是不确定的,甚至同一个 HashSet 在 JDK 不同版本下迭代顺序都可能不一样。所以如果你写了依赖 HashSet 迭代顺序的代码,换了个 JDK 版本可能就跑出不同的结果了。

但大多数时候,数据量没大到需要纠结这个程度。我个人的习惯是:默认用 HashSet,有顺序需求再考虑另外两个。

最后

写这篇的时候我想起来,学 Set 那几天我最大的困惑其实是"为什么要有这么多 Set"。后来想明白了:不同的需求对应不同的实现,没有哪个是最好的,只有哪个最适合你当前的场景。

如果你只需要去重,HashSet 足够了。如果还想要顺序,在 LinkedHashSet 和 TreeSet 之间选一个------先想清楚你要的是"插入顺序"还是"值排序",选错了用起来会很别扭。

相关推荐
SamDeepThinking1 小时前
为什么我会主动给一个年轻程序员加薪、晋升?
java·后端·程序员
ikun_文1 小时前
Java面向对象编程OOP核心知识
java
宠友信息1 小时前
MySQL 租户数据隔离在即时通讯源码后端中的落地方式
java·数据库·spring boot·redis·websocket·mysql
AI人工智能+电脑小能手1 小时前
【大白话说Java面试题 第163题】【06_Spring篇】第23题:说说SpringBoot 自动配置原理?
java·自动配置·springboot·spi·条件注解
江湖有缘2 小时前
【保姆级教程】使用Docker Compose一键搭建Picsur私有图床服务
java·docker·容器
一路向北North2 小时前
Spring Security OAuth2.0(18):资源服务测试
java·后端·spring
问简3 小时前
【共享盘】ubuntu、windows
linux·windows·ubuntu
极光代码工作室3 小时前
基于SpringBoot的订单管理系统
java·springboot·web开发·后端开发
xiaoduzi19913 小时前
ConcurrentHashMap学习
java·学习
折哥的程序人生 · 物流技术专研3 小时前
《Java 100 天进阶之路》第53篇:volatile与JMM(2026版)
java·volatile·内存屏障·可见性·java内存模型·jmm·java100天进阶