Java性能优化(三):HashMap的设计与优化

news2024/12/23 18:41:17
  • 作者主页: 🔗进朱者赤的博客

  • 精选专栏:🔗经典算法

  • 作者简介:阿里非典型程序员一枚 ,记录在大厂的打怪升级之路。 一起学习Java、大数据、数据结构算法(公众号同名

  • ❤️觉得文章还不错的话欢迎大家点赞👍➕收藏⭐️➕评论,💬支持博主,记得点个大大的关注,持续更新🤞
    ————————————————-

引言

在Java的集合框架中,HashMap无疑是一个非常重要的成员。它以哈希表数据结构为基础,提供了高效的键值对存储和查询功能。无论是在大型数据处理系统中,还是在日常编程的各个方面,HashMap都扮演着至关重要的角色。其设计巧妙,性能卓越,为开发者提供了极大的便利。

然而,就像任何强大的工具一样,要充分利用HashMap的优势,就需要对其内部机制和工作原理有深入的了解。在本文中,我们将深入探讨HashMap的工作原理、参数设置、冲突解决策略以及性能优化等方面,帮助读者更好地理解HashMap,并在实际开发中发挥其最大效用。

接下来,我们将从HashMap的核心特性开始,逐步展开对其的总结和探讨。

HashMap的实现结构

作为最常用的Map类,HashMap是基于哈希表实现的,继承了AbstractMap并且实现了Map接口。HashMap根据键的Hash值来决定对应值的存储位置,使得获取数据的速度非常快。

哈希表将键的Hash值映射到内存地址,即根据键获取对应的值,并将其存储到内存地址。也就是说HashMap是根据键的Hash值来决定对应值的存储位置。通过这种索引方式,HashMap获取数据的速度会非常快。

例如,存储键值对(x,“aa”)时,哈希表会通过哈希函数f(x)得到"aa"的实现存储位置。

哈希冲突

  • 当两个对象的哈希值相同时,它们的存储地址会发生冲突。
  • 解决哈希冲突的方法有:开放定址法、再哈希函数法和链地址法。
  • HashMap采用链地址法解决哈希冲突问题,即采用数组(哈希表)+ 链表的数据结构。

HashMap的重要属性

  • HashMap由一个Node数组构成,每个Node包含了一个key-value键值对。
transient Node<K,V>[] table;
  • Node类作为HashMap中的一个内部类,除了key、value两个属性外,还定义了一个next指针。
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;
    }
}
  • HashMap还有两个重要的属性:加载因子(loadFactor)和边界值(threshold)。
int threshold;
final float loadFactor;
  • LoadFactor属性是用来间接设置Entry数组(哈希表)的内存空间大小。
  • 阈值(threshold)的计算公式为:threshold = capacity * loadFactor。当HashMap中的元素数量超过了阈值时,就会触发扩容操作。

LoadFactor属性是用来间接设置Entry数组(哈希表)的内存空间大小,在初始HashMap不设置参数的情况下,默认LoadFactor值为0.75。为什么是0.75这个值呢?

这是因为对于使用链表法的哈希表来说,查找一个元素的平均时间是O(1+n),这里的n指的是遍历链表的长度,因此加载因子越大,对空间的利用就越充分,这就意味着链表的长度越长,查找效率也就越低。如果设置的加载因子太小,那么哈希表的数据将过于稀疏,对空间造成严重浪费。

那有没有什么办法来解决这个因链表过长而导致的查询时间复杂度高的问题呢?你可以先想想,我将在后面的内容中讲到。

Entry数组的Threshold是通过初始容量和LoadFactor计算所得,在初始HashMap不设置参数的情况下,默认边界值为12。如果我们在初始化时,设置的初始化容量较小,HashMap中Node的数量超过边界值,HashMap就会调用resize()方法重新分配table数组。这将会导致HashMap的数组复制,迁移到另一块内存中去,从而影响HashMap的效率。

HashMap添加元素优化

初始化完成后,HashMap就可以使用put()方法添加键值对了。从下面源码可以看出,当程序将一个key-value对添加到HashMap中,程序首先会根据该key的hashCode()返回值,再通过hash()方法计算出hash值,再通过putVal方法中的(n - 1) & hash决定该Node的存储位置。

public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}

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

// 省略部分代码...

// 通过putVal方法中的(n - 1) & hash决定该Node的存储位置
if ((p = tab[i = (n - 1) & hash]) == null)
    tab[i] = newNode(hash, key, value, null);

hash()算法详述

如果你不太清楚hash()以及(n-1)&hash的算法,就请你看下面的详述。

hash()方法中的算法

如果我们没有使用hash()方法计算hashCode,而是直接使用对象的hashCode值,会出现什么问题呢?

假设要添加两个对象a和b,如果数组长度是16,这时对象a和b通过公式(n - 1) & hash运算(即(16-1)&a.hashCode(16-1)&b.hashCode),因为15的二进制为0000000000000000000000000001111,如果对象A和B的hashCode的低4位恰好都为0(这在高并发下是很有可能的),那么与运算的结果就会相同,即发生哈希冲突。

为了避免上述情况,HashMap在hash()方法中对hashCode值进行了再处理。它将hashCode值右移16位(h >>> 16代表无符号右移16位),也就是取int类型的一半,并且使用位异或运算(^),这样的做法可以尽量打乱hashCode真正参与运算的低16位,从而减少哈希冲突的可能性。

(n - 1) & hash设计

这里的n代表哈希表的长度,哈希表习惯将长度设置为2的n次方,这样恰好可以保证(n - 1) & hash的计算得到的索引值总是位于table数组的索引之内。这是因为当n是2的幂时,n-1的二进制形式中所有位都是1(例如,当n=16时,n-1=15,其二进制为00001111),与任何hash值进行与运算,都能保证结果落在0到n-1的范围内。

例如:

  • hash=15n=16时,(n - 1) & hash的结果为15
  • hash=17n=16时,(n - 1) & hash的结果为1

流程图

在获得Node的存储位置后,如果判断Node不在哈希表中,就新增一个Node,并添加到哈希表中。以下是整个流程的简化图说明(由于Markdown不支持直接绘制流程图,这里用文字描述):

  1. 输入key和value:用户向HashMap提供key和value。
  2. 计算hash值:调用hash(key)方法,得到key的hash值。
  3. 确定存储位置:使用(n - 1) & hash计算Node的存储位置i
  4. 检查位置是否已存在Node:检查table数组中索引为i的位置是否已存在Node。
  5. 添加新Node:如果位置为空,则创建一个新Node,并将其放置在table数组的相应位置。
  6. 处理哈希冲突:如果位置已有Node(即发生哈希冲突),则使用链表或红黑树(在JDK 1.8及以后版本中)来处理冲突。
  7. 完成添加:key-value对成功添加到HashMap中。

在这里插入图片描述
从图中我们可以看出:在JDK1.8中,HashMap引入了红黑树数据结构来提升链表的查询效率。

这是因为链表的长度超过8后,红黑树的查询效率要比链表高,所以当链表超过8时,HashMap就会将链表转换为红黑树,这里值得注意的一点是,这时的新增由于存在左旋、右旋效率会降低。讲到这里,我前面我提到的“因链表过长而导致的查询时间复杂度高”的问题,也就迎刃而解了。

以下就是put的实现源码:

 final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        if ((tab = table) == null || (n = tab.length) == 0)
//1、判断当table为null或者tab的长度为0时,即table尚未初始化,此时通过resize()方法得到初始化的table
            n = (tab = resize()).length;
        if ((p = tab[i = (n - 1) & hash]) == null)
//1.1、此处通过(n - 1) & hash 计算出的值作为tab的下标i,并另p表示tab[i],也就是该链表第一个节点的位置。并判断p是否为null
            tab[i] = newNode(hash, key, value, null);
//1.1.1、当p为null时,表明tab[i]上没有任何元素,那么接下来就new第一个Node节点,调用newNode方法返回新节点赋值给tab[i]
        else {
//2.1下面进入p不为null的情况,有三种情况:p为链表节点;p为红黑树节点;p是链表节点但长度为临界长度TREEIFY_THRESHOLD,再插入任何元素就要变成红黑树了。
            Node<K,V> e; K k;
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
//2.1.1HashMap中判断key相同的条件是key的hash相同,并且符合equals方法。这里判断了p.key是否和插入的key相等,如果相等,则将p的引用赋给e

                e = p;
            else if (p instanceof TreeNode)
//2.1.2现在开始了第一种情况,p是红黑树节点,那么肯定插入后仍然是红黑树节点,所以我们直接强制转型p后调用TreeNode.putTreeVal方法,返回的引用赋给e
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            else {
//2.1.3接下里就是p为链表节点的情形,也就是上述说的另外两类情况:插入后还是链表/插入后转红黑树。另外,上行转型代码也说明了TreeNode是Node的一个子类
                for (int binCount = 0; ; ++binCount) {
//我们需要一个计数器来计算当前链表的元素个数,并遍历链表,binCount就是这个计数器

                    if ((e = p.next) == null) {
                        p.next = newNode(hash, key, value, null);
                        if (binCount >= TREEIFY_THRESHOLD - 1) 
// 插入成功后,要判断是否需要转换为红黑树,因为插入后链表长度加1,而binCount并不包含新节点,所以判断时要将临界阈值减1
                            treeifyBin(tab, hash);
//当新长度满足转换条件时,调用treeifyBin方法,将该链表转换为红黑树
                        break;
                    }
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }
            if (e != null) { // existing mapping for key
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;
            }
        }
        ++modCount;
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }


结构化输出

HashMap获取元素优化

最佳情况

  • 当HashMap中只存在数组,而数组中没有Node链表时,是HashMap查询数据性能最好的时候。

哈希冲突与链表

  • 一旦发生大量的哈希冲突,就会产生Node链表。
  • 每次查询元素都可能遍历Node链表,从而降低查询数据的性能。

红黑树优化

  • 红黑树的使用解决了链表过长时性能降低的问题。
  • 使得查询的平均复杂度降低到了O(log(n))。
  • 链表越长,使用红黑树替换后的查询效率提升就越明显。

编码优化

  • 重写key值的hashCode()方法,降低哈希冲突,减少链表的产生。
  • 高效利用哈希表,提高性能。
HashMap扩容优化

JDK 1.7 扩容方式

  • 分别取出数组元素(一般是链表尾部的元素)。
  • 遍历以该元素为头的单向链表。
  • 依据每个被遍历元素的hash值计算其在新数组中的下标,然后进行交换。
  • 扩容后可能将原来哈希冲突的单向链表尾部变成扩容后单向链表的头部。

JDK 1.8 扩容优化

  • 扩容数组长度是原数组长度的2倍。
  • 使用“与运算”重新分配索引:原hash值和左移动的一位(newtable的值)按位与操作是0或1。
  • 0的话索引不变,1的话索引变成原索引加上扩容前数组长度。
  • 这种方式将哈希冲突的元素随机分布到不同的索引中。

总结

HashMap核心特性

  • 数据结构:HashMap采用哈希表数据结构来存储键值对,利用哈希函数和哈希表快速定位元素位置,提供高效的键值对查询。

参数设置

  • 初始容量:HashMap允许用户根据使用场景设定初始容量,以优化性能。在预知数据量时,可以通过计算(初始容量=预知数据量/加载因子)来设定合适的初始容量,以减少扩容操作,提高效率。
  • 加载因子:加载因子定义了哈希表何时进行扩容的阈值。加载因子较小时,哈希表会更早地进行扩容,减少哈希冲突;加载因子较大时,会提高内存利用率但可能增加哈希冲突。用户可以根据查询频率和内存使用需求调整加载因子。

冲突解决

  • 链地址法:HashMap使用链地址法解决哈希冲突。当两个或多个键的哈希值相同时,这些键值对会被存储在同一个桶(数组中的一个位置)的链表中。
  • 红黑树优化:在Java 8中,为了解决链表过长导致的查询性能下降问题,HashMap引入了红黑树。当链表长度超过一定阈值(默认为8)时,链表会转换为红黑树,将查询的时间复杂度从O(n)降低到O(log n)。

数据结构图

  • HashMap的数据结构图通常展示了一个数组(哈希表),数组的每个位置(桶)可能包含一个链表或一个红黑树。每个链表或红黑树中的节点代表一个键值对。

性能建议

  • 在预知数据量时,合理设置初始容量和加载因子,以减少扩容操作。
  • 如果查询操作频繁,考虑降低加载因子以减少哈希冲突。
  • 如果内存利用率是首要考虑因素,可以适当增加加载因子以提高内存利用率。
  • 注意HashMap不是线程安全的,在多线程环境下使用时需要额外的同步措施。

HashMap的数据结构图:
在这里插入图片描述

欢迎一键三连(关注+点赞+收藏),技术的路上一起加油!!!代码改变世界

  • 关于我:阿里非典型程序员一枚 ,记录在大厂的打怪升级之路。 一起学习Java、大数据、数据结构算法(公众号同名),回复暗号,更能获取学习秘籍和书籍等

  • —⬇️欢迎关注下面的公众号:进朱者赤,认识不一样的技术人。⬇️—

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/1643752.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

《动手学深度学习》V2(00-10)

文章目录 一、学习目标二、环境搭建三、数据操作1、张量介绍2、运算符介绍3、广播介绍4、索引和切片5、节省内存6、课后练习实现 :fire: 四、数据预处理1、读取数据集2、处理缺失数据3、课后练习实现 :fire:①第一步&#xff1a;造数据②第二步 筛选遍历缺失值③第三步 统计降序…

arthas无法捕获到try catch了的异常怎么办呢?

本案例使用的arthas是最新版本3.7.2 要跟踪的代码&#xff1a; 1、arthas watch试下能不能捕获到 页面上请求 http://localhost:8080/exception发现捕获不了。 2、可以使用btrace捕获&#xff0c;能够捕获到 我本案例使用Eclipse编写btrace脚本 &#xff0c;首先引入btrace的…

assert函数详解

assert函数详解 1.函数概述2.assert函数一般用法3.assert函数的一些使用案例3.1判断大小3.2strlen函数的模拟实现3.3其它 4.注意 1.函数概述 评价一个表达式&#xff0c;当表达式错误时&#xff0c;输出一个诊断信息并且终止程序 assert是一个宏&#xff0c;在使用之前要调用库…

详解:-bash: mysql command not found (mysql未找到命令)

1、确认是否安装MySQL rpm -qa |grep mysql rpm -qa |grep mariadb MariaDB是一个开源的关系型数据库管理系统&#xff08;RDBMS&#xff09;&#xff0c;是广泛使用的MySQL数据库技术的替代品。安装MySQL后就会覆盖掉之前的mariadb。 如果没有就需要找教程安装 2、找到自己My…

【软件设计师】上午题

【软考】软件设计师plus 「软件设计师」 2022年下半年上午真题解析视频 计算机系统知识 22下 考点&#xff1a;指令系统之CISC vs RISC RISC指令系统整体特点是简单、精简 》指令种类少&#xff0c;但是指令功能强 考点&#xff1a;计算机系统组成 A属于运算器&#xff0c;…

嵌入式开发三:STM32初体验

本节主要向大家介绍如何开发过程中的基本操作&#xff0c;如编译、串口下载、仿真器下载、仿真调试程序&#xff0c;体验一下 STM32 的开发流程&#xff0c;并介绍 MDK5 的一些使用技巧&#xff0c;通过本节的学习&#xff0c;将对 STM32 的开发流程和 MDK5 使用有个大概了解&a…

八股文(C#篇)

C#中的数值类型 堆和栈 值类型的数据被保存在栈&#xff08;stack)上&#xff0c;而引用类型的数据被保存在堆&#xff08;heap&#xff09;上&#xff0c;当值类型作为参数传递给函数时&#xff0c;会将其复制到新的内存空间中&#xff0c;因此在函数中对该值类型的修改不会影…

ttkbootstrap界面美化系列之Menubutton(五)

一&#xff1a;Menubutton接口 print(help(help(ttk.Menubutton))) Help on class Menubutton in module tkinter.ttk:class Menubutton(Widget)| Menubutton(masterNone, **kw)|| Ttk Menubutton widget displays a textual label and/or image, and| displays a menu wh…

linux搭建个人博客wordpress(LNMP)

目录 准备阶段&#xff1a; 1.部署LNMP环境 2.配置数据库 3.上线WordPress博客平台 4.来到web界面安装博客平台 5.WordPress博客平台优化 总结&#xff1a; 利用LNMPWordPress搭建博客网站平台 WordPress是一款使用PHP语言开发的博客平台 1.易用性高&#xff1a;操作简单…

模拟集成电路(2)----MOSFET大小信号分析,二级效应

模拟集成电路(2)----MOSFET大小信号分析&#xff0c;二级效应 文章目录 模拟集成电路(2)----MOSFET大小信号分析&#xff0c;二级效应MOS的结构及符号大信号特性Turn-on process for an NMOS耗尽区反形层形成 I-V特性推导三极管区 ( V D S ≤ V G S − V T H ) (V_{DS}\le V_{G…

杭电acm1013 Digital Roots 数字根 Java解法 高精度

Problem - 1013 (hdu.edu.cn) 高精度算术模拟 开long没过想到开bI 开bl一次过 import java.math.BigInteger; import java.util.Scanner;public class Main {public static void main(String[] args) {Scanner sc new Scanner(System.in);BigInteger i;while (!(i sc.nextB…

GPT是什么?直观解释Transformer | 深度学习第5章 【3Blue1Brown 官方双语】

【官方双语】GPT是什么&#xff1f;直观解释Transformer | 深度学习第5章 0:00 - 预测&#xff0c;采样&#xff0c;重复&#xff1a;预训练/生成式/Transformer模型 3:03 - Transformer 的内部结构 6:36 - 本期总述 7:20 - 深度学习的大框架 12:27 - GPT的第一层&#xff1a;…

HT32F52352 -- 解锁电调、电机速度控制

一、问题背景 1.1 硬件&#xff1a; 电池组&#xff0c;电子调速器&#xff08;好盈电调 /ESC&#xff09;&#xff0c;接收机&#xff08;HT32F52352&#xff09;&#xff0c;风扇。 1.2 软件 keil5 二、问题分析 通过1.1图中可知&#xff0c;我们只需要使用 HT32F52352 模拟…

MAT内存分析软件安装

一、简介 MAT&#xff08;Memory Analyzer Tool&#xff09;工具是java堆内存分析器。可以用于查找内存泄漏以及查看内存消耗情况。MAT是Eclipse开发的免费的性能分析工具。 下载链接https://www.eclipse.org/mat/downloads.php 二、安装常见问题 1. 仅支持JDK17及以上版本 …

在GPU上加速RWKV6模型的Linear Attention计算

精简版&#xff1a;经过一些profile发现flash-linear-attention中的rwkv6 linear attention算子的表现比RWKV-CUDA中的实现性能还要更好&#xff0c;然后也看到了继续优化triton版本kernel的线索。接着还分析了一下rwkv6 cuda kernel的几次开发迭代以此说明对于不懂cuda以及平时…

如何使用Go语言的标准库和第三方库?

文章目录 一、如何使用Go语言的标准库示例&#xff1a;使用标准库中的fmt包打印输出 二、如何使用Go语言的第三方库示例&#xff1a;使用第三方库github.com/gin-gonic/gin创建Web服务器 总结 在Go语言中&#xff0c;标准库和第三方库的使用是日常编程中不可或缺的一部分。标准…

Java基于Spring Boot框架的课程管理系统(附源码,说明文档)

博主介绍&#xff1a;✌IT徐师兄、7年大厂程序员经历。全网粉丝15W、csdn博客专家、掘金/华为云//InfoQ等平台优质作者、专注于Java技术领域和毕业项目实战✌ &#x1f345;文末获取源码联系&#x1f345; &#x1f447;&#x1f3fb; 精彩专栏推荐订阅&#x1f447;&#x1f3…

Spring IoCDI (1)

目录 一、IoC & DI入门 1、Spring是什么 &#xff08;1&#xff09;什么是容器&#xff1f; &#xff08;2&#xff09;什么是IoC&#xff1f; 二、IoC介绍 1、传统程序开发 2、解决方案 3、IoC程序开发 4、IoC优势 三、DI介绍 通过前面的学习&#xff0c;我们知…

5月4(信息差)

&#x1f384; HDMI ARC国产双精度浮点dsp杜比数码7.1声道解码AC3/dts/AAC环绕声光纤、同轴、USB输入解码板KC33C &#x1f30d; 国铁集团回应高铁票价将上涨 https://finance.eastmoney.com/a/202405043066422773.html ✨ 源代码管理平台GitLab发布人工智能编程助手DuoCha…

mysql设置允许其他IP访问

文章目录 更改mysql配置文件登录mysql 更改mysql配置文件 查找.ini或者.cnf文件 更改bind-address为0.0.0.0 [mysqld] character-set-serverutf8mb4 bind-address0.0.0.0 default-storage-engineINNODB [mysql] default-character-setutf8mb4 [client] default-character-s…