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

简介: 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成为了一种更加高效和稳定的数据结构。

相关文章
|
14天前
|
设计模式 安全 Java
Java编程中的单例模式:理解与实践
【10月更文挑战第31天】在Java的世界里,单例模式是一种优雅的解决方案,它确保一个类只有一个实例,并提供一个全局访问点。本文将深入探讨单例模式的实现方式、使用场景及其优缺点,同时提供代码示例以加深理解。无论你是Java新手还是有经验的开发者,掌握单例模式都将是你技能库中的宝贵财富。
19 2
|
7天前
|
XML Java 数据库连接
性能提升秘籍:如何高效使用Java连接池管理数据库连接
在Java应用中,数据库连接管理至关重要。随着访问量增加,频繁创建和关闭连接会影响性能。为此,Java连接池技术应运而生,如HikariCP。本文通过代码示例介绍如何引入HikariCP依赖、配置连接池参数及使用连接池高效管理数据库连接,提升系统性能。
30 5
|
9天前
|
JSON Java Apache
非常实用的Http应用框架,杜绝Java Http 接口对接繁琐编程
UniHttp 是一个声明式的 HTTP 接口对接框架,帮助开发者快速对接第三方 HTTP 接口。通过 @HttpApi 注解定义接口,使用 @GetHttpInterface 和 @PostHttpInterface 等注解配置请求方法和参数。支持自定义代理逻辑、全局请求参数、错误处理和连接池配置,提高代码的内聚性和可读性。
|
16天前
|
Java API Apache
Java编程如何读取Word文档里的Excel表格,并在保存文本内容时保留表格的样式?
【10月更文挑战第29天】Java编程如何读取Word文档里的Excel表格,并在保存文本内容时保留表格的样式?
71 5
|
11天前
|
安全 Java 编译器
JDK 10中的局部变量类型推断:Java编程的简化与革新
JDK 10引入的局部变量类型推断通过`var`关键字简化了代码编写,提高了可读性。编译器根据初始化表达式自动推断变量类型,减少了冗长的类型声明。虽然带来了诸多优点,但也有一些限制,如只能用于局部变量声明,并需立即初始化。这一特性使Java更接近动态类型语言,增强了灵活性和易用性。
93 53
|
10天前
|
存储 安全 Java
Java多线程编程的艺术:从基础到实践####
本文深入探讨了Java多线程编程的核心概念、应用场景及其实现方式,旨在帮助开发者理解并掌握多线程编程的基本技能。文章首先概述了多线程的重要性和常见挑战,随后详细介绍了Java中创建和管理线程的两种主要方式:继承Thread类与实现Runnable接口。通过实例代码,本文展示了如何正确启动、运行及同步线程,以及如何处理线程间的通信与协作问题。最后,文章总结了多线程编程的最佳实践,为读者在实际项目中应用多线程技术提供了宝贵的参考。 ####
|
7天前
|
监控 安全 Java
Java中的多线程编程:从入门到实践####
本文将深入浅出地探讨Java多线程编程的核心概念、应用场景及实践技巧。不同于传统的摘要形式,本文将以一个简短的代码示例作为开篇,直接展示多线程的魅力,随后再详细解析其背后的原理与实现方式,旨在帮助读者快速理解并掌握Java多线程编程的基本技能。 ```java // 简单的多线程示例:创建两个线程,分别打印不同的消息 public class SimpleMultithreading { public static void main(String[] args) { Thread thread1 = new Thread(() -> System.out.prin
|
9天前
|
存储 缓存 安全
在 Java 编程中,创建临时文件用于存储临时数据或进行临时操作非常常见
在 Java 编程中,创建临时文件用于存储临时数据或进行临时操作非常常见。本文介绍了使用 `File.createTempFile` 方法和自定义创建临时文件的两种方式,详细探讨了它们的使用场景和注意事项,包括数据缓存、文件上传下载和日志记录等。强调了清理临时文件、确保文件名唯一性和合理设置文件权限的重要性。
24 2
|
10天前
|
Java UED
Java中的多线程编程基础与实践
【10月更文挑战第35天】在Java的世界中,多线程是提升应用性能和响应性的利器。本文将深入浅出地介绍如何在Java中创建和管理线程,以及如何利用同步机制确保数据一致性。我们将从简单的“Hello, World!”线程示例出发,逐步探索线程池的高效使用,并讨论常见的多线程问题。无论你是Java新手还是希望深化理解,这篇文章都将为你打开多线程的大门。
|
11天前
|
安全 Java 编译器
Java多线程编程的陷阱与最佳实践####
【10月更文挑战第29天】 本文深入探讨了Java多线程编程中的常见陷阱,如竞态条件、死锁、内存一致性错误等,并通过实例分析揭示了这些陷阱的成因。同时,文章也分享了一系列最佳实践,包括使用volatile关键字、原子类、线程安全集合以及并发框架(如java.util.concurrent包下的工具类),帮助开发者有效避免多线程编程中的问题,提升应用的稳定性和性能。 ####
38 1