笔者在参与某银行面试时,被问到了这样的问题。HashSet的实现原理有了解过吗?由于这个小点平时只是使用,但是源码确实没看过于是就只能“囊中羞涩”了。
(以openjdk-19为例)
源码
首先,我们要分析HashSet,就还要看其实现的接口Set本身包含哪些方法。
Set接口
public interface Set<E> extends Collection<E> {
int size();
boolean isEmpty();
boolean contains(Object o);
Iterator<E> iterator();
Object[] toArray();
<T> T[] toArray(T[] a);
boolean add(E e);
boolean remove(Object o);
boolean containsAll(Coolection<?> c);
boolean addAll(Collection<? extends E> c);
boolean retainAll(Collection<?> c);
boolean removeAll(Collection<?> c);
void clear();
boolean equals(Object o);
int hashCode();
@Override
default Spliterator<E> spliteraotr() {
return Spliterators.spliteraotr(thiss, spliterator.DISTINCT);
}
}
可以说,由于开发流程完善与历史积淀厚重,源码中的注释非常完备,我们已经可以从简称中了解到不同方法的目的,不过有些默认注解需要我们额外注重就是了,比如NotNull。
HashSet 实现
我们首先应该看源代码中的整体注释,这里就不放原文,感兴趣的可以自行翻阅。
装填因子
HashSet底层源码中,有部分方法的入参均涉及loadFactor
public HashSet(int initialCapacity, float loadFactor) {
map = new HashMap<>(initialCapacity, loadFactor);
}
HashSet(int initialCapacity, float loadFactor, boolean dummy) {
map = new LinkedHashMap<>(initialCapacity, loadFactor);
}
public HashSet(Collection<? extends E> c) {
map = new HashMap<>(Math.max((int) (c.size()/.75f) + 1, 16));
addAll(c);
}
该装填因子通常用来衡量哈希表的填充程度,具体来说其计算公式如下
装填因子 = 元素数量 / 哈希表容量
HashMap在扩容时,会根据装填因子的值判断是否需要进行扩容操作。上述源代码中的.75f即对应着默认阈值为0.75,这个是根据经验得到的平衡点,在内存占用和查找效率之间做出的权衡。
源代码注释总结
- HashSet是基于散列表实现的Set接口类
- 该实现不保证集合的迭代顺序,且不会保证顺序会随时间保持恒定
- 该实现对基本操作提供了常数时间性能,包含添加、删除、包含、大小操作
- 对HashSet迭代时,时间复杂度与集合的大小和散列表的容量成正比
- HashSet可以存储null元素,但最多只能存储一个。其原因在于散列表使用元素的哈希码来确定其在内部存储结构的位置,null的哈希码为0,多个null元素尝试添加时,哈希码相同会发生冲突,只能存储一个。
- HashSet不是线程安全的,多线程并发访问且同时至少有一个线程尝试修改集合时,必须进行外部同步。
- HashSet的迭代器是快速失败的,如果在迭代过程中以除迭代器自身的remove方法之外的方式修改集合,迭代器将抛出ConcurrentModificationException
- 快速失败行为并不能得到保证,因为在存在并发修改的情况下无法做出硬性保证。
什么是快速失败,是否存在慢失败?
在Java开发中,“快速失败”(fail-fast)是一种迭代器(Iterator)或集合(Collection)的行为机制。当一个迭代器检测到在迭代过程中集合被修改时(除了通过迭代器自身的remove方法),它会立即抛出ConcurrentModificationException异常,以避免在未来的迭代中产生不确定的行为。
快速失败机制的目的是提前检测并报告并发修改,以确保程序能够在迭代过程中发现错误,而不是在后续的迭代中可能导致不确定的结果。这样可以帮助开发人员及早发现并修复潜在的并发问题,提高程序的可靠性和稳定性。
Java中并没有"慢失败"(slow-fail)的机制,如果Java使用慢失败机制,那么在迭代器遍历集合期间,如果集合被修改,迭代器可能会继续执行而不抛出异常,这可能导致迭代器在后续的操作中产生错误的结果。
总结概述
概述
HashSet、LinkedHashSet和TreeSet是Set接口的实现类,它们都具有保证元素唯一性的特性,并且都不是线程安全的。这三者之间的主要区别在于它们使用的底层数据结构不同。
HashSet使用哈希表作为底层数据结构,实际上是基于HashMap实现的。哈希表通过将元素的哈希码映射到桶(bucket)的索引来存储元素,并使用链表或红黑树来解决哈希冲突。HashSet适用于不需要保证元素插入和取出顺序的场景。由于哈希表的特性,HashSet提供了快速的添加、删除和查找操作,时间复杂度为O(1)。
LinkedHashSet底层数据结构包含了链表和哈希表。它继承自HashSet,并在HashSet的基础上通过使用链表来维护元素的插入顺序。具体而言,当元素被添加到LinkedHashSet时,它会被插入到链表的尾部,从而保证了元素的插入和取出顺序满足FIFO(先进先出)的特性。LinkedHashSet适用于需要保留元素插入顺序的场景。
TreeSet底层数据结构是红黑树(一种自平衡的二叉查找树)。它实现了SortedSet接口,可以确保元素按照特定的顺序进行排序。TreeSet支持自然排序(元素的自然顺序)和定制排序(通过Comparator接口定义的排序规则)。由于红黑树的特性,TreeSet中的元素是有序的。插入、删除和查找操作的时间复杂度是O(log N)。TreeSet适用于需要元素有序排列或自定义排序规则的场景。
总结
- HashSet使用哈希表作为底层数据结构,适用于不需要保证元素顺序的场景。
- LinkedHashSet底层数据结构包含了链表和哈希表,保证元素插入和取出顺序满足FIFO。
- TreeSet底层数据结构是红黑树,元素有序,支持自然排序和定制排序。
- 底层数据结构的不同导致HashSet、LinkedHashSet和TreeSet在应用场景上的差异。
- HashSet适用于不需要保证顺序的场景,LinkedHashSet适用于保证FIFO顺序的场景,TreeSet适用于排序和自定义排序规则的场景。
以下是三种实现的源码片段,我们重点关注其使用的数据结构:
HashSet:
public class HashSet<E> extends AbstractSet<E> implements Set<E>, Cloneable, Serializable {
private transient HashMap<E,Object> map;
// ...
public boolean add(E e) {
return map.put(e, PRESENT)==null;
}
// ...
}
LinkedHashSet:
public class LinkedHashSet<E> extends HashSet<E> implements Set<E>, Cloneable, Serializable {
private transient LinkedHashMap<E,Object> map;
// ...
public boolean add(E e) {
return map.put(e, PRESENT)==null;
}
// ...
}
TreeSet:
public class TreeSet<E> extends AbstractSet<E> implements NavigableSet<E>, Cloneable, Serializable {
private transient NavigableMap<E,Object> map;
// ...
public boolean add(E e) {
return map.put(e, PRESENT)==null;
}
// ...
}
上述源码片段展示了HashSet、LinkedHashSet和TreeSet的底层数据结构的具体实现,其中HashSet和LinkedHashSet使用了HashMap和LinkedHashMap作为底层数据结构,而TreeSet使用了NavigableMap(通常为TreeMap)作为底层数据结构。
我们在面试或者上机考试的手撕中,可以考虑借助TreeMap的key有序的特点来方便我们解决问题。