「Java 进阶之路」系列 Day24
写在前面
这三个 Set 实现类经常被放在一起问选型,但很多人不知道的是------它们其实都不是"从零实现"的,底层都是套用了前面讲过的 Map 实现。这篇把这层"套壳"关系讲清楚,选型问题自然就迎刃而解了。
一、是什么:三者底层分别套的是哪个 Map
HashSet:内部就是一个HashMap,调用add(e)本质是执行map.put(e, PRESENT)(PRESENT是一个共享的固定占位对象),Day22 讲过的HashMap底层结构、扩容机制,HashSet全盘照搬。LinkedHashSet:继承自HashSet,内部套的是LinkedHashMap------在HashMap基础上多维护了一条双向链表,记录元素的插入顺序(或者访问顺序,取决于构造参数)。TreeSet:内部套的是TreeMap,底层是红黑树,元素按照自然顺序(实现Comparable)或者传入的Comparator规则自动排序。
一句话理解三者的选型逻辑 :HashSet 只关心去重、不关心顺序;LinkedHashSet 在去重的基础上还想要"记住插入顺序";TreeSet 在去重的基础上还想要"元素始终有序排列"。
二、为什么需要三种:顺序需求不同,天然对应不同底层结构
| 底层结构 | 是否有序 | add/contains 时间复杂度 | |
|---|---|---|---|
| HashSet | HashMap(数组+链表/红黑树) | 无序(遍历顺序取决于哈希分布,不能依赖) | O(1) |
| LinkedHashSet | LinkedHashMap(HashMap+双向链表) | 有序,按插入顺序排列 | O(1),比HashSet多一点链表维护的开销 |
| TreeSet | TreeMap(红黑树) | 有序,按排序规则自动排列 | O(log n) |
为什么TreeSet的操作是O(log n)而不是O(1):红黑树是一种自平衡二叉搜索树,插入、查找都需要沿着树的层级往下比较,操作耗时和树的高度成正比,树的高度是O(log n)级别,天然比数组+链表这种结构慢一些------这是"始终保持有序"必须付出的代价,天下没有免费的午餐。
三、怎么用:两个容易踩的坑
坑一:往 TreeSet 里放自定义对象,忘了告诉它怎么排序
java
class Person {
String name;
int age;
Person(String name, int age) { this.name = name; this.age = age; }
}
Set<Person> set = new TreeSet<>();
set.add(new Person("Tom", 20)); // 运行时抛出ClassCastException!
TreeSet 要维护有序性,每次插入都要知道"新元素和已有元素比谁大谁小",如果元素本身没有实现Comparable接口、构造TreeSet时也没传Comparator,它压根不知道该怎么比较,运行时直接抛出ClassCastException。正确做法是让Person实现Comparable<Person>,或者构造时传入一个Comparator<Person>:
java
Set<Person> set = new TreeSet<>(Comparator.comparingInt(p -> p.age));
坑二:TreeSet 判断"重复",靠的是 compareTo,不是 equals/hashCode
这是最容易被面试问倒的细节------HashSet判断两个元素是否重复,用的是Day20讲过的equals+hashCode那套机制;但TreeSet完全不看这两个方法,它认为"比较结果为0"的两个元素就是重复的:
java
class Person implements Comparable<Person> {
String name;
int age;
Person(String name, int age) { this.name = name; this.age = age; }
@Override
public int compareTo(Person o) {
return Integer.compare(this.age, o.age); // 只按年龄比较
}
// 没有重写equals/hashCode,用的是Object默认的(比较引用地址)
}
Set<Person> set = new TreeSet<>();
set.add(new Person("Tom", 20));
set.add(new Person("Jerry", 20)); // 年龄一样,compareTo返回0
System.out.println(set.size()); // 1!Jerry被当成"重复元素"直接丢弃了,尽管equals认为他们不是同一个人
Tom和Jerry是两个完全不同的人(equals按引用判断显然不相等),但因为compareTo只比较了age、两人年龄一样,TreeSet认为这是"重复元素",第二次add直接被无声丢弃。这条规则的官方说法是"compareTo应该和equals保持一致" (如果不一致,TreeSet/TreeMap的行为会和equals定义的相等语义产生偏差),实际写compareTo的时候一定要想清楚:这个排序规则是不是也隐含了"判断两个对象是否算作同一个"的语义,如果不是,就要在比较逻辑里加上足够多的字段来避免误判(比如年龄相同再比较姓名,姓名也相同再退化成用某个唯一id兜底)。
四、面试追问
Q1:HashSet、LinkedHashSet、TreeSet 底层分别是基于什么实现的?
HashSet内部持有一个HashMap,元素作为key存进去;LinkedHashSet继承自HashSet,内部换成了LinkedHashMap,多维护一条双向链表记录插入顺序;TreeSet内部持有一个TreeMap,底层是红黑树,按照排序规则自动排列元素。
Q2:为什么说 HashSet 是无序的,实际测试时看到的遍历顺序是随机的吗?
不是真正随机,遍历顺序完全由元素哈希值落在哪个桶、以及桶内链表/树的组织方式决定,同样的元素、同样的插入顺序,多次运行结果通常是一致的。但这个顺序不是"插入顺序",也不保证在不同JDK版本、不同哈希值分布下保持稳定,所以不能依赖这个顺序做任何业务逻辑,需要保证遍历顺序应该用LinkedHashSet。
Q3:往 TreeSet 里添加没有实现 Comparable 的自定义对象,会发生什么?
如果构造TreeSet时也没有传入Comparator,运行时调用add会抛出ClassCastException。因为TreeSet需要在插入时确定新元素和已有元素的大小关系来维持有序性,如果元素既没实现Comparable、又没有外部传入的比较规则,它没有任何依据去做这个比较。
Q4:TreeSet 是怎么判断两个元素是否重复的?和 HashSet 一样吗?
不一样。HashSet依赖equals和hashCode判断重复;TreeSet依赖compareTo(或者传入的Comparator)的比较结果,只要两个元素比较结果是0,就会被认为是重复元素,即使它们的equals判断并不相等,后添加的那个也会被无声丢弃。这也是为什么写compareTo时要注意让排序逻辑和"相等"的业务语义保持一致,避免出现这种和直觉不符的丢弃行为。
Q5:TreeSet 的操作为什么比 HashSet 慢,具体慢在哪个环节?
TreeSet底层是红黑树,插入、查找、删除都需要沿着树的高度逐层比较,时间复杂度是O(log n);HashSet底层是哈希表,正常情况下直接通过哈希值定位到桶,时间复杂度接近O(1)。TreeSet慢的代价换来的是"元素始终保持有序"这个HashSet不具备的能力,如果不需要排序,HashSet的性能通常更好。
下一篇预告
Day25 讲 Iterator 与 fail-fast 机制------为什么用普通for循环遍历集合时删除元素会抛异常,Iterator自己删除又为什么没事。