一、Set基础知识
1. Set集合
1.1 HashSet
Set集合区别于其他三大集合的重要特性就是元素具有唯一性,南友们记不住这个特性的话,有个易记的方法。Set集合为什么要叫Set呢?因为Set集合的命名取自于我们小学数学里的集合论(Set Theory),数学集合一个很重要的概念就是每个元素的值都互不相同。
Set集合常见的有实例有:HashSet、LinkedHashSet、TreeSet,南哥先缕一缕HashSet。
// HashSet类源码
public class HashSet<E> extends AbstractSet<E> implements Set<E>, Cloneable, java.io.Serializable {...}
HashSet底层实现其实是基于HashMap,HashMap的特点就是Key
具有唯一性,这一点被HashSet利用了起来,每一个HashMap的Key
对应的就是HashSet的元素值
。来看看官方源码的解释。
此类实现Set接口,由哈希表(实际上是HashMap实例)支持。它不保证集合的迭代顺序;特别是,它不保证顺序随时间保持不变。此类允许null元素。
我们创建一个HashSet对象,实际上底层创建了一个HashMap对象。
// HashSet构造方法源码
public HashSet() {
map = new HashMap<>();
}
HashSet一共提供了以下常用方法,不得不说HahSet在业务开发中还是用的没那么多的,南哥在框架源码上看HashSet用的就比较多,比如由Java语言实现的zookeeper框架源码。
(1)添加元素
public boolean add(E e) {
return map.put(e, PRESENT)==null;
}
我们看上面add方法的源码,是不是调用了HashMap的put方法呢?而put方法添加的Key是HashSet的值,Val则是一个空的Object对象。PRESENT
是这么定义的。
// Dummy value to associate with an Object in the backing Map
private static final Object PRESENT = new Object();
(2)判断元素是否存在
public boolean contains(Object o) {
return map.containsKey(o);
}
HashSet的contains方法同样是调用HashMap判断Key是否存在的方法:containsKey
。
(3)移除元素
public boolean remove(Object o) {
return map.remove(o)==PRESENT;
}
1.2 LinkedHashSet
接着轮到LinkedHashSet,同为Set集合之一,它和上文的HashSet有什么区别?南哥卖个关子。
源码对LinkedHashSet
的解释。
Hash table and linked list implementation of the Set interface, with predictable iteration order. This implementation differs from HashSet in that it maintains a doubly-linked list running through all of its entries. This linked list defines the iteration ordering, which is the order in which elements were inserted into the set (insertion-order).
源码的大概意思就是:Set接口的哈希表和链表实现,具有可预测的迭代顺序。此实现与HashSet的不同之处在于,它维护一个贯穿其所有条目的双向链表。此链表定义迭代顺序,即**元素插入集合的顺序 (**插入顺序)。
底层数据结构是一条双向链表,每个元素通过指针进行相连,也就有了按插入顺序排序的功能。
知道了LinkedHashSet的特性,看看他的构造方法。
/**
* 构造一个新的、空的链接哈希集,具有默认初始容量(16)和负载因子(0.75)。
*/
public LinkedHashSet() {
super(16, .75f, true);
}
这个super
方法向上调用了底层C语言源码实现的LinedHashMap的构造方法。LinkedHashMap的特点就是元素的排序是根据插入的顺序进行排序,那LinkedHashSet也就继承了这个特性。
// C语言源码
HashSet(int initialCapacity, float loadFactor, boolean dummy) {
map = new LinkedHashMap<>(initialCapacity, loadFactor);
}
LinkedHashSet的常见方法和HashSet一样,同样是add()、contains()、remove(),这里我写个简单的Demo。
public static void main(String[] args) throws IOException {
LinkedHashSet<Integer> set = new LinkedHashSet<>();
set.add(1);
System.out.println(set.contains(1));
set.remove(1);
System.out.println(set.contains(1));
}
# 运行结果
true
false
1.3 TreeSet
轮到你了,TreeSet。我们南友们很好奇为什么他叫TreeSet?
因为他是基于TreeMap实现的。。。
但根本原因不是,TreeMap的底层是通过红-黑树数据结构来实现自然排序,那TreeSet也就继承了这个特性。
官方源码对TreeSet的解释:
基于TreeMap的NavigableSet实现。元素使用其自然顺序进行排序,或者根据使用的构造函数,使用创建集合时提供的Comparator进行排序。
源码解释告诉我们,TreeSet和HashSet、LinkedHashSet不同的特性在于,元素既不像HashSet一样无序,也不是像LinkedHashSet一样是以插入顺序来排序,它是根据元素的自然顺序来进行排序。
b、c、a
这三个元素插入到TreeSet中,自然顺序就和字母表顺序一样是:a、b、c
。
public static void main(String[] args) throws IOException {
TreeSet<String> treeSet = new TreeSet<>();
treeSet.add("b");
treeSet.add("c");
treeSet.add("a");
System.out.println(treeSet);
}
# 运行结果
[a, b, c]
TreeSet除了拥有以下的add()、contains()、remove()方法。
// 如果指定元素尚不存在,则将其添加到此集合中。
public boolean add(E e) {
return m.put(e, PRESENT)==null;
}
// 如果此集合包含指定元素,则返回true
public boolean contains(Object o) {
return m.containsKey(o);
}
// 如果存在指定元素,则从此集合中移除该元素。
public boolean remove(Object o) {
return m.remove(o)==PRESENT;
}
值得提出来的是,TreeSet还拥有first()、last(),可以方便我们提取出第一个、最后一个元素。
// 返回集合中的第一个元素。
public E first() {
return m.firstKey();
}
// 返回集合中的最后一个元素。
public E last() {
return m.lastKey();
}
1.4 TreeSet自定义排序
TreeSet的自定义排序我们要利用Comparator
接口,通过向TreeSet传入自定义排序规则的Comparator
来实现。
官方源码是这么解释的,南友们看一看。
// 构造一个新的空树集,根据指定的比较器进行排序。
// 插入到集合中的所有元素都必须能够通过指定的比较器相互比较: comparator. compare(e1, e2)不得对集合中的任何元素e1和e2抛出ClassCastException 。
// 如果用户尝试向集合中添加违反此约束的元素,则add调用将抛出ClassCastException
public TreeSet(Comparator<? super E> comparator) {
this(new TreeMap<>(comparator));
}
传入Comparator
接口时,我们还需要定义compare
方法的游戏规则:如果compare
方法比较两个元素的大小,返回正整数代表第一个元素 > 第二个元素、返回负整数代表第一个元素 < 第二个元素、返回0代表第一个元素 = 第二个元素。
下面我写了一个Demo,Comparator接口的规则是这样:人的岁数越小,那么他排名越靠前。
public class JavaProGuideTest {
public static void main(String[] args) {
TreeSet set = new TreeSet(new Comparator() {
public int compare(Object o1, Object o2) {
Person p1 = (Person)o1;
Person p2 = (Person)o2;
return (p1.age > p2.age) ? 1 : (p1.age < p2.age) ? -1 : 0;
}
});
set.add(new Person(5));
set.add(new Person(3));
set.add(new Person(6));
System.out.println(set);
}
@Data
@AllArgsConstructor
private static class Person {
int age;
}
}
# 执行结果
[JavaProGuideTest.Person(age=3), JavaProGuideTest.Person(age=5), JavaProGuideTest.Person(age=6)]
二、Set常见面试题
1. 你能解释一下 Set 和 List 的区别吗?特别是它们在使用场景上的差异?
回答: Set
和 List
是 Java 集合框架中的两个核心接口,它们的主要区别在于是否允许重复元素。List
允许存储重复的元素,并且元素是有序的,可以通过索引访问。Set
则不允许存储重复元素,元素的顺序不固定(具体取决于实现类)。在实际应用中,当你需要保证集合中的元素唯一时,使用 Set
,例如处理用户权限、存储唯一标识符等场景;而 List
适用于需要频繁访问元素或保持插入顺序的场景,如订单列表、待办事项等。
2. 你如何选择使用 HashSet、LinkedHashSet 和 TreeSet?分别在什么情况下使用它们?
回答: HashSet
是最常用的 Set
实现,适用于对顺序没有要求且需要高效查询的场景。LinkedHashSet
保留了插入顺序,适用于需要保留元素插入顺序且对性能要求不太高的场景。TreeSet
则保证元素的排序顺序,适用于需要对集合中的元素进行排序的场景,如存储按字母顺序排列的单词、按照优先级排序的任务等。
3. 假如我们有一个包含大量元素的 List,现在需要去重并保持顺序,你会选择什么集合实现?为什么?
回答: 我会选择 LinkedHashSet
。因为 LinkedHashSet
不仅能去重,还能保持元素的插入顺序。通过将 List
转换为 LinkedHashSet
,可以轻松地去除重复元素,同时保留原始顺序。
4. 在 HashSet 中插入一个元素时,底层具体做了哪些操作?
回答: 当在 HashSet
中插入一个元素时,底层首先通过 hashCode()
方法计算该元素的哈希值,然后将这个哈希值用于定位元素在哈希表中的存储位置。如果该位置已经有元素,则会调用 equals()
方法来比较新元素和现有元素是否相等。如果相等,则不插入新元素;如果不相等,则使用链表或红黑树结构解决哈希冲突,最后将新元素插入到适当位置。
5. 能详细解释一下 TreeSet 是如何进行排序的吗?如果我们有自定义对象呢?
回答: TreeSet
是基于红黑树实现的,默认情况下使用元素的自然顺序(通过 Comparable
接口的 compareTo()
方法)进行排序。如果是自定义对象,可以实现 Comparable
接口并重写 compareTo()
方法,或者在创建 TreeSet
时传入一个 Comparator
来定义排序逻辑。例如,假设我们有一个 Person
类,按照年龄排序,可以在 compareTo()
方法中定义比较逻辑,或者通过 Comparator
传入 TreeSet
。
6. 在实际项目中,是否遇到过 HashSet 无法去重的情况?是什么原因造成的?
回答: 是的,可能会遇到这种情况。这通常是由于自定义对象没有正确实现 equals()
和 hashCode()
方法造成的。HashSet
依赖这两个方法来判断元素的唯一性,如果它们没有正确实现,即使两个对象内容相同,HashSet
也会认为它们是不同的元素,导致无法去重。解决方法是确保 equals()
和 hashCode()
方法按照相同的逻辑比较对象的所有关键属性。
7. 你如何在 Set 中存储自定义对象,同时确保没有重复对象?
回答: 要在 Set
中存储自定义对象并确保没有重复对象,必须重写对象类的 equals()
和 hashCode()
方法。equals()
方法用于判断两个对象是否相等,而 hashCode()
方法则用于计算对象的哈希值。在重写这些方法时,通常需要确保 equals()
方法判断的关键字段都参与 hashCode()
的计算,以确保哈希值和相等性逻辑一致。
8. 什么情况下你会选择使用 EnumSet?它和普通的 Set 有什么不同?
回答: EnumSet
是一个专门用于存储枚举类型的高效 Set
实现。它的实现基于位向量,因而非常高效,特别是当你需要存储一组枚举常量时。相比普通的 Set
,EnumSet
占用更少的内存,性能也更好。例如,在权限管理系统中,如果有一组固定的权限(枚举),可以使用 EnumSet
来高效管理这些权限。
9. 为什么 TreeSet 不允许插入 null 元素?
回答: TreeSet
基于红黑树实现,它需要对插入的元素进行排序,而 null
元素无法进行比较(因为 compareTo()
或 Comparator
都无法处理 null
),因此在插入 null
时会抛出 NullPointerException
。这与 HashSet
可以插入 null
元素形成了对比,因为 HashSet
不需要对元素进行排序。
10. 假如我们需要创建一个线程安全的 Set,你会怎么做?
回答: 有几种方法可以创建线程安全的 Set
。一种方法是使用 Collections.synchronizedSet(new HashSet<>())
,这会返回一个同步的 Set
,所有对它的访问都会自动加锁。另一种更现代的方法是使用 ConcurrentHashMap.newKeySet()
,它基于 ConcurrentHashMap
实现,允许并发访问,并且性能更好。
11. HashSet 的默认加载因子是 0.75,你能解释一下这个值的含义吗?它对性能有什么影响?
回答: 加载因子表示 HashSet
在需要扩容前能填满的比例。默认值 0.75 表示当 HashSet
中的元素数量达到容量的 75% 时,它会自动扩容以保持性能。较高的加载因子可以减少内存的使用,但会增加冲突率,从而降低查找和插入的性能。较低的加载因子会增加内存使用,但减少冲突率,提升性能。一般情况下,0.75 是在性能和内存使用之间的一个折中选择。
12. 你能描述一下 Set 和 Map 之间的关系吗?为什么我们说 HashSet 实际上是基于 HashMap 实现的?
回答: Set
和 Map
是 Java 集合框架的两种不同接口。Set
用于存储不重复的元素,而 Map
用于存储键值对。HashSet
是基于 HashMap
实现的,实际上,HashSet
通过 HashMap
来存储元素,每个元素作为 HashMap
的键(key
),而值(value
)则是一个固定的常量对象。因此,HashSet
在内部通过 HashMap
的 put()
方法来确保元素唯一。
13. 在并发环境中,使用 ConcurrentSkipListSet 有什么优势?
回答: ConcurrentSkipListSet
是一个线程安全的 Set
实现,它基于跳表数据结构。这种结构不仅支持并发访问,而且在执行插入、删除和查找操作时具有较高的性能。与传统的锁机制不同,跳表允许多个线程并发地操作不同的部分,从而减少了锁争用,提升了并发性能。ConcurrentSkipListSet
特别适用于需要自然排序且需要支持高并发的场景。
14. 你是否遇到过 HashSet 中的元素顺序不一致的情况?为什么会这样?
回答: 是的,HashSet
的元素顺序是不固定的。这是因为 HashSet
的底层实现是基于哈希表的,元素的位置取决于其哈希值。而哈希值的分布可能随着元素的增加、删除或哈希表的扩容而发生变化,从而导致元素顺序的变化。因此,HashSet
不适合用在需要保持元素顺序的场景中,如果需要顺序,可以使用 LinkedHashSet
15. 如何在一个 Set 中查找最大或最小元素?
回答: 如果使用的是 TreeSet
,可以直接使用 first()
和 last()
方法来获取最小和最大元素,因为 TreeSet
是有序的。对于无序的 Set
(如 HashSet
或 LinkedHashSet
),你需要手动遍历集合并使用比较器来找到最大或最小元素。
16. 如何判断两个 Set 是否相等?
回答: 两个 Set
被认为是相等的,当且仅当它们包含的元素相同,且元素的数量也相同。可以使用 Set
的 equals()
方法来判断两个 Set
是否相等。这个方法会检查两个集合是否具有相同的元素(无论顺序),如果是,则返回 true。
17. 你能解释一下 WeakHashSet 是什么吗?它在什么场景下使用?
回答: 实际上,Java 标准库中并没有 WeakHashSet
,但可以通过 Collections.newSetFromMap(new WeakHashMap<>())
来创建一个 WeakHashSet
。这种 Set
使用 WeakHashMap
来存储元素的引用,当元素不再被其他强引用所引用时,GC 会自动回收这些元素。这种 Set
适用于缓存等场景,可以防止内存泄漏。
18. 如果需要对一个非常大的 Set 进行操作,比如合并、交集或差集,如何处理会更高效?
回答: 对于非常大的 Set
,可以使用并行流(parallelStream()
)来提高操作效率。并行流可以利用多核处理器同时处理多个元素,从而加快合并、交集和差集的计算。另外,如果这些操作频繁且数据量非常大,使用高效的并发集合如 ConcurrentSkipListSet
也可以帮助提升性能。
19. 在 Java 8 中,如何使用流(Stream)操作 Set?
回答: 在 Java 8 中,可以使用 Set.stream()
方法将 Set
转换为流,然后使用流的各种操作如 filter
、map
、collect
等进行操作。例如,如果你想对 Set
中的每个元素进行处理并收集结果,可以这样做: java Set<String> set = new HashSet<>(Arrays.asList("a", "b", "c")); Set<String>result=set.stream().map(String::toUpperCase).collect(Collectors.toSet());
这样你就可以很方便地使用流来操作 Set
。
20. 在面试中经常问到如何优化 Set 操作,你会给出哪些建议?
回答: 优化 Set
操作的建议包括: - 根据需求选择合适的 Set
实现,如 HashSet
、LinkedHashSet
、TreeSet
等。 - 合理设置 HashSet
的初始容量和加载因子,以减少扩容次数。 - 对于频繁的集合操作如并集、交集等,可以使用并行流来提升性能。 - 使用 EnumSet
来处理枚举类型的集合,能大幅度减少内存消耗和提高性能。 - 在多线程环境下使用线程安全的集合如 ConcurrentSkipListSet
或通过 Collections.synchronizedSet()
来保证安全性。
让我们一起学习,一起进步!期待在评论区与你们见面。
祝学习愉快!