【Java编程进阶之路 02】深入探索:红黑树如何重塑哈希表的性能边界

举报
浅夏的猫 发表于 2024/02/28 08:17:26 2024/02/28
【摘要】 JDK 1.8之后,HashMap引入红黑树来优化性能,当链表长度超过阈值(默认为8)时,链表会转换为红黑树,从而提高高冲突时的查询效率。同时,HashMap也采用了扰动函数来增加哈希值的随机性,使键值对更均匀分布,提升性能。

导言

Java中的HashMap是一种非常常用的数据结构,它以键-值对的形式存储数据,并能快速地进行数据的查找、插入和删除操作。在JDK1.8以后,HashMap的内部结构发生了一些重要的变化,其中最显著的变化是引入了红黑树来处理哈希冲突,以提高查询性能。本文将详细描述这些变化,并提供相关的源码片段进行解析。

01 HashMap的基本结构

在了解JDK1.8以后HashMap的变化之前,HashMap采用数组+链表的数据结构,其中数组是HashMap的主体,每个数组元素都是一个桶(bucket),而链表则主要用来解决哈希冲突。当发生哈希冲突时,具有相同哈希值的元素会存储在同一个链表中。

HashMap的基本结构可以分点描述如下:

1.1 数组

  • HashMap的主体是一个数组,数组中的每个元素被称为桶(bucket)。
  • 数组的大小(容量)决定了HashMap的容量,即能够存储的键值对的数量上限。
  • 数组的索引位置是通过哈希算法计算得出的,确保键值对能够均匀分布在数组中。

1.2 链表/红黑树

  • 当两个不同的键经过哈希算法计算后得到相同的数组索引时,会发生哈希冲突。
  • 为了解决哈希冲突,HashMap将具有相同索引的键值对以链表的形式存储在同一个桶中。
  • 在JDK 1.8及以后的版本中,当链表长度超过一定阈值(默认为8)时,链表会转换为红黑树。
  • 红黑树是一种自平衡的二叉查找树,它能够在插入、删除和查找操作中保持较低的时间复杂度。
  • 当红黑树中的节点数少于一定数量(默认为6)时,红黑树会退化为链表。

1.3 哈希算法

  • HashMap使用哈希算法来计算键的存储位置。
  • 哈希算法将键的hashCode值映射到数组的索引上,确保键值对能够均匀分布在数组中。
  • 为了提高哈希分布的均匀性和减少哈希冲突,HashMap在计算索引时还会对hashCode值进行扰动处理。

1.4 扩容机制

  • 当HashMap中的元素数量超过数组的容量乘以加载因子时,HashMap会进行扩容。
  • 扩容时,HashMap会创建一个新的数组,并将原数组中的元素重新计算索引后放入新数组中。
  • 扩容机制确保了HashMap能够在需要时动态调整其容量,以保持良好的性能。

综上所述,HashMap通过结合数组、链表和红黑树的数据结构,以及哈希算法和扩容机制,实现了高效的键值对存储和查找操作。

02 JDK1.8以后的变化

2.1 引入红黑树处理哈希冲突

在JDK 8及以后的版本中,HashMap在处理哈希冲突时引入了红黑树的数据结构。这种改变主要是为了优化在哈希冲突严重时HashMap的性能。

1. 哈希冲突与链表

在早期的HashMap实现中,当发生哈希冲突时,即将不同的键计算出的哈希值相同时,这些键值对会以链表的形式存储在同一个桶(bucket)中。然而,当哈希冲突变得非常严重时,链表会变得很长,导致在查找、插入和删除操作时的性能下降。具体来说,链表的查找操作需要遍历整个链表,时间复杂度为O(n),其中n是链表的长度。

2. 引入红黑树

为了解决这个问题,JDK 8对HashMap进行了改进。当链表长度达到一定阈值(默认为8)时,链表会转换为红黑树。红黑树是一种自平衡的二叉查找树,它的查找、插入和删除操作的时间复杂度为O(log n),其中n是树的节点数。与链表相比,红黑树在性能上更有优势。

3. 红黑树的优势

红黑树作为一种自平衡的二叉查找树,具有以下优势:

  1. 查找效率高:红黑树的查找时间复杂度为O(log n),远低于链表的O(n)。
  2. 插入和删除性能良好:红黑树在插入和删除节点时能够保持树的平衡,避免了链表过长导致的性能下降问题。
  3. 空间利用率高:与完全平衡的二叉树(如AVL树)相比,红黑树在插入和删除时的旋转次数较少,因此空间利用率更高。

4. 阈值的选择

在JDK 8中,链表转换为红黑树的阈值默认为8。这个值是通过权衡性能和空间开销来选择的。当链表长度超过8时,转换为红黑树可以提高查找性能;而当链表长度较短时,由于红黑树的维护成本相对较高,因此保持链表结构更为合适。

2.2 优化哈希算法

在 JDK 8 的 HashMap 实现中,引入了扰动函数(perturbation function)来优化哈希算法。扰动函数的主要目的是增加哈希值的随机性,使得键值对能够更均匀地分布在哈希表中,从而减少哈希冲突和提高查询效率。

具体来说,HashMap 中的扰动函数实现如下:

static final int hash(Object key) {  
    int h;  
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);  
}

这里,key.hashCode() 是调用键对象的 hashCode() 方法来获取其哈希值。然后,这个哈希值会经过一个额外的步骤:与其自身的高 16 位进行异或(XOR)运算。异或运算是一种位运算,对应位上的数字相同则结果为 0,不同则结果为 1。

  1. 增加随机性:通过将哈希值的高 16 位与低 16 位进行异或运算,可以将高位的信息混合到低位中,增加了哈希值的随机性。这有助于减少由于低位相同而高位不同导致的哈希冲突。
  2. 利用全部哈希值:在之前的 HashMap 实现中,由于哈希表的大小通常是 2 的幂次方,因此只使用了哈希值的低位来进行索引计算(通过位运算 (n - 1) & hash)。这样做忽略了哈希值的高位信息。通过扰动函数,HashMap 能够更好地利用整个哈希值的信息。
  3. 性能考虑:异或运算是一种非常快的位运算,引入的额外计算开销很小,几乎可以忽略不计。因此,这种优化是在几乎不增加计算成本的情况下提高了哈希表的性能。

综上所述,通过引入扰动函数,JDK 8 的 HashMap 实现了对哈希值的进一步优化,使得键值对能够更均匀地分布在哈希表中,提高了查询效率和整体性能。

03 源码片段解析

下面将通过源码片段来解析JDK1.8以后HashMap结构的变化。

3.1 Node类定义

在 JDK 8 的 HashMap 实现中,Node 类是一个静态内部类,用于存储哈希表中的键值对。每个 Node 对象都持有一个键、一个值、一个指向下一个节点的引用(用于解决哈希冲突)以及该节点的哈希值。

下面是 HashMapNode 类的简化定义:

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;
    }

    // 实现 Map.Entry 接口的方法
    public final K getKey() {
        return key;
    }

    public final V getValue() {
        return value;
    }

    public final String toString() {
        return key + "=" + value;
    }

    // 设置新值并返回旧值
    public final V setValue(V newValue) {
        V oldValue = value;
        value = newValue;
        return oldValue;
    }

    // 判断两个节点是否相等(基于键和值的 equals 方法)
    public final boolean equals(Object o) {
        // ... 省略了具体的实现细节
    }

    // 返回节点的哈希码(基于键的 hashCode 方法和值的 hashCode 方法)
    public final int hashCode() {
        // ... 省略了具体的实现细节
    }

    // ... 可能还有其他方法或字段,例如用于红黑树节点的 left 和 right 字段等
}

需要注意的是,上面的代码是一个简化的版本,并没有包含所有的方法和字段。在实际的 JDK 8 HashMap 源码中,Node 类还可能会包含其他用于红黑树操作的字段和方法,例如 leftright 指针等。但是,对于基本的哈希表操作来说,上面的定义已经足够说明问题了。

每个 Node 对象都通过 next 引用连接在一起,形成链表,用于解决哈希冲突。当哈希表中的某个索引位置上有多个键值对的哈希值相同时,这些键值对就会以链表的形式存储在该索引位置上。在 JDK 8 中,当链表长度超过一定阈值(默认为 8)时,链表会转换为红黑树,以进一步提高查询效率。但是,Node 类本身并不直接支持红黑树的操作,而是通过继承自 Node 的其他类(如 TreeNode)来实现的。

3.2 TreeNode类定义

在 JDK 8 的 HashMap 实现中,TreeNode 类是 Node 类的子类,用于实现红黑树结构以优化哈希表在高度冲突情况下的性能。当某个索引位置的链表长度超过一定阈值(默认为 8)并且哈希表的大小大于或等于 64 时,链表就会转换为红黑树,此时节点类型会从 Node 变为 TreeNode

TreeNode 类除了包含 Node 类中的所有字段外,还添加了用于维护红黑树结构的额外字段和方法。下面是 TreeNode 类的一个简化定义:

static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
    TreeNode<K,V> left;    // 指向左子节点的引用
    TreeNode<K,V> right;   // 指向右子节点的引用
    TreeNode<K,V> parent;  // 指向父节点的引用
    boolean red;           // 颜色标志,用于红黑树的平衡操作

    TreeNode(int hash, K key, V value, Node<K,V> next) {
        super(hash, key, value, next);
    }

    // 返回当前节点是红色还是黑色
    final boolean isRed() {
        return red;
    }

    // ... 其他用于维护红黑树结构和性能的方法,如旋转、重新着色等
}

需要注意的是,上面的代码是一个简化的版本,并没有包含所有的方法和字段。在实际的 JDK 8 HashMap 源码中,TreeNode 类会包含更多的方法和字段,以支持红黑树的各种操作。

然而,有一个重要的更正需要指出:在 JDK 8 的实际 HashMap 实现中,并没有一个名为 TreeNode 的公共类。相反,红黑树节点是通过 Node 类本身实现的,Node 类中包含了一些额外的字段来支持红黑树的操作。这些字段包括 leftright 和一个用于表示颜色的字段(但在实际的 JDK 代码中并没有直接命名为 red,而是通过位运算在哈希值中存储颜色信息)。

这里是一个更接近 JDK 8 源码的 Node 类定义,其中包含了红黑树相关的字段:

static final class Node<K,V> implements Map.Entry<K,V> {
    final int hash;
    final K key;
    V value;
    Node<K,V> next;
    Node<K,V> left;  // 仅在红黑树中使用
    Node<K,V> right; // 仅在红黑树中使用

    // ... 构造函数、getter、setter 和其他方法

    // 用于判断节点是否是红色(实际上是通过哈希值的一个位来判断)
    final boolean isRed() {
        // 在实际的 JDK 代码中,这里会是一个位运算操作来检查颜色位
        // 例如:return (hash & RED_FLAG) != 0;
        // 但为了简化说明,这里我们假设有一个布尔字段来表示颜色
        // 注意:实际的 JDK 实现中并没有这样的布尔字段
        return false; // 示例代码,实际上需要根据具体的位运算来判断
    }
}

在实际的实现中,HashMap 通过一系列复杂的位运算和条件判断来管理节点的颜色以及红黑树的平衡性。当链表转换为红黑树时,节点的类型仍然是 Node,但会利用额外的字段来构建和维护红黑树结构。

3.3 putVal方法

putVal 是 HashMap 中一个核心的私有方法,用于处理插入或更新键值对的逻辑。这个方法在 putputAll 和其他一些内部方法中都会被调用。以下是 putVal 方法的详细描述,包括其参数、主要逻辑和关键步骤。

(1)putVal 方法签名

final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict)

(2)参数说明

  • hash:键的哈希码,通过 key.hashCode() 获得,并可能经过进一步的处理。
  • key:要插入或更新的键。
  • value:与键相关联的值。
  • onlyIfAbsent:一个布尔值,当为 true 时,如果映射中已经包含键的映射关系,则不执行任何操作。这对应于 putIfAbsent 方法的行为。
  • evict:一个布尔值,当为 true 时,如果映射已超出最大容量并且需要移除旧元素以容纳新元素,则执行移除操作。这对应于在插入新元素时可能需要进行的扩容和/或元素移除。

(3)主要逻辑

  1. 检查是否需要扩容:如果当前数组为空或长度为0,则调用 resize 方法进行扩容。

  2. 计算索引:使用哈希码计算键在数组中的索引位置。

  3. 处理哈希冲突

    • 如果计算出的索引位置的桶为空,则直接在该位置创建一个新的节点。
    • 如果桶不为空(即存在哈希冲突),则遍历链表/红黑树:
      • 如果链表/红黑树中已存在该键,则根据 onlyIfAbsent 的值决定是否更新值。
      • 如果不存在,则将新节点添加到链表/红黑树的末尾。
      • 如果链表长度超过了 TREEIFY_THRESHOLD(默认为8),则将链表转换为红黑树。
      • 如果红黑树中的节点数小于 UNTREEIFY_THRESHOLD(默认为6),则将红黑树退化为链表。
  4. 检查是否需要扩容:在插入新元素后,如果HashMap的大小超过了阈值(即容量乘以加载因子),则进行扩容。

  5. 返回插入或更新的旧值:如果键已存在,则 putVal 方法返回旧值;否则返回 null

(4)关键步骤

  • 计算索引:确保键值对能够均匀分布在数组中。
  • 处理哈希冲突:使用链表或红黑树解决哈希冲突,保持查找、插入和删除操作的高效性。
  • 扩容机制:当HashMap达到其容量上限时,通过创建一个更大的数组并重新计算所有元素的索引来扩容。

putVal 方法是 HashMap 中实现高效插入和更新操作的核心。由于 HashMap 是非同步的,因此在多线程环境下使用 putVal 时需要注意线程安全问题。如果需要线程安全的HashMap,可以考虑使用 Collections.synchronizedMap(new HashMap<>())ConcurrentHashMap

3.4 treeifyBin方法

treeifyBinHashMap 中用于将链表转换为红黑树的方法。这个方法在 HashMap 的某个桶(bucket)中的链表长度超过一定阈值(默认为8)时被调用,以提高后续查找、插入和删除操作的效率。

下面是 treeifyBin 方法的详细描述:

(1)treeifyBin 方法签名

final void treeifyBin(Node<K,V>[] tab, int hash) {
    int n, index; Node<K,V> e;
    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);
    }
}

(2)方法逻辑

  1. 检查是否需要扩容:如果当前数组(tab)为空或长度小于 MIN_TREEIFY_CAPACITY(默认为64),则先调用 resize 方法进行扩容。这是为了确保在转换为红黑树之前,HashMap具有足够的容量。

  2. 遍历链表并转换为红黑树

    • 计算索引位置 index
    • 如果该索引位置的节点 e 不为空,说明存在哈希冲突,即链表不为空。
    • 遍历链表,为每个节点创建一个 TreeNode 对象(红黑树的节点),并将这些节点连接起来形成红黑树的初始形态。
    • 使用 replacementTreeNode 方法将链表节点转换为 TreeNode 对象。
    • 通过 prevnext 指针将 TreeNode 对象连接起来,形成双向链表。
    • 最后,将链表的头节点 hd 设置为该索引位置的新节点,并调用 hd.treeify(tab) 来将双向链表转换为红黑树。

(3)treeify 方法

treeifyTreeNode 类中的一个方法,用于将双向链表转换为红黑树。这个方法会进行一系列的旋转和颜色调整操作,以确保树满足红黑树的性质。转换过程包括重新调整节点的颜色、旋转树以及修复任何可能破坏红黑树性质的情况。

(4)注意

  • treeifyBin 方法仅当链表长度超过阈值时才被调用,以确保高效的查找性能。
  • 转换后的红黑树会保持原有的节点顺序,即按照它们在链表中的顺序。
  • 由于 treeifyBin 是在链表长度超过阈值时由 HashMap 自动调用的,因此通常不需要手动调用此方法。

treeifyBin 方法是 HashMap 在内部优化其数据结构以提高性能的一个重要环节,它确保了当哈希冲突较为严重时,仍然能够保持高效的查找、插入和删除操作。

04 总结

在JDK 1.8之后,HashMap的结构发生了重大变化,其中最显著的是引入了红黑树来优化高冲突情况下的性能。在JDK 1.7及之前的版本中,HashMap完全基于链表来解决哈希冲突,当链表过长时会导致查询性能下降。

在JDK 1.8中,HashMap使用了一种称为“链表+红黑树”的混合结构。当链表长度超过一定阈值(默认为8)并且哈希表的大小大于或等于64时,链表会转换为红黑树。红黑树是一种自平衡的二叉搜索树,它保证了树的最坏情况下操作的时间复杂度为O(log n),从而显著提高了在高度冲突时的查询性能。

除了引入红黑树之外,JDK 1.8的HashMap还进行了其他一些优化。其中最重要的是引入了扰动函数(perturbation function),通过异或操作增加了哈希值的随机性,减少了哈希冲突的可能性。这一改变使得键值对能够更均匀地分布在哈希表中,提高了查询效率。

此外,JDK 1.8的HashMap还进行了其他一些细节上的调整,例如使用了更加高效的数组扩容策略、优化了链表转换为红黑树的阈值等。这些改进使得HashMap在保持简单性和通用性的同时,性能得到了显著提升。

综上所述,JDK 1.8之后的HashMap通过引入红黑树和扰动函数等优化措施,显著提高了在高冲突情况下的性能,使得HashMap成为了一种更加高效和稳定的数据结构。

【版权声明】本文为华为云社区用户原创内容,转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息, 否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。