HashMap

4 阅读1分钟

总结对比表

特性

JDK 1.7

JDK 1.8

变化原因/优势

数据结构

数组 + 链表

数组 + 链表 + 红黑树

解决哈希冲突严重时查询慢的问题 (O(n) -> O(log n))

插入方式

头插法

尾插法

解决多线程扩容死循环问题

哈希扰动

9 次移位异或

1 次 移位异或

简化计算,提升性能

扩容逻辑

重新计算 Hash 并迁移

复用 Hash,仅判断高位

提升扩容速度 (O(n) -> 更快)

初始化时机

构造方法的 “伪初始化“,仅被赋值为一个空数组

懒加载 (第一次 put 时初始化)

节省内存,避免无用的数组分配

链表转树

有 (长度>8 且 容量>64)

平衡时间与空间复杂度

线程安全

不安全 (死循环 + 数据覆盖)

不安全 (仅修复死循环,仍会数据覆盖)

仍需使用 ConcurrentHashMap 处理并发

链表节点名      Entry<K,V>                  Node<K,V> (链表) / TreeNode<K,V> (树)

1. 底层数据结构(最核心的区别)

  • JDK 1.7:仅由数组 + 单链表组成。当发生哈希冲突时,所有冲突的元素都挂在同一个链表上。

    • 缺陷

      :当链表长度过长(例如极端情况下达到 1000),查询效率会从 O(1) 退化为 O(n)。

  • JDK 1.8:引入了红黑树优化。

    • 链表长度 >= 8数组长度 >= 64 时,将链表转换为红黑树。

    • 当红黑树节点数量 <= 6 时,退化为链表。

    • 优势

      :树化后,查询、插入、删除的时间复杂度稳定在 O(logn),解决了哈希碰撞导致的性能问题。

2. 链表插入方式(导致死循环的根源)

  • JDK 1.7 - 头插法 (Head Insert)

    • 新节点会插入到链表的头部。

    • 目的

      :设计者认为新插入的元素被查询的概率更高。

    • 致命缺陷

      :在多线程并发扩容时,会导致链表形成,进而引发死循环(Infinite Loop)。

  • JDK 1.8 - 尾插法 (Tail Insert)

    • 新节点插入到链表的尾部。

    • 优势

      :扩容时保持了链表元素的原始顺序,解决了多线程扩容死循环的问题(但 HashMap 依然不是线程安全的,并发修改仍可能导致数据丢失)。

3. 扩容机制的优化(rehash 过程)

数组扩容时,需要重新计算元素在新数组中的下标(rehash)。

  • JDK 1.7:所有元素都必须重新计算哈希值,再进行取模运算,过程较为繁重。

  • JDK 1.8:利用扩容是原容量 2 倍的特性,做了位运算优化。

    • 新下标 = 原下标原下标 + 原容量
    • 只需判断哈希值的某一个特定二进制位是 0 还是 1,即可确定新位置,无需重新计算哈希,效率大幅提升。

4. 哈希扰动函数(hash 算法)

为了减少哈希冲突,需要将 hashCode() 的高 16 位和低 16 位混合(扰动)。

  • JDK 1.7hash() 方法做了 4 次 位运算和 5 次 异或运算,扰动剧烈。

  • JDK 1.8:简化为 1 次 异或运算((h = key.hashCode()) ^ (h >>> 16))。

    • 原因

      :引入红黑树后,即使发生冲突,性能也有保障,因此不再需要过度的扰动,以换取计算速度。

5. 初始化时机(细节补充)

  • JDK 1.7:在第一次调用 put() 时,执行 inflateTable() 初始化数组。

  • JDK 1.8:在第一次调用 putVal() 时初始化数组。逻辑更内聚,直接在核心方法中处理。

关键源码对比(伪代码)

1. 节点结构

JDK 1.7 (Entry)

java

static class Entry<K,V> implements Map.Entry<K,V> {
    final K key;
    V value;
    Entry<K,V> next; // 单链表指针
    int hash;
    // ...
}

JDK 1.8 (Node & TreeNode)

java

// 链表节点
static class Node<K,V> implements Map.Entry<K,V> { /* ... */ }

// 树节点,继承自 LinkedHashMap.Entry
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
    TreeNode<K,V> parent; // 父节点
    TreeNode<K,V> left;   // 左子树
    TreeNode<K,V> right;  // 右子树
    TreeNode<K,V> prev;   // 前驱节点
    boolean red;          // 红黑树颜色标记
    // ...
}

2. 插入逻辑

JDK 1.7

java

// 头插法,newEntry.next = e;
void addEntry(int hash, K key, V value, int bucketIndex) {
    Entry<K,V> e = table[bucketIndex];
    table[bucketIndex] = new Entry<>(hash, key, value, e); // 新节点指向旧头
    // ...
}

JDK 1.8

java

// 尾插法
if ((p = tab[i = (n - 1) & hash]) == null)
    tab[i] = newNode(hash, key, value, null);
else {
    // ... 
    if (e == null) {
        p.next = newNode(hash, key, value, null); // 插入尾部
        // 检查是否需要树化
        if (++binCount >= TREEIFY_THRESHOLD - 1) 
            treeifyBin(tab, hash);
    }
    // ...
}

总结与最佳实践

  1. 为什么 JDK 1.8 性能更好? 红黑树解决了最坏情况的性能问题,尾插法避免了扩容死循环,简化的 hash 算法提升了计算速度。
  2. 线程安全问题:JDK 1.8 虽然修复了死循环,但依然线程不安全(可能出现数据覆盖)。并发场景请使用 ConcurrentHashMap
  3. 开发建议:在使用 HashMap 时,尽量指定初始容量。特别是当你知道存储元素的大致数量时(初始容量 = 预计元素数 / 0.75 + 1),可以避免频繁扩容带来的性能损耗。

疑问与解答:

HashMap存储key、value是否可以为null的问题,以及与Hashtable、LinkedHashMap的区别?

答:HashMap 存储的 key 和 value 都可以为 null,但有明确约束(JDK 1.7 和 JDK 1.8 规则一致,无版本差异)

1. Value 可以为 null(无数量限制)

规则:value 允许为 null,且可以有多个 key 对应 null 值(只要这些 key 不重复)。

2. Key 可以为 null(仅允许 1 个)

  • 规则:key 允许为 null,但全局仅能存在 1 个 null 键(HashMap 会将 null 键的哈希值视为 0,存入数组下标为 0 的位置,重复 put null 键会覆盖原有值)。

  • . 补充注意事项(避坑重点)

    1. 与 Hashtable 区别:Hashtable 是线程安全的,但禁止 key 和 value 为 null(会抛出 NullPointerException),这是 HashMap 与 Hashtable 的核心区别之一。
    2. 查询 null 键 / 值:可以直接通过 map.get(null) 获取 null 键对应的值(若未存 null 键,返回 null);无法通过 get(null) 区分 “不存在 null 键” 和 “null 键对应的值为 null”,需用 map.containsKey(null) 判断。
    3. 遍历影响:遍历 HashMap 时,null 键和 null 值会正常被遍历到(无过滤),需注意判空避免空指针异常。

    简单总结:HashMap 对 null 友好,key 可存 1 个 null,value 可存多个 null;Hashtable 完全禁止 null,开发中需根据场景区分使用。