学完 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 是否存在的流程一样:
- 先算元素的 hashCode,定位到数组的某个桶
- 如果桶是空的,说明没有重复元素,直接放进去
- 如果桶里有元素,再用 equals 挨个比一遍
- 有任何一个 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 之间选一个------先想清楚你要的是"插入顺序"还是"值排序",选错了用起来会很别扭。