【Java源码分析】HashMap和HashSet源码分析 (JDK 17)

113 阅读5分钟

代码基于 JDK 17。

HashMap

HashMap 的特性

  1. 添加和查找值的效率很高,都是 O(1)O(1),根据 hash 值可以快速定位
  2. 键值对没有顺序(因为 hash 值是随机的)
  3. 允许键或者值是null线程不安全(如果需要线程安全,可以使用 Hashtable,但是 Hashtable 的键值不可以是 null)

Map 接口

int size();  // Map 中键值对的个数
boolean isEmpty(); // 是否为空
boolean containsKey(Object key); // 是否包含某个键
boolean containsValue(Object value); // 是否包含某个值
V get(Object key); // 根据键获取值,如果没有找到,返回 null
V put(K key, V value); // 保存键值对,如果 key 已经存在,覆盖原来的值,并把这个值返回
V remove(Object key); // 根据键删除键值对,返回 key 原来的值,如果不存在,返回 null
void putAll(Map<? extends K, ? extends V> m); // 保存 m 中所有键值对到当前 map
void clear(); // 清空 Map 中的键值对

Set<K> keySet(); // 获取 Map 中 键的集合
Collection<V> values(); // 获取 map 中所有值的集合
Set<Map.Entry<K, V>> entrySet(); // 获取 map 中所有键值对

interface Entry<K, V> { // 表示键值对的接口
    K getKey(); // 键值对的键
    V getValue(); // 键值对的值
    V setValue(V value);
    boolean equals(Object o);
    int hashCode();
}

Java 8 增加了方法,getOrDefaultforEachreplaceAllputIfAbsentremove(Object key, Object value)replacecomputeIfAbsentcomputeIfPresentcomputemerge等。Java 9 增加了多个重载的 of 方法,Java 10 增加了一个 copyOf 方法,可以根据一个或多个键值对构建不变的 Map。

注:keySet()values()entrySet() 返回的都是视图,因此在返回值上修改会修改 Map 本身。

实现原理

HashMap是 Map 的实现类,因此也沿用了 Map 的特性。有的概念,一个键映射到一个值,按照键存储和访问值键不能重复,如果给同一个键重复设置值,会覆盖键原来映射的值。

HashMap 使用了 Hash(哈希)。使用的是链地址法解决哈希冲突问题(哈希冲突的解决方式见附录)。Java 8 对 HashMap 进行了优化,在哈希冲突比较严重的情况下 ,即大量元素映射到同一个链表(链表中至少8个元素),将该链表转化为红黑树

一、HashMap 内部组成

transient Node<K,V>[] table;
transient int size;
int threshold;
final float loadFactor;
transient int modCount;

table 是一个 Node(一个内部类,如下所示,JDK 7以前是 Entry类型)类型的数组,称为哈希表或者哈希桶,是实际用来存放键值对的地方。table 中每个元素指向一个单向链表(或者),每个节点表示一个键值对。 image.png

keyvalue 分别表示键和值,next 表示下一个节点,hashkey 的 hash 值。

size 是 Map 中实际存储的键值对的个数。threshold 指扩容的阈值,根据负载因子 loadFactor 计算。

modCount 是用来记录结构化的修改的次数(如键值对数量变化),方便在遍历 Map 时(迭代)检测是否有结构性变化。

二、初始化和扩容

public HashMap(int initialCapacity, float loadFactor) 

初始化 HashMap 时,会对 loadFactorinitalCapacity 进行赋值,如果没有指定值,就会使用默认值。initialCapacity 指初始容量,默认为16,最大为 2302^{30}loadFactor 是负载系数,默认为0.75。 在添加第一个元素时,才会 对 Map 分配空间

三、put() 保存键值对(添加 / 修改)

基本步骤如下

  1. 计算键的哈希值
  2. 根据哈希值得到保存的位置( (table.length1) & hash(table.length - 1)\space\&\space hash
  3. 查到对应位置的链表尾或者更新已有值
  4. 如果需要,扩展 table 大小

image.png

1. 计算 key 的 hash 值

image.png

如果 key 为 null,返回 0,否则对 key 的 hashCode 进行高低位异或运算并返回

2. 添加 / 修改键值对

image.png

判断键是否存在。先比较 hash 值,hash 值相同时,再用 equals 方法进行比较。(因为 hash 是整数,性能一般比 equals 高。如果 hash 不同,就无需 equals 了。)

p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))

四、get() 查找方法

image.png image.png

调用 getNode() 获取对应节点,如果不存在,直接返回 null,存在的话,获取值。

containsKey() 逻辑类似

image.png

containsValue() 遍历table中的链表的每个节点 image.png

五、remove() 删除键值对

image.png 逻辑和 get() 类似,

  1. 计算键的哈希值
  2. 根据哈希值得到保存的位置( (table.length1) & hash(table.length - 1)\space\&\space hash
  3. 如果查找到该点
    1. 如果是树节点,直接在红黑树中删除该节点
    2. 如果是待删节点是头节点,把头节点变成 next
    3. 如果是链表中的点,那么跳过这个点(删除该节点) image.png

HashSet

HashSet 是 Set 的实现类,因此具有 Set 的特性。Set 是用来存储没有重复的元素。如果使用HashSet存储自定义类,需要重写 hashCode 和 equals 方法。

HashSet 的应用场景

  1. 需要对元素进行排重,且对元素没有顺序要求
  2. 保存特殊值
  3. 集合运算

Set 接口

扩展了 Collection。Java 9 增加了多个重载的 of 方法,Java 10 增加了一个 copyOf 方法,可以根据一个或多个键值对构建不变的 Set。

实现原理

private transient HashMap<E,Object> map;
private static final Object PRESENT = new Object();

HashSet 内部实际是 HashMap。HashSet中的值相当于键,值都是相同的固定值 PRESENT

添加,就是调用put方法,如果map中不含这个键,则 put 返回 null。

public boolean add(E e) {
    return map.put(e, PRESENT)==null;
}

查找是否包含元素

public boolean contains(Object o) {
    return map.containsKey(o);
}

删除,返回值为 PRESENT 表示原来有对应的键,并且删除成功。

public boolean remove(Object o) {
    return map.remove(o)==PRESENT;
}

迭代器,就是返回 map 的 keySet() 迭代器。

public Iterator<E> iterator() {
    return map.keySet().iterator();
}

附录

哈希冲突

解决哈希冲突的几种方法:

  1. 开放定址法 如果发生哈希冲突,寻找一个新的空闲位置放置元素

    1. 线性探测法 如果发生冲突,就往后面依次寻找,直到找到空闲位置放置元素
    2. 平方探测法 如果发生冲突,就往前后面分别寻找
  2. 再哈希法 同时构造多个哈希函数,如果发生哈希冲突,就是用其他哈希函数计算地址

  3. 链地址法 将所有哈希地址相同的元素都放在同一个链表中