【Java集合篇】接上篇博文--为什么在JDK8中HashMap要转成红黑树

2024-01-06 20:04

本文主要是介绍【Java集合篇】接上篇博文--为什么在JDK8中HashMap要转成红黑树,希望对大家解决编程问题提供一定的参考价值,需要的开发者们随着小编来一起学习吧!

在这里插入图片描述

为什么在JDK8中HashMap要转成红黑树

  • ✔️为什么不继续使用链表
  • ✔️为什么是红黑树
    • ✔️红黑树的性能优势
  • ✔️ 拓展知识仓
    • ✔️为什么是链表长度达到8的时候转
    • ✔️为什么不在冲突的时候立刻转
    • ✔️关于为什么长度为8的时候转(源码注释解读)
    • ✔️为什么长度为6的时候转回来?
    • ✔️双向链表是怎么回事
  • ✔️HashMap的元素没有比较能力,红黑树为什么可以比较?


✔️为什么不继续使用链表


我们知道, HashMap 解决hash冲突是通过拉链法完成的,在JDK8之前,如果产生冲突,就会把新增的元素增加到当前桶所在的链表中。


这样就会产生一个问题,当某个 bucket 冲突过多的时候,其指向的链表就会变得很长,这样如果 put 或者 getbucket 上的元素时,复杂度就无限接近于O(N),这样显然是不可以接受的。


所以在 JDK1.7 的时候,在元素 put 之前做 hash 的时候,就会充分利用扰动函数,将不同 KEYhash 尽可能的分散开。不过这样做起来效果还不是太好,所以当链表过长的时候,我们就要对其数据结构进行修改。


使用 Java 代码进行解释如下:


假设我们有以下 HashMap 的简化实现:


public class HashMap<K, V> {  static class Node<K, V> {  final int hash;  final K key;  V value;  Node<K, V> left;  Node<K, V> right;  Node<K, V> next; // For chaining hash collisions  ...  }  ...  
}

当一个节点(我们称其为 A)的next字段指向另一个节点 B 时,我们称之为链表。而当这个next字段指向另一个节点 C,并且 C 的 next 字段又指向 B 时,我们称之为双向链表。但为了简单起见,我们这里只讨论单向链表。


当查找某个键时,我们从根节点开始,沿着链表查找,直到找到相应的节点或到达链表的末尾。查找的时间复杂度为 O(n),其中 n 是链表的长度。


但当我们需要频繁查找和插入操作时,链表的性能会变得较差。为了提高性能,我们可以考虑使用红黑树。红黑树是一种自平衡的二叉搜索树,它在插入和查找操作中都能保持 O(log n) 的时间复杂度。但是,与链表相比,红黑树需要更多的内存来存储额外的节点和属性。


因此,为了平衡性能和内存使用,HashMap 选择在链表长度超过一定阈值时将链表转换为红黑树。这样可以确保在大多数情况下 HashMap 的性能接近于 O(1),而在极端情况下仍然保持O(log n) 的性能。


✔️为什么是红黑树


当元素过多的时候,用什么来代替链表呢? 我们很自然的就能想到可以用二叉树查找树代替,所谓的二叉查找树,一定是 left < root< right ,这样我们遍历的时间复杂度就会由链表的 O(N) 变为二又查找树的 O(logN) ,二又查找树如下所示:


在这里插入图片描述


但是,对于极端情况,当子节点都比父节点大或者小的时候,二叉查找树又会退化成链表,查询复杂度会重新变为 O(N) ,如下所示:


在这里插入图片描述

所以,我们就需要二叉平衡树出场,他会在每次插入操作时来检查每个节点的左子树和右子树的高度差至多等于1,如果>1,就需要进行左旋或者右旋操作,使其查询复杂度一直维持在 O(logN)


但是这样就万无一失了吗? 其实并不然,我们不仅要保证查询的时间复杂度,还需要保证插入的时间复杂度足够低,因为平衡二叉树要求高度差最多为1,非常严格,导致每次插入都需要左旋或者右旋,极大的消耗了插入的时间。


在这里插入图片描述


对于那些插入和删除比较频的场景, AVL 树显然是不合适的。为了保证查询和插入的时间复杂度维持在一个均衡的水平上,所以就引入了红黑树。


在红黑树中,所有的叶子节点都是黑色的的空节点,也就是叶子节点不存数据,任何相邻的节点都不能同时为红色,红色节点是被黑色节点隔开的,每个节点,从该节点到达其可达的叶子节点的所有路径都包含相同数目的黑色节点。


我们可以得到如下结论: 红黑树不会像 AVL 树一样追求绝对的平衡,它的插入最多两次旋转,删除最多三次旋转,在频繁的插入和删除场景中,红黑树的时间复杂度,是优于 AVL 树的。


在这里插入图片描述


综上所述,这就是HashMap选择红黑树的原因。


✔️红黑树的性能优势

  1. 查找性能:对于长链表,红黑树的查找性能明显优于链表。因为红黑树是一种平衡的二叉搜索树,它的查找性能是 O(log n),而长链表的查找性能是 O(n)。
  2. 空间效率:红黑树虽然需要额外的节点来维护平衡,但其总体空间效率仍然高于链表。

为什么不在一开始就使用红黑树?

  1. 初始化成本:红黑树的初始化成本高于链表。因为红黑树需要维护额外的节点和属性(如颜色),所以初始化的空间和时间成本都较高。
  2. 简单性:链表结构简单,易于理解和实现。
  3. 内存使用:对于较小的数据结构,链表的内存使用可能更有效。

✔️ 拓展知识仓


✔️为什么是链表长度达到8的时候转


这个问题有两层含义,第一个是为什么不在冲突的时候立刻转为红黑树,第二个是为什么是达到8的时候转。


链表长度达到8时转为红黑树的原因主要有以下几点:

1、平衡性:红黑树是一种自平衡的二叉搜索树,它能够在插入和查找操作中保持较好的性能。当链表长度达到8时,链表可能会变得过长,导致查找性能下降。通过将链表转换为红黑树,可以保持较好的平衡性,提高查找性能。


2、内存效率:虽然红黑树需要额外的节点和属性来维护平衡,但其总体空间效率仍然高于链表。当链表长度超过一定阈值时,将其转换为红黑树可以减少内存占用。

3、时间复杂度:红黑树的时间复杂度是 O(log n),比链表的 O(n) 性能更好。当链表长度较长时,转换为红黑树可以显著提高查找、插入和删除操作的性能。


4、简单性:链表结构简单,易于理解和实现。但当链表长度较长时,转换为红黑树可以提供更好的性能和内存效率。


综上所述,当链表长度达到8时转为红黑树是为了实现更好的性能和内存效率。这样可以确保在大多数情况下 HashMap 的性能接近于 O(1),而在极端情况下仍然保持 O(log n) 的性能。


一个简单的Java代码片段,解释一下为什么当链表长度达到8时,HashMap会将其转换为红黑树:


import java.util.HashMap;  
import java.util.Map;  public class HashMapExample {  public static void main(String[] args) {  // 创建一个初始容量为16的HashMap  HashMap<Integer, String> map = new HashMap<>(16);  // 插入一些元素到HashMap中  for (int i = 0; i < 10; i++) {  map.put(i, "Value" + i);  }  // 打印初始链表长度  System.out.println("Initial LinkedList length: " + getLinkedListLength(map));  // 继续插入元素,直到链表长度达到8  for (int i = 10; i < 18; i++) {  map.put(i, "Value" + i);  }  // 打印链表长度达到8后的链表长度  System.out.println("LinkedList length after adding more elements: " + getLinkedListLength(map));  // 打印此时的节点数(红黑树中的节点数)  System.out.println("Number of nodes in the Red-Black Tree: " + getRedBlackTreeNodes(map));  }  /**  * 获取HashMap中链表的长度  */  private static int getLinkedListLength(Map<Integer, String> map) {  int length = 0;  Node<Integer, String> node = map.getNode(0); // 获取第一个节点(根节点)  while (node != null) {  length++;  node = node.next; // 遍历链表直到末尾  }  return length;  }  /**  * 获取HashMap中红黑树的节点数  */  private static int getRedBlackTreeNodes(Map<Integer, String> map) {  // 假设我们查询的键为17,这里只是为了示例。实际情况下,我们需要遍历红黑树来计算节点数。 return map.getNode(17).right == null ? 1 : 2;  }  
}

✔️为什么不在冲突的时候立刻转


原因有2:


从空间维度来讲,因为红黑树的空间是普通链表节点空间的2倍,立刻转为红黑树后,太浪费空间;


从时间维度上讲,红黑树虽然查询比链表快,但是插入比链表慢多了,每次插入都要旋转和变色,如果小于8就转为红黑树,时间和空间的综合平衡上就没有链表好。


看一个Demo,为什么不在冲突时立刻将链表转换为红黑树:


import java.util.HashMap;  
import java.util.Map;  /**
*  MMMMM
*/
public class HashMapExample {  public static void main(String[] args) {  // 创建一个初始容量为16的HashMap  HashMap<Integer, String> map = new HashMap<>(16);  // 插入一些元素到HashMap中  for (int i = 0; i < 10; i++) {  map.put(i, "Value" + i);  }  // 模拟冲突情况:多次插入相同键的元素  for (int i = 10; i < 18; i++) {  map.put(i, "Value" + i); // 发生冲突,链表长度增加  }  // 输出当前链表长度和红黑树节点数(这里只是一个demo,只是模拟查询了某个键)  System.out.println("Current LinkedList length: " + getLinkedListLength(map)); // 输出链表长度  System.out.println("Number of nodes in the Red-Black Tree: " + getRedBlackTreeNodes(map)); // 输出红黑树节点数(这里只是模拟查询)  // 继续插入元素,直到链表长度超过阈值(例如16)  for (int i = 18; i < 34; i++) {  map.put(i, "Value" + i); // 继续发生冲突,链表长度增加  }  // 输出当前链表长度和红黑树节点数(这里为了演示目的,只是模拟查询了某个键)  System.out.println("Current LinkedList length: " + getLinkedListLength(map)); // 输出链表长度  System.out.println("Number of nodes in the Red-Black Tree: " + getRedBlackTreeNodes(map)); // 输出红黑树节点数(这里只是模拟查询)  }  /**  * 获取HashMap中链表的长度  */  private static int getLinkedListLength(Map<Integer, String> map) {  int length = 0;  Node<Integer, String> node = map.getNode(0); // 获取第一个节点(根节点)  while (node != null) {  length++;  node = node.next; // 遍历链表直到末尾  }  return length;  }  /**  * 获取HashMap中红黑树的节点数(这里只是模拟查询)  */  private static int getRedBlackTreeNodes(Map<Integer, String> map) {// 假设我们查询的键为33,这里只是为了示例。实际情况下,我们需要遍历红黑树来计算节点数。   return map.getNode(33).right == null ? 1 : 2;  }  
}

✔️关于为什么长度为8的时候转(源码注释解读)


上面我们讲了案例,接下来我们来看看源码中的解读:


/**
*    Because TreeNodes are about twice the size of regular nodes, we
*    use them only when bins contain enough nodes to warrant use
*    (see TREEIFY THRESHOLD). And when they become too small (due to
*    removal or resizing) they are converted back to plain bins. In
*    usages with well-distributed user hashCodes, tree bins are
*    rarely used. Ideally, under random hashCodes, the frequency of
*    nodes in bins follows a Poisson distribution
*    (http://en.wikipedia.org/wiki/Poisson distribution) with a
*    parameter of about 0.5 on average for the default resizing
*    threshold of 0.75, although with a large variance because of
*    resizing granularity. Ignoring variance,the expected
*    occurrences of list size k are (exp(-0.5) * pow(0.5,k)  /
*    factorial(k)). The first values are:
*
*    0:    0.60653066
*    1:    0.30236533
*    2:    0.07581633
*    3:    0.01263606
*    4:    0.00157952
*    5:    0.00015795
*    6:    0.00001316
*    7:    0.00000094
*    8:    0.00000006
*    more: less than 1 in ten million
*/

翻译注释


大概的翻译就是 TreeNode 占用的内存是 Node 的两倍,只有在 node 数量达到8时才会使用它,而当node 数量变小时(删除或者扩容),又会变回普通的 Node 。当 hashCode 遵循泊松分布时,因为哈希冲突造成桶的链表长度等于8的概率只有0.00000006 。官方认为这个概率足够的低,所以指定链表长度为 8 时转化为红黑树。所以 8 这个数是经过数学推理的不是瞎写的。


✔️为什么长度为6的时候转回来?


但是,当红黑树节点数小于 6 时,又会把红黑树转换回链表,这个设计的主要原因是出于对于性能和空间的考虑。前面讲过为什么直接用红黑树,那同理,转成红黑树之后总要在适当的时机转回来,要不然无论是空间占用大,还是插入性能都会下降。


8的时候转成红黑树,那么如果小于8立刻转回去,那么就可能会导致频繁转换,所以要选一个小于8的值,但是又不能是7。而通过前面提到的泊松分布可以看到,当红黑树节点数小于 6 时,它所带来的优势其实就是已经没有那么大了,就不足以抵消由于红黑树维护节点所带来的额外开销,此时转换回链表能够节省空间和时间。


但是不管怎样,6 这个数值是通过大量实验得到的经验值,在绝大多数情况下取得比较好的效果。


✔️双向链表是怎么回事


HashMap 红黑树的数据结构中,不仅有常见的 parent , left right 节点,还有一个nextprev 节点,这很明显的说明,其不仅是一个红黑树,还是一个双向链表,为什么是这样呢?


这个其实我们也在之前红黑树退化成链表的时候稍微提到过,红黑树会记录树化之前的链表结构,这样当红黑树退化成链表的时候,就可以直接按照链表重新链接的方式进行 (详细分析可以见前面扩容的文章)。


不过可能有人会问,那不是需要一个next节点就行了,为什么还要prev节点呢?

这是因为当删除红黑树中的某人节点的时候,这人节点可能就是原始链表的中间节点,如果把该节点删除,只有next属性是没办法将原始的链表重新链接的,所以就需要prev节点,找到上一个节点,重新成链


✔️HashMap的元素没有比较能力,红黑树为什么可以比较?


这里红黑树使用了一个骚操作:


1 . 如果元素实现了comparable接口,则直接比较,否则

2 . 则使用默认的仲裁方法,该方法的源码如下


static int tieBreakOrder(Object a, Object b)  {int dd;if (a == null || b == null || (dd = a.getClass( ).getName().compareTo(b.getClass()getName())) == 0) {dd = (System.identityHashCode(a) <= System.identityHashCode(b) ? -1 : 1);}return dd;
}

这篇关于【Java集合篇】接上篇博文--为什么在JDK8中HashMap要转成红黑树的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!



http://www.chinasem.cn/article/577452

相关文章

SpringBoot中四种AOP实战应用场景及代码实现

《SpringBoot中四种AOP实战应用场景及代码实现》面向切面编程(AOP)是Spring框架的核心功能之一,它通过预编译和运行期动态代理实现程序功能的统一维护,在SpringBoot应用中,AO... 目录引言场景一:日志记录与性能监控业务需求实现方案使用示例扩展:MDC实现请求跟踪场景二:权限控制与

Java NoClassDefFoundError运行时错误分析解决

《JavaNoClassDefFoundError运行时错误分析解决》在Java开发中,NoClassDefFoundError是一种常见的运行时错误,它通常表明Java虚拟机在尝试加载一个类时未能... 目录前言一、问题分析二、报错原因三、解决思路检查类路径配置检查依赖库检查类文件调试类加载器问题四、常见

Java注解之超越Javadoc的元数据利器详解

《Java注解之超越Javadoc的元数据利器详解》本文将深入探讨Java注解的定义、类型、内置注解、自定义注解、保留策略、实际应用场景及最佳实践,无论是初学者还是资深开发者,都能通过本文了解如何利用... 目录什么是注解?注解的类型内置注编程解自定义注解注解的保留策略实际用例最佳实践总结在 Java 编程

Java 实用工具类Spring 的 AnnotationUtils详解

《Java实用工具类Spring的AnnotationUtils详解》Spring框架提供了一个强大的注解工具类org.springframework.core.annotation.Annot... 目录前言一、AnnotationUtils 的常用方法二、常见应用场景三、与 JDK 原生注解 API 的

Java controller接口出入参时间序列化转换操作方法(两种)

《Javacontroller接口出入参时间序列化转换操作方法(两种)》:本文主要介绍Javacontroller接口出入参时间序列化转换操作方法,本文给大家列举两种简单方法,感兴趣的朋友一起看... 目录方式一、使用注解方式二、统一配置场景:在controller编写的接口,在前后端交互过程中一般都会涉及

Java中的StringBuilder之如何高效构建字符串

《Java中的StringBuilder之如何高效构建字符串》本文将深入浅出地介绍StringBuilder的使用方法、性能优势以及相关字符串处理技术,结合代码示例帮助读者更好地理解和应用,希望对大家... 目录关键点什么是 StringBuilder?为什么需要 StringBuilder?如何使用 St

使用Java将各种数据写入Excel表格的操作示例

《使用Java将各种数据写入Excel表格的操作示例》在数据处理与管理领域,Excel凭借其强大的功能和广泛的应用,成为了数据存储与展示的重要工具,在Java开发过程中,常常需要将不同类型的数据,本文... 目录前言安装免费Java库1. 写入文本、或数值到 Excel单元格2. 写入数组到 Excel表格

Java并发编程之如何优雅关闭钩子Shutdown Hook

《Java并发编程之如何优雅关闭钩子ShutdownHook》这篇文章主要为大家详细介绍了Java如何实现优雅关闭钩子ShutdownHook,文中的示例代码讲解详细,感兴趣的小伙伴可以跟随小编一起... 目录关闭钩子简介关闭钩子应用场景数据库连接实战演示使用关闭钩子的注意事项开源框架中的关闭钩子机制1.

Maven中引入 springboot 相关依赖的方式(最新推荐)

《Maven中引入springboot相关依赖的方式(最新推荐)》:本文主要介绍Maven中引入springboot相关依赖的方式(最新推荐),本文给大家介绍的非常详细,对大家的学习或工作具有... 目录Maven中引入 springboot 相关依赖的方式1. 不使用版本管理(不推荐)2、使用版本管理(推

Java 中的 @SneakyThrows 注解使用方法(简化异常处理的利与弊)

《Java中的@SneakyThrows注解使用方法(简化异常处理的利与弊)》为了简化异常处理,Lombok提供了一个强大的注解@SneakyThrows,本文将详细介绍@SneakyThro... 目录1. @SneakyThrows 简介 1.1 什么是 Lombok?2. @SneakyThrows