探秘 Java HashSet:从源码洞悉其使用原理
一、引言
在 Java 编程领域,集合框架扮演着至关重要的角色,它为开发者提供了一系列用于存储和操作数据的工具。其中,HashSet 作为一种常用的集合实现,以其独特的特性在众多场景中发挥着关键作用。HashSet 基于哈希表实现,它不允许存储重复元素,并且能够快速地进行元素的添加、删除和查找操作。对于开发者而言,深入理解 HashSet 的使用原理不仅有助于提高代码的性能和效率,还能避免在使用过程中出现一些潜在的问题。本文将深入到 HashSet 的源码层面,详细剖析其内部结构、核心方法的实现原理以及性能特点,带领读者全面了解 HashSet 的奥秘。
二、HashSet 概述
2.1 基本概念
HashSet 是 Java 集合框架中的一个类,它实现了 Set 接口。Set 接口代表一种不包含重复元素的集合,即集合中的每个元素都是唯一的。HashSet 基于哈希表(实际上是 HashMap)来存储元素,它通过哈希函数将元素映射到哈希表的特定位置,从而实现快速的元素查找和插入操作。由于哈希表的特性,HashSet 不保证元素的存储顺序,也就是说,元素在 HashSet 中的存储顺序可能与插入顺序不同。
2.2 继承关系与接口实现
从类的继承关系和接口实现角度来看,HashSet 的定义如下:
// 继承自 AbstractSet 类,实现了 Set、Cloneable 和 Serializable 接口
public class HashSet<E>
extends AbstractSet<E>
implements Set<E>, Cloneable, java.io.Serializable
{
// 类的具体实现将在后续详细分析
}
可以看到,HashSet 继承自 AbstractSet 类,该类提供了 Set 接口的一些基本实现。同时,它实现了 Set 接口,具备集合的基本功能,如添加元素、删除元素、判断元素是否存在等;实现了 Cloneable 接口,支持对象的克隆操作;实现了 Serializable 接口,支持对象的序列化和反序列化。
2.3 与其他集合的对比
与其他常见的集合实现(如 ArrayList 和 TreeSet)相比,HashSet 具有以下特点:
- 不允许重复元素:
HashSet不允许存储重复的元素,当尝试向HashSet中添加一个已经存在的元素时,添加操作将失败。而ArrayList允许存储重复元素。 - 无序性:
HashSet不保证元素的存储顺序,元素在HashSet中的存储顺序可能与插入顺序不同。而ArrayList按照元素的插入顺序存储元素,TreeSet则按照元素的自然顺序或指定的比较器顺序对元素进行排序。 - 快速查找:由于
HashSet基于哈希表实现,它能够快速地进行元素的查找操作,平均时间复杂度为 。而ArrayList的查找操作需要遍历整个列表,时间复杂度为 ,TreeSet的查找操作时间复杂度为 。
三、HashSet 的内部结构
3.1 核心属性
HashSet 类的核心属性决定了其数据存储和操作的基本机制,以下是关键属性的源码及注释:
// 用于存储元素的 HashMap 实例
private transient HashMap<E,Object> map;
// 作为 HashMap 中所有键对应的值,是一个静态常量对象
private static final Object PRESENT = new Object();
map:HashSet内部使用一个HashMap实例来存储元素。HashMap是 Java 中基于哈希表实现的键值对存储结构,HashSet利用HashMap的键来存储元素,而所有键对应的值都使用一个静态常量对象PRESENT。PRESENT:这是一个静态常量对象,作为HashMap中所有键对应的值。由于HashSet只关心元素本身,不关心元素对应的值,因此使用一个固定的对象作为所有键的值。
3.2 数据存储结构
HashSet 基于 HashMap 实现,HashMap 是一种哈希表结构,它通过哈希函数将键映射到哈希表的特定位置。在 HashMap 中,每个位置可以存储一个或多个键值对,当多个键通过哈希函数映射到同一个位置时,会发生哈希冲突。HashMap 通常使用链表或红黑树来解决哈希冲突。在 HashSet 中,元素作为 HashMap 的键存储,所有键对应的值都是 PRESENT。
3.3 初始化过程
HashSet 的构造函数有多种重载形式,下面分别介绍不同构造函数的源码及注释。
3.3.1 无参构造函数
// 无参构造函数,初始化一个空的 HashSet
public HashSet() {
// 创建一个默认初始容量为 16,加载因子为 0.75 的 HashMap 实例
map = new HashMap<>();
}
在无参构造函数中,创建了一个默认初始容量为 16,加载因子为 0.75 的 HashMap 实例。加载因子表示哈希表在达到多满时进行扩容操作,默认的加载因子 0.75 是在时间和空间效率之间的一个权衡。
3.3.2 带初始容量的构造函数
// 带初始容量的构造函数,初始化一个具有指定初始容量的 HashSet
public HashSet(int initialCapacity) {
// 创建一个具有指定初始容量,加载因子为 0.75 的 HashMap 实例
map = new HashMap<>(initialCapacity);
}
在带初始容量的构造函数中,创建了一个具有指定初始容量,加载因子为 0.75 的 HashMap 实例。
3.3.3 带初始容量和加载因子的构造函数
// 带初始容量和加载因子的构造函数,初始化一个具有指定初始容量和加载因子的 HashSet
public HashSet(int initialCapacity, float loadFactor) {
// 创建一个具有指定初始容量和加载因子的 HashMap 实例
map = new HashMap<>(initialCapacity, loadFactor);
}
在带初始容量和加载因子的构造函数中,创建了一个具有指定初始容量和加载因子的 HashMap 实例。
3.3.4 带集合参数的构造函数
// 带集合参数的构造函数,初始化一个包含指定集合中所有元素的 HashSet
public HashSet(Collection<? extends E> c) {
// 创建一个初始容量足够大的 HashMap 实例,以容纳指定集合中的所有元素
map = new HashMap<>(Math.max((int) (c.size()/.75f) + 1, 16));
// 将指定集合中的所有元素添加到 HashSet 中
addAll(c);
}
在带集合参数的构造函数中,首先创建一个初始容量足够大的 HashMap 实例,以容纳指定集合中的所有元素,然后调用 addAll 方法将指定集合中的所有元素添加到 HashSet 中。
四、基本操作的源码分析
4.1 添加操作
4.1.1 add(E e) 方法
add(E e) 方法用于向 HashSet 中添加一个元素。源码及注释如下:
// 向 HashSet 中添加一个元素
public boolean add(E e) {
// 调用 HashMap 的 put 方法,将元素作为键,PRESENT 作为值插入到 HashMap 中
// 如果该键已经存在于 HashMap 中,put 方法将返回旧值,否则返回 null
// 如果返回 null,说明元素成功添加到 HashSet 中,返回 true;否则返回 false
return map.put(e, PRESENT)==null;
}
在 add 方法中,调用 HashMap 的 put 方法将元素作为键,PRESENT 作为值插入到 HashMap 中。如果该键已经存在于 HashMap 中,put 方法将返回旧值,否则返回 null。如果返回 null,说明元素成功添加到 HashSet 中,返回 true;否则返回 false。
4.1.2 addAll(Collection<? extends E> c) 方法
addAll(Collection<? extends E> c) 方法用于将指定集合中的所有元素添加到 HashSet 中。源码及注释如下:
// 将指定集合中的所有元素添加到 HashSet 中
public boolean addAll(Collection<? extends E> c) {
// 标记是否有元素被添加到 HashSet 中
boolean modified = false;
// 遍历指定集合中的每个元素
for (E e : c)
// 调用 add 方法将元素添加到 HashSet 中
// 如果元素成功添加,将 modified 标记为 true
if (add(e))
modified = true;
return modified;
}
在 addAll 方法中,遍历指定集合中的每个元素,调用 add 方法将元素添加到 HashSet 中。如果有元素成功添加,将 modified 标记为 true,最后返回 modified。
4.2 删除操作
4.2.1 remove(Object o) 方法
remove(Object o) 方法用于从 HashSet 中移除指定的元素。源码及注释如下:
// 从 HashSet 中移除指定的元素
public boolean remove(Object o) {
// 调用 HashMap 的 remove 方法,移除指定键对应的键值对
// 如果该键存在于 HashMap 中,remove 方法将返回旧值,否则返回 null
// 如果返回值不为 null,说明元素成功从 HashSet 中移除,返回 true;否则返回 false
return map.remove(o)==PRESENT;
}
在 remove 方法中,调用 HashMap 的 remove 方法移除指定键对应的键值对。如果该键存在于 HashMap 中,remove 方法将返回旧值,否则返回 null。如果返回值不为 null,说明元素成功从 HashSet 中移除,返回 true;否则返回 false。
4.2.2 removeAll(Collection<?> c) 方法
removeAll(Collection<?> c) 方法用于从 HashSet 中移除指定集合中包含的所有元素。源码及注释如下:
// 从 HashSet 中移除指定集合中包含的所有元素
public boolean removeAll(Collection<?> c) {
// 检查输入的集合是否为 null,如果为 null 则抛出 NullPointerException 异常
Objects.requireNonNull(c);
// 标记是否有元素被从 HashSet 中移除
boolean modified = false;
// 如果 HashSet 的大小大于指定集合的大小
if (size() > c.size()) {
// 遍历指定集合中的每个元素
for (Object e : c)
// 调用 remove 方法移除该元素
// 如果元素成功移除,将 modified 标记为 true
if (remove(e))
modified = true;
} else {
// 如果 HashSet 的大小小于等于指定集合的大小
// 遍历 HashSet 中的每个元素
for (Iterator<?> i = iterator(); i.hasNext(); ) {
// 如果指定集合包含该元素
if (c.contains(i.next())) {
// 调用迭代器的 remove 方法移除该元素
i.remove();
// 将 modified 标记为 true
modified = true;
}
}
}
return modified;
}
在 removeAll 方法中,首先检查输入的集合是否为 null,如果为 null 则抛出 NullPointerException 异常。然后根据 HashSet 的大小和指定集合的大小选择不同的遍历方式。如果 HashSet 的大小大于指定集合的大小,遍历指定集合中的每个元素,调用 remove 方法移除该元素;如果 HashSet 的大小小于等于指定集合的大小,遍历 HashSet 中的每个元素,如果指定集合包含该元素,调用迭代器的 remove 方法移除该元素。最后返回 modified 标记。
4.2.3 clear() 方法
clear() 方法用于清空 HashSet 中的所有元素。源码及注释如下:
// 清空 HashSet 中的所有元素
public void clear() {
// 调用 HashMap 的 clear 方法,清空 HashMap 中的所有键值对
map.clear();
}
在 clear 方法中,调用 HashMap 的 clear 方法清空 HashMap 中的所有键值对,从而清空 HashSet 中的所有元素。
4.3 查找操作
4.3.1 contains(Object o) 方法
contains(Object o) 方法用于检查 HashSet 中是否包含指定的元素。源码及注释如下:
// 检查 HashSet 中是否包含指定的元素
public boolean contains(Object o) {
// 调用 HashMap 的 containsKey 方法,检查 HashMap 中是否包含指定的键
// 如果包含,说明 HashSet 中包含该元素,返回 true;否则返回 false
return map.containsKey(o);
}
在 contains 方法中,调用 HashMap 的 containsKey 方法检查 HashMap 中是否包含指定的键。如果包含,说明 HashSet 中包含该元素,返回 true;否则返回 false。
4.4 其他操作
4.4.1 size() 方法
size() 方法用于返回 HashSet 中元素的数量。源码及注释如下:
// 返回 HashSet 中元素的数量
public int size() {
// 调用 HashMap 的 size 方法,返回 HashMap 中键值对的数量,即 HashSet 中元素的数量
return map.size();
}
在 size 方法中,调用 HashMap 的 size 方法返回 HashMap 中键值对的数量,也就是 HashSet 中元素的数量。
4.4.2 isEmpty() 方法
isEmpty() 方法用于检查 HashSet 是否为空。源码及注释如下:
// 检查 HashSet 是否为空
public boolean isEmpty() {
// 调用 HashMap 的 isEmpty 方法,检查 HashMap 是否为空
// 如果为空,说明 HashSet 为空,返回 true;否则返回 false
return map.isEmpty();
}
在 isEmpty 方法中,调用 HashMap 的 isEmpty 方法检查 HashMap 是否为空。如果为空,说明 HashSet 为空,返回 true;否则返回 false。
4.4.3 iterator() 方法
iterator() 方法用于返回一个迭代器,用于遍历 HashSet 中的元素。源码及注释如下:
// 返回一个迭代器,用于遍历 HashSet 中的元素
public Iterator<E> iterator() {
// 调用 HashMap 的 keySet 方法返回键集合,然后调用键集合的 iterator 方法返回迭代器
return map.keySet().iterator();
}
在 iterator 方法中,调用 HashMap 的 keySet 方法返回键集合,然后调用键集合的 iterator 方法返回迭代器,用于遍历 HashSet 中的元素。
五、核心方法的源码分析
5.1 迭代器相关方法
5.1.1 iterator() 方法
HashSet 的 iterator() 方法实际上调用了 HashMap 的 keySet().iterator() 方法。下面分析 HashMap 中 keySet() 方法和 iterator() 方法的实现。
// HashMap 中的 keySet 方法,返回键集合
public Set<K> keySet() {
// 获取键集合对象,如果键集合对象为 null,则创建一个新的键集合对象
Set<K> ks = keySet;
if (ks == null) {
ks = new KeySet();
keySet = ks;
}
return ks;
}
// HashMap 中 KeySet 类的定义
final class KeySet extends AbstractSet<K> {
// 返回键集合中元素的数量
public final int size() { return size; }
// 清空键集合中的所有元素
public final void clear() { HashMap.this.clear(); }
// 返回一个迭代器,用于遍历键集合中的元素
public final Iterator<K> iterator() { return new KeyIterator(); }
// 检查键集合中是否包含指定的元素
public final boolean contains(Object o) { return containsKey(o); }
// 从键集合中移除指定的元素
public final boolean remove(Object key) {
return removeNode(hash(key), key, null, false, true) != null;
}
// 返回一个可分割迭代器,用于并行遍历键集合中的元素
public final Spliterator<K> spliterator() {
return new KeySpliterator<>(HashMap.this, 0, -1, 0, 0);
}
// 对键集合中的每个元素执行指定的操作
public final void forEach(Consumer<? super K> action) {
Node<K,V>[] tab;
if (action == null)
throw new NullPointerException();
if (size > 0 && (tab = table) != null) {
int mc = modCount;
for (int i = 0; i < tab.length; ++i) {
for (Node<K,V> e = tab[i]; e != null; e = e.next)
action.accept(e.key);
}
if (modCount != mc)
throw new ConcurrentModificationException();
}
}
}
// HashMap 中 KeyIterator 类的定义,继承自 HashIterator
final class KeyIterator extends HashIterator
implements Iterator<K> {
// 返回下一个键元素
public final K next() { return nextNode().key; }
}
// HashMap 中 HashIterator 类的定义
abstract class HashIterator {
// 下一个要返回的节点
Node<K,V> next; // next entry to return
// 当前节点
Node<K,V> current; // current entry
// 记录 HashMap 结构修改的次数,用于快速失败机制
int expectedModCount; // for fast-fail
// 当前索引
int index; // current slot
// 构造函数,初始化迭代器
HashIterator() {
// 获取 HashMap 结构修改的次数
expectedModCount = modCount;
// 获取 HashMap 的哈希表数组
Node<K,V>[] t = table;
current = next = null;
index = 0;
// 找到第一个非空的桶
if (t != null && size > 0) {
do {} while (index < t.length && (next = t[index++]) == null);
}
}
// 判断是否还有下一个元素
public final boolean hasNext() {
return next != null;
}
// 获取下一个节点
final Node<K,V> nextNode() {
// 获取下一个节点
Node<K,V>[] t;
Node<K,V> e = next;
// 检查 HashMap 结构是否被修改
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
if (e == null)
throw new NoSuchElementException();
// 更新 next 为下一个节点
if ((next = (current = e).next) == null && (t = table) != null) {
// 如果当前桶遍历完,找到下一个非空的桶
do {} while (index < t.length && (next = t[index++]) == null);
}
return e;
}
// 移除当前元素
public final void remove() {
// 获取当前节点
Node<K,V> p = current;
if (p == null)
throw new IllegalStateException();
// 检查 HashMap 结构是否被修改
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
current = null;
// 调用 HashMap 的 removeNode 方法移除当前节点
removeNode(p.hash, p.key, null, false, false);
// 更新预期的修改次数
expectedModCount = modCount;
}
}
在 HashMap 中:
keySet()方法返回一个KeySet对象,该对象是AbstractSet的子类,代表HashMap的键集合。KeySet类实现了size()、clear()、iterator()、contains()、remove()等方法,用于操作键集合。KeyIterator类继承自HashIterator,用于遍历键集合中的元素。HashIterator类是一个抽象类,实现了迭代器的基本功能,包括hasNext()、nextNode()和remove()方法。在HashIterator的构造函数中,会找到第一个非空的桶,在nextNode()方法中会更新next为下一个节点,如果当前桶遍历完,会找到下一个非空的桶。在remove()方法中,会调用HashMap的removeNode方法移除当前节点,并更新预期的修改次数。
5.2 克隆与序列化
5.2.1 clone() 方法
// 克隆当前 HashSet
@SuppressWarnings("unchecked")
public Object clone() {
try {
// 调用父类的 clone 方法创建一个新的 HashSet 实例
HashSet<E> newSet = (HashSet<E>) super.clone();
// 创建一个新的 HashMap 实例
newSet.map = (HashMap<E, Object>) map.clone();
return newSet;
} catch (CloneNotSupportedException e) {
throw new InternalError(e);
}
}
在 clone() 方法中,首先调用父类的 clone 方法创建一个新的 HashSet 实例,然后创建一个新的 HashMap 实例,并将原 HashMap 实例克隆到新的 HashMap 实例中,最后返回新的 HashSet 实例。
5.2.2 序列化相关方法
// 序列化写入方法
private void writeObject(java.io.ObjectOutputStream s)
throws java.io.IOException {
// 写入非静态和非瞬态字段
s.defaultWriteObject();
// 写入 HashMap 的容量和加载因子
s.writeInt(map.capacity());
s.writeFloat(map.loadFactor());
// 写入 HashSet 的大小
s.writeInt(map.size());
// 遍历 HashSet 中的每个元素,将元素写入输出流
for (E e : map.keySet())
s.writeObject(e);
}
// 反序列化读取方法
private void readObject(java.io.ObjectInputStream s)
throws java.io.IOException, ClassNotFoundException {
// 读取非静态和非瞬态字段
s.defaultReadObject();
// 读取 HashMap 的容量和加载因子
int capacity = s.readInt();
float loadFactor = s.readFloat();
// 创建一个具有指定容量和加载因子的 HashMap 实例
map = (((HashSet<?>)this) instanceof LinkedHashSet ?
new LinkedHashMap<E,Object>(capacity, loadFactor) :
new HashMap<E,Object>(capacity, loadFactor));
// 读取 HashSet 的大小
int size = s.readInt();
// 依次读取元素并添加到 HashSet 中
for (int i=0; i<size; i++) {
@SuppressWarnings("unchecked")
E e = (E) s.readObject();
map.put(e, PRESENT);
}
}
writeObject() 方法用于将 HashSet 对象序列化,先写入非静态和非瞬态字段,再写入 HashMap 的容量和加载因子,然后写入 HashSet 的大小,最后遍历 HashSet 中的每个元素,将元素写入输出流。readObject() 方法用于反序列化,先读取非静态和非瞬态字段,再读取 HashMap 的容量和加载因子,创建一个具有指定容量和加载因子的 HashMap 实例,然后读取 HashSet 的大小,依次读取元素并添加到 HashSet 中。
六、性能分析
6.1 时间复杂度分析
- 添加操作:
add(E e)方法的平均时间复杂度为 ,因为HashSet基于哈希表实现,通过哈希函数可以快速定位元素的存储位置。但在哈希冲突严重的情况下,时间复杂度可能会退化为 。 - 删除操作:
remove(Object o)方法的平均时间复杂度为 ,同样是因为哈希表的特性。在哈希冲突严重时,时间复杂度可能会退化为 。 - 查找操作:
contains(Object o)方法的平均时间复杂度为 ,通过哈希函数可以快速判断元素是否存在于HashSet中。在哈希冲突严重时,时间复杂度可能会退化为 。 - 迭代操作:使用迭代器遍历
HashSet的时间复杂度为 ,因为需要依次访问HashSet中的每个元素。
6.2 空间复杂度分析
HashSet 的空间复杂度为 ,其中 是 HashSet 中元素的数量。除了存储元素本身所需的空间外,还需要额外的空间来存储哈希表的结构和解决哈希冲突所需的数据结构(如链表或红黑树)。
6.3 性能比较与适用场景
与其他集合实现(如 TreeSet 和 ArrayList)相比,HashSet 在查找、添加和删除操作上具有明显的性能优势,尤其是在数据量较大的情况下。但 HashSet 不保证元素的顺序,而 TreeSet 可以保证元素按照自然顺序或指定的比较器顺序排序,ArrayList 则按照元素的插入顺序存储元素。因此,HashSet 适用于以下场景:
- 需要快速判断元素是否存在的场景,如去重操作。
- 不需要元素有序存储的场景。
- 对插入、删除和查找操作的性能要求较高的场景。
七、使用示例
7.1 基本使用示例
import java.util.HashSet;
import java.util.Set;
public class HashSetExample {
public static void main(String[] args) {
// 创建一个 HashSet 实例
Set<String> set = new HashSet<>();
// 添加元素
set.add("Apple");
set.add("Banana");
set.add("Cherry");
// 打印 HashSet 中的元素
System.out.println("HashSet elements: " + set);
// 检查 HashSet 中是否包含指定元素
boolean containsApple = set.contains("Apple");
System.out.println("Contains Apple: " + containsApple);
// 移除指定元素
set.remove("Banana");
System.out.println("HashSet elements after removal: " + set);
// 遍历 HashSet 中的元素
for (String element : set) {
System.out.println("Element: " + element);
}
}
}
在这个示例中,创建了一个 HashSet 实例,演示了添加元素、检查元素是否存在、移除元素和遍历元素的操作。
7.2 去重示例
import java.util.Arrays;
import java.util.HashSet;
import java.util.Set;
public class HashSetDeduplicationExample {
public static void main(String[] args) {
// 定义一个包含重复元素的数组
String[] array = {"Apple", "Banana", "Apple", "Cherry", "Banana"};
// 创建一个 HashSet 实例
Set<String> set = new HashSet<>(Arrays.asList(array));
// 打印去重后的元素
System.out.println("Deduplicated elements: " + set);
}
}
在这个示例中,使用 HashSet 对数组中的元素进行去重操作,利用 HashSet 不允许存储重复元素的特性,将数组中的元素添加到 HashSet 中,从而实现去重。
八、总结与展望
8.1 总结
HashSet 是 Java 集合框架中一个非常实用的集合实现,它基于 HashMap 实现,具有以下特点:
- 不允许重复元素:
HashSet不允许存储重复的元素,通过add方法添加重复元素时,添加操作将失败。 - 无序性:
HashSet不保证元素的存储顺序,元素在HashSet中的存储顺序可能与插入顺序不同。 - 快速查找、添加和删除操作:由于
HashSet基于哈希表实现,它能够快速地进行元素的查找、添加和删除操作,平均时间复杂度为 。 - 支持克隆和序列化:
HashSet支持对象的克隆和序列化操作,可以方便地进行对象的复制和持久化存储。
8.2 展望
随着 Java 技术的不断发展,HashSet 可能会在以下方面得到进一步的优化和改进:
- 性能优化:尽管
HashSet在大多数情况下具有较好的性能,但在某些极端情况下,如哈希冲突严重时,性能可能会受到影响。未来可能会通过优化哈希函数、改进哈希冲突解决机制等方式来提高HashSet的性能。 - 并发支持:在多线程环境下,
HashSet不是线程安全的,需要使用额外的同步机制来保证线程安全。未来可能会提供更高效的并发实现,以满足多线程环境下的使用需求。 - 与其他数据结构的融合:可以将
HashSet与其他数据结构进行融合,以发挥各自的优势,实现更复杂的功能。例如,可以将HashSet与TreeSet结合,实现一个既支持快速查找又支持元素排序的集合。
总之,HashSet 作为 Java 集合框架中的一个重要组成部分,在实际开发中具有广泛的应用场景。通过深入理解其使用原理和性能特点,开发者可以更好地选择合适的数据结构,优化代码性能,提高开发效率。
以上文章详细分析了 Java HashSet 的使用原理,从源码层面深入剖析了其内部结构、核心方法的实现以及性能特点,并通过示例代码展示了其使用方式。希望这篇文章能够帮助你更好地理解和使用 HashSet。后续将继续丰富内容以满足 30000 字以上的要求。
九、哈希冲突的处理机制
9.1 哈希冲突的概念
在 HashSet 基于 HashMap 实现的过程中,哈希函数用于将元素的键映射到哈希表的特定位置。然而,由于哈希函数的输出范围是有限的,而元素的键可能是无限的,因此可能会出现多个不同的键通过哈希函数映射到同一个位置的情况,这种情况就称为哈希冲突。例如,假设有一个哈希函数 hash(key) = key % 10,当 key1 = 1 和 key2 = 11 时,hash(key1) = hash(key2) = 1,这就发生了哈希冲突。
9.2 链地址法
HashMap 中使用链地址法来解决哈希冲突。链地址法的基本思想是,当发生哈希冲突时,将所有映射到同一个位置的元素存储在一个链表中。在 HashMap 中,每个位置存储一个链表的头节点,当有新元素映射到该位置时,将其插入到链表中。以下是 HashMap 中处理哈希冲突的部分源码及注释:
// HashMap 中存储元素的数组
transient Node<K,V>[] table;
// 节点类,用于存储键值对
static class Node<K,V> implements Map.Entry<K,V> {
// 哈希值
final int hash;
// 键
final K key;
// 值
V value;
// 指向下一个节点的引用
Node<K,V> next;
// 节点的构造函数
Node(int hash, K key, V value, Node<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
// 获取键
public final K getKey() { return key; }
// 获取值
public final V getValue() { return value; }
// 重写 toString 方法
public final String toString() { return key + "=" + value; }
// 计算哈希码
public final int hashCode() {
return Objects.hashCode(key) ^ Objects.hashCode(value);
}
// 设置值
public final V setValue(V newValue) {
V oldValue = value;
value = newValue;
return oldValue;
}
// 判断两个节点是否相等
public final boolean equals(Object o) {
if (o == this)
return true;
if (o instanceof Map.Entry) {
Map.Entry<?,?> e = (Map.Entry<?,?>)o;
if (Objects.equals(key, e.getKey()) &&
Objects.equals(value, e.getValue()))
return true;
}
return false;
}
}
// 插入键值对的方法
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
// 如果哈希表为空或者长度为 0,进行扩容操作
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 如果指定位置为空,直接创建一个新节点
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
// 如果指定位置不为空,处理哈希冲突
Node<K,V> e; K k;
// 如果第一个节点的键和要插入的键相同
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
// 如果第一个节点是树节点
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
// 遍历链表
for (int binCount = 0; ; ++binCount) {
// 如果到达链表末尾
if ((e = p.next) == null) {
// 创建一个新节点并插入到链表末尾
p.next = newNode(hash, key, value, null);
// 如果链表长度达到树化阈值,将链表转换为红黑树
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
// 如果找到相同键的节点
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
// 如果找到了相同键的节点
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
// 记录结构修改次数
++modCount;
// 如果元素数量超过阈值,进行扩容操作
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
在上述 putVal 方法中,当发生哈希冲突时(即 (p = tab[i = (n - 1) & hash]) != null),会有以下几种处理情况:
- 键相同:如果第一个节点的键和要插入的键相同,将该节点赋值给
e。 - 树节点:如果第一个节点是树节点(红黑树),调用
putTreeVal方法将键值对插入到红黑树中。 - 链表节点:遍历链表,如果到达链表末尾,则创建一个新节点并插入到链表末尾。如果链表长度达到树化阈值(默认是 8),则调用
treeifyBin方法将链表转换为红黑树。如果在遍历过程中找到相同键的节点,则跳出循环。
9.3 红黑树的引入
当链表长度过长时,链表的查找、插入和删除操作的时间复杂度会退化为 ,为了提高性能,HashMap 在链表长度达到一定阈值(默认是 8)且哈希表的长度达到 64 时,会将链表转换为红黑树。红黑树是一种自平衡的二叉搜索树,它可以保证在最坏情况下,查找、插入和删除操作的时间复杂度为 。以下是 treeifyBin 方法的源码及注释:
// 将链表转换为红黑树
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
// 如果哈希表为空或者长度小于 MIN_TREEIFY_CAPACITY(默认是 64),进行扩容操作
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
else if ((e = tab[index = (n - 1) & hash]) != null) {
// 树节点的头节点和尾节点
TreeNode<K,V> hd = null, tl = null;
do {
// 将链表节点转换为树节点
TreeNode<K,V> p = replacementTreeNode(e, null);
if (tl == null)
hd = p;
else {
p.prev = tl;
tl.next = p;
}
tl = p;
} while ((e = e.next) != null);
// 如果树节点的头节点不为空,将链表转换为红黑树
if ((tab[index] = hd) != null)
hd.treeify(tab);
}
}
在 treeifyBin 方法中,如果哈希表的长度小于 MIN_TREEIFY_CAPACITY,会进行扩容操作。否则,将链表节点转换为树节点,并调用 treeify 方法将链表转换为红黑树。
9.4 红黑树的插入与删除
当链表转换为红黑树后,插入和删除操作会在红黑树中进行。以下是 putTreeVal 方法(插入操作)和 removeTreeNode 方法(删除操作)的部分源码及注释:
// 在红黑树中插入键值对
final TreeNode<K,V> putTreeVal(HashMap<K,V> map, Node<K,V>[] tab,
int h, K k, V v) {
Class<?> kc = null;
boolean searched = false;
// 获取根节点
TreeNode<K,V> root = (parent != null)? root() : this;
for (TreeNode<K,V> p = root;;) {
int dir, ph; K pk;
// 比较哈希值
if ((ph = p.hash) > h)
dir = -1;
else if (ph < h)
dir = 1;
// 如果哈希值相同,比较键
else if ((pk = p.key) == k || (k != null && k.equals(pk)))
return p;
else if ((kc == null &&
(kc = comparableClassFor(k)) == null) ||
(dir = compareComparables(kc, k, pk)) == 0) {
if (!searched) {
TreeNode<K,V> q, ch;
searched = true;
if (((ch = p.left) != null &&
(q = ch.find(h, k, kc)) != null) ||
((ch = p.right) != null &&
(q = ch.find(h, k, kc)) != null))
return q;
}
// 如果无法比较键的大小,使用系统默认的比较方式
dir = tieBreakOrder(k, pk);
}
TreeNode<K,V> xp = p;
if ((p = (dir <= 0)? p.left : p.right) == null) {
Node<K,V> xpn = xp.next;
// 创建一个新的树节点
TreeNode<K,V> x = map.newTreeNode(h, k, v, xpn);
if (dir <= 0)
xp.left = x;
else
xp.right = x;
xp.next = x;
x.parent = x.prev = xp;
if (xpn != null)
((TreeNode<K,V>)xpn).prev = x;
// 将树节点插入到红黑树中,并进行平衡操作
moveRootToFront(tab, balanceInsertion(root, x));
return null;
}
}
}
// 从红黑树中移除树节点
final void removeTreeNode(HashMap<K,V> map, Node<K,V>[] tab,
boolean movable) {
int n;
if (tab == null || (n = tab.length) == 0)
return;
int index = (n - 1) & hash;
TreeNode<K,V> first = (TreeNode<K,V>)tab[index], root = first, rl;
TreeNode<K,V> succ = (TreeNode<K,V>)next, pred = prev;
if (pred == null)
tab[index] = first = succ;
else
pred.next = succ;
if (succ != null)
succ.prev = pred;
if (first == null)
return;
if (root.parent != null)
root = root.root();
if (root == null || root.right == null ||
(rl = root.left) == null || rl.left == null) {
// 如果红黑树的节点数量较少,将红黑树转换为链表
tab[index] = first.untreeify(map); // too small
return;
}
TreeNode<K,V> p = this, pl = left, pr = right, replacement;
if (pl != null && pr != null) {
TreeNode<K,V> s = pr, sl;
while ((sl = s.left) != null) // find successor
s = sl;
boolean c = s.red; s.red = p.red; p.red = c; // swap colors
TreeNode<K,V> sr = s.right;
TreeNode<K,V> pp = p.parent;
if (s == pr) { // p was s's direct parent
p.parent = s;
s.right = p;
}
else {
TreeNode<K,V> sp = s.parent;
if ((p.parent = sp) != null) {
if (s == sp.left)
sp.left = p;
else
sp.right = p;
}
if ((s.right = pr) != null)
pr.parent = s;
}
p.left = null;
if ((p.right = sr) != null)
sr.parent = p;
if ((s.left = pl) != null)
pl.parent = s;
if ((s.parent = pp) == null)
root = s;
else if (p == pp.left)
pp.left = s;
else
pp.right = s;
if (sr != null)
replacement = sr;
else
replacement = p;
}
else if (pl != null)
replacement = pl;
else if (pr != null)
replacement = pr;
else
replacement = p;
if (replacement != p) {
TreeNode<K,V> pp = replacement.parent = p.parent;
if (pp == null)
root = replacement;
else if (p == pp.left)
pp.left = replacement;
else
pp.right = replacement;
p.left = p.right = p.parent = null;
}
TreeNode<K,V> r = p.red? root : balanceDeletion(root, replacement);
if (replacement == p) { // detach
TreeNode<K,V> pp = p.parent;
p.parent = null;
if (pp != null) {
if (p == pp.left)
pp.left = null;
else
pp.right = null;
}
}
if (movable)
moveRootToFront(tab, r);
}
- 插入操作:
putTreeVal方法会先比较哈希值和键的大小,找到合适的插入位置,然后创建一个新的树节点并插入到红黑树中,最后进行平衡操作,以保证红黑树的性质。 - 删除操作:
removeTreeNode方法会先调整树节点的引用关系,移除要删除的节点,然后进行平衡操作。如果红黑树的节点数量较少(节点数小于 6),会将红黑树转换为链表。
9.5 扩容机制对哈希冲突的影响
HashMap 有扩容机制,当哈希表中的元素数量达到阈值(容量乘以加载因子)时,会进行扩容操作。扩容操作会创建一个新的哈希表,其容量是原来的两倍,然后将原来哈希表中的元素重新哈希到新的哈希表中。扩容操作可以减少哈希冲突的概率,因为哈希表的容量增大后,元素分布会更加均匀。以下是 resize 方法的部分源码及注释:
// 扩容方法
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null)? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
if (oldCap > 0) {
// 如果旧容量已经达到最大容量,不再进行扩容
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
// 否则,将新容量扩大为旧容量的两倍
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
if (oldTab != null) {
// 遍历旧哈希表
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
if (e.next == null)
// 如果只有一个节点,直接重新哈希到新哈希表中
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
// 如果是树节点,进行树节点的拆分操作
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // preserve order
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
// 根据哈希值的某一位是否为 0 进行分组
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
// 将分组后的节点分别放入新哈希表的不同位置
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
在 resize 方法中,会根据旧容量和阈值计算新容量和新阈值,然后创建一个新的哈希表。接着遍历旧哈希表,将元素重新哈希到新的哈希表中。对于链表节点,会根据哈希值的某一位是否为 0 进行分组,分别放入新哈希表的不同位置;对于树节点,会进行树节点的拆分操作。
十、线程安全性问题
10.1 非线程安全的原因
HashSet 是基于 HashMap 实现的,而 HashMap 是非线程安全的。在多线程环境下,如果多个线程同时对 HashSet 进行读写操作,可能会导致数据不一致、死循环等问题。例如,当一个线程正在对 HashSet 进行扩容操作时,另一个线程同时进行插入操作,可能会导致链表形成环形结构,从而导致死循环。以下是一个简单的示例代码,展示了多线程环境下使用 HashSet 可能出现的问题:
import java.util.HashSet;
import java.util.Set;
public class HashSetThreadSafetyExample {
private static Set<Integer> set = new HashSet<>();
public static void main(String[] args) {
// 创建两个线程
Thread thread1 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
set.add(i);
}
});
Thread thread2 = new Thread(() -> {
for (int i = 1000; i < 2000; i++) {
set.add(i);
}
});
// 启动线程
thread1.start();
thread2.start();
try {
// 等待线程执行完毕
thread1.join();
thread2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
// 打印集合大小
System.out.println("Set size: " + set.size());
}
}
在上述代码中,创建了两个线程,分别向 HashSet 中添加元素。由于 HashSet 是非线程安全的,在多线程环境下可能会出现数据丢失、死循环等问题。
10.2 线程安全的替代方案
如果需要在多线程环境下使用类似 HashSet 的功能,可以使用以下几种线程安全的替代方案:
- Collections.synchronizedSet:可以使用
Collections.synchronizedSet方法将一个非线程安全的Set转换为线程安全的Set。以下是示例代码:
import java.util.Collections;
import java.util.HashSet;
import java.util.Set;
public class SynchronizedSetExample {
private static Set<Integer> set = Collections.synchronizedSet(new HashSet<>());
public static void main(String[] args) {
// 创建两个线程
Thread thread1 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
set.add(i);
}
});
Thread thread2 = new Thread(() -> {
for (int i = 1000; i < 2000; i++) {
set.add(i);
}
});
// 启动线程
thread1.start();
thread2.start();
try {
// 等待线程执行完毕
thread1.join();
thread2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
// 打印集合大小
System.out.println("Set size: " + set.size());
}
}
Collections.synchronizedSet 方法返回的 Set 是线程安全的,它通过在每个方法上使用同步锁来保证线程安全。
- ConcurrentSkipListSet:
ConcurrentSkipListSet是 Java 并发包中的一个线程安全的Set实现,它基于跳表(Skip List)数据结构。跳表是一种随机化的数据结构,它可以在 的时间复杂度内完成插入、删除和查找操作。以下是示例代码:
import java.util.concurrent.ConcurrentSkipListSet;
public class ConcurrentSkipListSetExample {
private static ConcurrentSkipListSet<Integer> set = new ConcurrentSkipListSet<>();
public static void main(String[] args) {
// 创建两个线程
Thread thread1 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
set.add(i);
}
});
Thread thread2 = new Thread(() -> {
for (int i = 1000; i < 2000; i++) {
set.add(i);
}
});
// 启动线程
thread1.start();
thread2.start();
try {
// 等待线程执行完毕
thread1.join();
thread2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
// 打印集合大小
System.out.println("Set size: " + set.size());
}
}
ConcurrentSkipListSet 适用于需要在多线程环境下进行有序存储和操作的场景。
十一、HashSet 在不同 JDK 版本中的变化
11.1 JDK 7 与 JDK 8 的差异
-
数据结构:
- JDK 7:
HashMap(HashSet基于HashMap)在处理哈希冲突时,使用链表来存储冲突的元素。当发生哈希冲突时,新元素会插入到链表的头部。 - JDK 8:为了提高在哈希冲突严重时的性能,
HashMap在链表长度达到一定阈值(默认是 8)且哈希表的长度达到 64 时,会将链表转换为红黑树。红黑树是一种自平衡的二叉搜索树,它可以保证在最坏情况下,查找、插入和删除操作的时间复杂度为 。
- JDK 7:
-
扩容机制:
- JDK 7:在扩容时,会重新计算每个元素的哈希值,并将其插入到新的哈希表中。这个过程需要遍历链表,效率较低。
- JDK 8:在扩容时,采用了更高效的方式。根据元素哈希值的某一位是否为 0,将链表中的元素分为两组,分别放入新哈希表的不同位置,避免了重新计算哈希值的过程,提高了扩容的效率。
以下是 JDK 7 和 JDK 8 中 HashMap 扩容机制的简单对比代码示例(简化版):
JDK 7 扩容代码示例(简化):
// JDK 7 中 HashMap 的扩容方法(简化)
void resize(int newCapacity) {
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}
Entry[] newTable = new Entry[newCapacity];
transfer(newTable);
table = newTable;
threshold = (int)(newCapacity * loadFactor);
}
// 转移元素到新的哈希表
void transfer(Entry[] newTable) {
Entry[] src = table;
int newCapacity = newTable.length;
for (int j = 0; j < src.length; j++) {
Entry<K,V> e = src[j];
if (e != null) {
src[j] = null;
do {
Entry<K,V> next = e.next;
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
} while (e != null);
}
}
}
JDK 8 扩容代码示例(简化):
// JDK 8 中 HashMap 的扩容方法(简化)
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null)? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
if (oldCap > 0) {
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1;
}
else if (oldThr > 0)
newCap = oldThr;
else {
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else {
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
11.2 后续 JDK 版本的优化
在 JDK 8 之后的版本中,HashSet (基于 HashMap)在性能和功能上也有一些优化和改进:
- 内存管理优化:后续版本对
HashMap的内存使用进行了优化,减少了内存开销。例如,对红黑树节点的内存布局进行了调整,提高了内存利用率。 - 并发性能优化:虽然
HashSet本身是非线程安全的,但 Java 并发包中的相关数据结构在后续版本中进行了性能优化,以更好地支持多线程环境下的操作。
十二、总结与展望
12.1 总结
通过对 Java HashSet 的深入分析,我们全面了解了其使用原理和内部机制:
- 核心结构:
HashSet基于HashMap实现,利用HashMap的键来存储元素,所有键对应的值都是一个静态常量对象PRESENT。 - 基本操作:
HashSet的添加、删除、查找等基本操作都是通过调用HashMap的相应方法实现的,平均时间复杂度为 ,但在哈希冲突严重时性能可能会受到影响。 - 哈希冲突处理:
HashMap使用链地址法处理哈希冲突,当链表长度达到一定阈值时,会将链表转换为红黑树,以提高性能。 - 线程安全性:
HashSet是非线程安全的,在多线程环境下使用可能会出现数据不一致等问题,可以使用Collections.synchronizedSet或ConcurrentSkipListSet等线程安全的替代方案。 - 版本变化:不同 JDK 版本中,
HashSet(基于HashMap)在数据结构、扩容机制等方面有一些变化和优化,以提高性能和内存利用率。
12.2 展望
未来,HashSet 可能会在以下方面得到进一步的发展和改进:
- 性能提升:随着计算机硬件和算法的不断发展,可能会有更高效的哈希函数和哈希冲突解决机制被应用到
HashSet中,进一步提高其性能。 - 并发支持增强:在多线程和分布式环境下,对集合类的并发性能要求越来越高。未来可能会有更高效的并发
HashSet实现,以满足大规模并发场景的需求。 - 功能扩展:可能会为
HashSet增加更多的功能,例如支持更复杂的元素比较和排序规则,或者与其他数据结构进行更紧密的集成。
总之,HashSet 作为 Java 集合框架中的重要组成部分,在实际开发中有着广泛的应用。通过深入理解其原理和机制,开发者可以更好地使用 HashSet,并根据具体场景选择合适的集合实现,提高代码的性能和可维护性。
以上内容详细分析了 Java HashSet 的使用原理,从多个方面进行了深入探讨,希望能帮助你更全面地理解 HashSet。后续可以根据具体需求,进一步细化和扩展相关内容,以满足更深入的学习和研究需求。