图解HashMap(一)-程序员宅基地

技术标签: Java  java  

HashMap设计的思路以及一些重要概念

作者:HuYounger

博客:http://rkhcy.github.io/

文章目录

  • 概述

  • HashMap是什么

  • HashCode是什么

  • HashMap的时间复杂度

  • 负载因子是什么

  • hash与Rehash

  • 源码分析

    • Java7源码分析

    • Java8源码分析

    • 对比

0

概述

HashMap是日常开发中经常会用到的一种数据结构,在介绍HashMap的时候会涉及到很多术语,比如时间复杂度O、散列(也叫哈希)、散列算法等,这些在大学课程里都有教过,但是由于某种不可抗力又还给老师了,在深入学习HashMap之前先了解HashMap设计的思路以及以及一些重要概念,在后续分析源码的时候就能够有比较清晰的认识。

1

HashMap是什么

在回答这个问题之前先看个例子:小明打算从A市搬家到B市,拿了两个箱子把自己的物品打包就出发了。

到了B市之后,他想拿手机给家里报个平安,这时候问题来了,东西多了他忘记手机放在哪个箱子了?小明开始打开1号箱子找手机,没找到;再打开2号箱子找,找到手机。当只有2个箱子的时候,东西又不多的情况下,他可能花个2分钟就找到手机了,假如有20个箱子,每个箱子的东西又多又杂,那么花的时间就多了。小明总结了下查找耗时的原因,发现是因为这些东西放的没有规律,如果他把每个箱子分个类别,比如定一个箱子专门放手机、电脑等电子设备,有专门放衣服的箱子等等,那么他找东西花的时间就可以大大缩短了。

其实HashMap也是用到这种思路,HashMap作为一种数据结构,像数组和链表一样用于常规的增删改查,在存数据的时候(put)并不是随便乱放,而是会先做一次类似“分类”的操作再存储,一旦“分类”存储之后,下次取(get)的时候就可以大大缩短查找的时间。我们知道数组在执行查、改的效率很高,而增、删(不是尾部)的效率低,链表相反,HashMap则是把这两者结合起来,看下HashMap的数据结构

从上面的结构可以看出,通常情况下HashMap是以数组和链表的组合构成(Java8中将链表长度超过8的链表转化成红黑树)。结合上面找手机的例子,我们简单分析下HashMap存取操作的心路历程。put存一个键值对的时候(比如存上图盖伦),先根据键值”分类”,”分类”一顿操作后告诉我们,盖伦应该属于14号坑,直接定位到14号坑。接下来有几种情况:

  • 14号坑没人,nice,直接存值;

  • 14号有人,也叫盖伦,替换原来的攻击值;

  • 14号有人,叫老王!插队到老王前面去(单链表的头插入方式,同一位置上新元素总会被放在链表的头部位置)

get取的时候也需要传键值,根据传的键值来确定要找的是哪个”类别”,比如找火男,”分类”一顿操作够告诉我们火男属于2号坑,于是我们直接定位到2号坑开始找,亚索不是…找到火男。

小结

HashMap是由数组和链表组合构成的数据结构,Java8中链表长度超过8时会把长度超过8的链表转化成红黑树;存取时都会根据键值计算出”类别”(hashCode),再根据”类别”定位到数组中的位置并执行操作。

2

HashCode是什么

还是举个栗子:一个工厂有500号人,下图用两种方案来存储厂里员工的信件。

左右各有27个信箱,左边保安大哥存信的时候不做处理,想放哪个信箱就放哪个信箱,当员工去找信的时候,只好挨个信箱找,再挨个比对信箱里信封的名字,万一哥们脸黑,要找的放在最后一个信箱的最底下,悲剧…所以这种情况的时间复杂度为O(N);右边采用HashCode的方式将27个信箱分类,分类的规则是名字首字母(第一个箱子放不写名字的哥们),保安大哥将符合对应姓名的信件放在对应的信箱里,这样员工就不用挨个找了,只需要比对一个信箱里的信件即可,大大提高了效率,这种情况的时间复杂度趋于一个常数O(1)。

例子中右图其实就是hashCode的一个实现,每个员工都有自己的hashCode,比如李四的hashCode是L,王五的hashCode是W(这取决于你的hash算法怎么写),然后我们根据确定的hashCode值把信箱分类,hashCode匹配则存在对应信箱。在Java的Object中可以调用hashCode()方法获取对象hashCode,返回一个int值。那么会出现两个对象的hashCode一样吗?答案是会的,就像上上个例子中盖伦和老王的hashCode就一样,这种情况网上有人称之为”hash碰撞”,出现这种所谓”碰撞”的处理上面已经介绍了解决思路,具体源码后续介绍。

小结

hashCode是一个对象的标识,Java中对象的hashCode是一个int类型值。通过hashCode来指定数组的索引可以快速定位到要找的对象在数组中的位置,之后再遍历链表找到对应值,理想情况下时间复杂度为O(1),并且不同对象可以拥有相同的hashCode。

3

HashMap的时间复杂度

通过上面信箱找信的例子来讨论下HashMap的时间复杂度,在使用hashCode之后可以直接定位到一个箱子,时间的耗费主要是在遍历链表上,理想的情况下(hash算法写得很完美),链表只有一个节点,就是我们要的

那么此时的时间复杂度为O(1),那不理想的情况下(hash算法写得很糟糕),比如上面信箱的例子,假设hash算法计算每个员工都返回同样的hashCode

所有的信都放在一个箱子里,此时要找信就要依次遍历C信箱里的信,时间复杂度不再是O(1),而是O(N),因此HashMap的时间复杂度取决于算法的实现上,当然HashMap内部的机制并不像信箱这么简单,在HashMap内部会涉及到扩容、Java8中会将长度超过8的链表转化成红黑树,这些都在后续介绍。

小结

HashMap的时间复杂度取决于hash算法,优秀的hash算法可以让时间复杂度趋于常数O(1),糟糕的hash算法可以让时间复杂度趋于O(N)。

4

负载因子是什么

我们知道HashMap中数组长度是16(什么?你说不知道,看下源码你就知道),假设我们用的是最优秀的hash算法,即保证我每次往HashMap里存键值对的时候,都不会重复,当hashmap里有16个键值对的时候,要找到指定的某一个,只需要1次;

之后继续往里面存值,必然会发生所谓的”hash碰撞”形成链表,当hashmap里有32个键值对时,找到指定的某一个最坏情况要2次;当hashmap里有128个键值对时,找到指定的某一个最坏情况要8次

随着hashmap里的键值对越来越多,在数组数量不变的情况下,查找的效率会越来越低。那怎么解决这个问题呢?只要增加数组的数量就行了,键值对超过16,相应的就要把数组的数量增加(HashMap内部是原来的数组长度乘以2),这就是网上所谓的扩容,就算你有128个键值对,我们准备了128个坑,还是能保证”一个萝卜一个坑”。

其实扩容并没有那么风光,就像ArrayList一样,扩容是件很麻烦的事情,要创建一个新的数组,然后把原来数组里的键值对”放”到新的数组里,这里的”放”不像ArrayList那样用原来的index,而是根据新表的长度重新计算hashCode,来保证在新表的位置,老麻烦了,所以同一个键值对在旧数组里的索引和新数组中的索引通常是不一致的(火男:”我以前是3号,怎么现在成了127号,给我个完美的解释!”新表:”大清亡了,现在你得听我的”)。另外,我们也可以看出这是典型的以空间换时间的操作。

说了这么多,那负载因子是个什么东西?负载因子其实就是规定什么时候扩容。上面我们说默认hashmap数组大小为16,存的键值对数量超过16则进行扩容,好像没什么毛病。然而HashMap中并不是等数组满了(达到16)才扩容,它会存在一个阀值(threshold),只要hashmap里的键值对大于等于这个阀值,那么就要进行扩容。阀值的计算公式:

阀值 = 当前数组长度负载因子

hashmap中默认负载因子为0.75,默认情况下第一次扩容判断阀值是16 0.75 = 12;所以第一次存键值对的时候,在存到第13个键值对时就需要扩容了;或者另外一种理解思路:假设当前存到第12个键值对:12 / 16 = 0.75,13 / 16 = 0.8125(大于0.75需要扩容) 。肯定会有人有疑问,我要这铁棒有何用?不,我要这负载因子有何用?直接规定超过数组长度再扩容不就行了,还省得每次扩容之后还要重新计算新的阀值,Google说取0.75是一个比较好的权衡,当然我们可以自己修改,HashMap初识化时可以指定数组大小和负载因子,你完全可以改成1。

我的理解是这负载因子就像人的饭量,有的人吃要7分饱,有的人要10分饱,稳妥起见默认让我们7.5分饱。

小结

在数组大小不变的情况下,存放键值对越多,查找的时间效率会降低,扩容可以解决该问题,而负载因子决定了什么时候扩容,负载因子是已存键值对的数量和总的数组长度的比值。默认情况下负载因子为0.75,我们可在初始化HashMap的时候自己修改。

5

hash与Rehash

hash和rehash的概念其实上面已经分析过了,每次扩容后,转移旧表键值对到新表之前都要重新rehash,计算键值对在新表的索引。如下图火男这个键值对被存进hashmap到后面扩容,会经过hash和rehash的过程

第一次hash可以理解成’”分类”‘,方便后续取、改等操作可以快速定位到具体的”坑”。那么为什么要进行rehash,按照之前元素在数组中的索引直接赋值,例如火男之前3号坑,现在跑到30号坑。

个人理解是,在未扩容前,可以看到如13号链的长度是3,为了保证我们每次查找的时间复杂度O趋于O(1),理想的情况是”一个萝卜一个坑”,那么现在”坑”多了,原来”3个萝卜一个坑”的情况现在就能有效的避免了。

6

源码分析

Java7源码分析

先看下Java7里的HashMap实现,有了上面的分析,现在在源码中找具体的实现。

以上就是HashMap的一些先决条件,接着看平时put操作的代码实现,put的时候会遇到3种情况上面已分析过,看下Java7代码:

先看上面key为空的情况(上面画图的时候总要在第一格留个空key的键值对),执行 putForNullKey() 方法单独处理,会把该键值对放在index0,所以HashMap中是允许key为空的情况。再看下主流程:

步骤①.根据键值算出hash值 — > hash(key)

步骤②.根据hash值和当前数组的长度计算在数组中的索引 — > indexFor(hash, table.length)

步骤③情况1.hash值和key值都相同,替换原来的值,并将被替换的值返回。

步骤③情况2.坑位没人或发生hash碰撞 — > addEntry(hash, key, value, i)

如果put的时候超过阀值,会调用 resize() 方法将数组大小扩大为原来的2倍,并且根据新表的长度计算在新表中的索引(如之前17%16 =1,现在17%32=17),看下resize方法

上面的重点是步骤②,看下它具体的转移操作

这段for循环的遍历会使得转移前后键值对的顺序颠倒(Java7和Java8的区别),画个图就清楚了,假设石头的key值为5,盖伦的key值为37,这样扩容前后两者还是在5号坑。第一次:

第二次

最后再看下创建节点的方法

创建节点时,如果找到的这个坑里面没有存值,那么直接把值存进去就行了,然后size++;如果是碰撞的情况,

前面说的以单链表头插入的方式就是这样(盖伦:”老王已被我一脚踢开!“),总结一下Java7 put流程图

相比put,get操作就没这么多套路,只需要根据key值计算hash值,和数组长度取模,然后就可以找到在数组中的位置(key为空同样单独操作),接着就是遍历链表,源码很少就不分析了。

Java8源码分析

基本思路是一样的

看下Java8 put的源码

通过上面注释分析,对比和Java7的区别,Java8一视同仁,管你key为不为空的统一处理,多了一步链表长度的判断以及转红黑树的操作,并且比较重要的一点,新增Node是插在尾部而不是头部!!!。当然上面的主角还是扩容resize操作

可以看到,Java8把初始化数组和扩容全写在resize方法里了,但是思路还是一样的,扩容后要转移,转移要重新计算在新表中的位置,上面代码最后一块高能可能不太好理解,刚开始看的我一脸懵逼,看了一张美团博客的分析图才豁然开朗,在分析前先捋清楚思路

下面我们讲解下JDK1.8做了哪些优化。经过观测可以发现,我们使用的是2次幂的扩展(指长度扩为原来2倍),所以,元素的位置要么是在原位置,要么是在原位置再移动2次幂的位置。看下图可以明白这句话的意思,n为table的长度,图(a)表示扩容前的key1(5)和key2(21)两种key确定索引位置的示例,图(b)表示扩容后key1和key2两种key确定索引位置的示例,其中hash1是key1对应的哈希与高位运算结果。

图a中key1(5)和key(21)计算出来的都是5,元素在重新计算hash之后,因为n变为2倍,那么n-1的mask范围在高位多1bit(红色),因此新的index就会发生这样的变化:

图b中计算后key1(5)的位置还是5,而key2(21)已经变成了21,因此,我们在扩充HashMap的时候,不需要像JDK1.7的实现那样重新计算hash,只需要看看原来的hash值新增的那个bit是1还是0就好了,是0的话索引没变,是1的话索引变成“原索引+oldCap”。

有了上面的分析再回来看下源码

为了更清晰明了,还是举个栗子,下面的表定义了键和它们的hash值(数组长度为16时,它们都在5号坑)

假设一个hash算法刚好算出来的的存储是这样的,在存第13个元素时要扩容

那么流程应该是这样的(只关注5号坑键值对的情况),第一次:

第二次:

.。。。。。。

省略中间几次,第六次

两条链找出来后,最后转移一波,大功告成。

总结下Java8 put流程图

对比

1.发生hash冲突时,Java7会在链表头部插入,Java8会在链表尾部插入

2.扩容后转移数据,Java7转移前后链表顺序会倒置,Java8还是保持原来的顺序

3.关于性能对比可以参考美团技术博客,引入红黑树的Java8大程度得优化了HashMap的性能

文章转载:

https://blog.csdn.net/cym492224103/article/details/106374873

版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
本文链接:https://blog.csdn.net/gqg_guan/article/details/123353522

智能推荐

latex算法分页问题_\makeatletter \newenvironment{breakablealgorithm} -程序员宅基地

文章浏览阅读2k次。latex算法分页问题引入的包可分页的算法格式使用方法引入的包\usepackage{algorithmic}\usepackage{algorithm,float}这些包会和其他的算法包比如algorithml2e冲突。可分页的算法格式\makeatletter\newenvironment{breakablealgorithm} {% \begin{breakablealgo..._\makeatletter \newenvironment{breakablealgorithm} {% \begin{breakablealgorit

C#/C++/Fortran 在32位/64位下数学计算性能对比-程序员宅基地

文章浏览阅读321次。测试平台在我的上一篇博客中对比了VS2010中C#和C++在运算密集型程序中的性能。上一篇博客的链接:http://www.cnblogs.com/ytyt2002ytyt/archive/2011/11/24/2261104.html当时是在AMD 速龙9650 CPU(4核心)下的测试结果。随着VS2012、Intel Parallel Studio XE 2013中新一..._c# 与fortran 计算能力对比

Macbookpro2019外接硬盘bootcamp启动转换尝试访问启动磁盘设置时出错解决方案-程序员宅基地

文章浏览阅读1.5w次,点赞5次,收藏14次。外接硬盘安装win10 2019官方镜像,进入win10发现bootcamp尝试访问启动磁盘设置时出错,报错如下。解决方案:按照以下步骤创建一个新用户右击用户,创建新用户。这里注意最好要设置用户名为Apple, 密码必须设置。其他不要勾选最后win+R 输出命令即可打开bootcamp面板:C:\Windows\System32\runas.exe /user:App..._尝试访问启动磁盘设置时出错

谈谈重定向、管道与C_简要说明管道与重定向结合使用有何优势-程序员宅基地

文章浏览阅读948次。谈谈重定向、管道与C我所知道的重定向和管道就这些了,写了一早晨终于写完了,现在接着睡,希望对大家有用。这是我学习之中知道的一些关于重定向和管道的知识,并积累的资料,在这里和大家分享。如果说的有不足和错误的地方,请指出。毕竟是交流信息。我这里是从DOS和C语言方面看它,没有太多涉及LINUX中所说的。我想从以下几个方面叙述:一、 重定向:所谓重定向,就是不使用系统的标准输入端口、标准输出端口或标_简要说明管道与重定向结合使用有何优势

小程序video问题_小程序video show-play-btn-程序员宅基地

文章浏览阅读2.2k次。fullscreenchange: function (e){ console.log('fullscreenchange退出全屏',e); let that = this; console.log(that.video); let shipin = that.data.shipin; let play = that.data.play; if (shipi..._小程序video show-play-btn

mac终端新建标签/窗口ssh重复输入密码问题_为啥xshell每次新建窗口都要重新输入密码-程序员宅基地

文章浏览阅读1.3w次,点赞2次,收藏4次。mac的终端默认在打开一个新的tab/window的时候需要重新输入ssh的密码, 很不方便。本文完成在mac中设置,实现secureCRT/xshell里的克隆会话功能, 即新开一个terminal进行ssh连接无需重新输入密码。原理很简单,开一个ssh连接在后台放着,以后再有需要用到ssh到同样主机的时候,直接使用这个连接的socket文件,不用再创建连接了,同理,也不需要再进行用户身份验证。默_为啥xshell每次新建窗口都要重新输入密码

随便推点

最短路径wt_q.push( ( node ){0, s} );是什么意思-程序员宅基地

文章浏览阅读62次。对于 100 % 100\% 100% 的数据: 1 ≤ n ≤ 1 0 4 1 \le n \le 10^4 1≤n≤104, 1 ≤ m ≤ 5 × 1 0 5 1\le m \le 5\times 10^5 1≤m≤5×105, 1 ≤ u , v ≤ n 1\le u,v\le n 1≤u,v≤n, w ≥ 0 w\ge 0 w≥0, ∑ w < 2 31 \sum w< 2^{31} ∑w_q.push( ( node ){0, s} );是什么意思

避坑06_Vue引入echarts 5.0报错export ‘default‘(imported as ‘echarts‘)was not found in ‘echarts‘_export 'config' (imported as 'echarts') was not fo-程序员宅基地

文章浏览阅读338次。解决方法:修改echarts引入语句为 import * as echarts from 'echarts';或const echarts = require('echarts');_export 'config' (imported as 'echarts') was not found in 'echarts

如何成为一个合格的程序员-程序员宅基地

文章浏览阅读148次。程序员1.对于程序员来说学什么语言并不重要,针对一些业务去学习,在工作中要把工作中常用的技术学会,学习项目经验,从开始到结束有总筹意识,技术只是细枝末节。关键是道,学习之道,工作之道。如果只沉浸于一向技术,最终结果可能会随着时代的进步而泥沙俱下。所有要有工程师的意识,而不是搬砖农民工的意识。一个项目从设计到结束,要选择考虑什么技术去做,根据具体情况采用最快最优最好的设计去完成工作。这个才是技术之本...

Android异形屏适配(官方方案)-程序员宅基地

文章浏览阅读9.7k次,点赞7次,收藏23次。一、前言 刘海屏,又叫水滴屏、挖孔屏,起初是iOS设备上的杰作,有吐槽,也有赞美。刚出来不久,国内的各大厂商开始效仿,起初官方并没有API进行适配,一些厂商(例如小米、vivo)自己搞了刘海屏,只能用自己的API进行检测适配,这些就是蛋疼的事,不过这篇文章不介绍这些不入流的设备,毕竟从Android 9.0 开始Android官方也出了刘海屏的适配支持,这里主要将官方的(因为现在国内各大厂商出的设备都是基于新系统,都支持官方API检测和适配)二、..._android异形屏适配

drtek收音机使用说明_根德收音机使用说明书-程序员宅基地

文章浏览阅读627次。根德E5中波、调频、短波单边带收音机用户手册如果您需要任何帮助,请联系我们:邮政地址:EtónCorporation,1015CorporationWay,PaloAlto,CA94303,USA.客服电话号码:1-800-872-2228*(美国);1-800-637-1648(加拿大);650-903-3866(全球客服);星期一到星期五,早八点半到下午四点,太平洋标准时间:...

【JY】ABAQUS混凝土CDP插件分享-程序员宅基地

文章浏览阅读2.1k次,点赞3次,收藏23次。导读:为简便钢筋混凝土构件或者结构的本构模型设置,本期给大家推荐一款Abaqus混凝土CDP模型插件,供大家应用参考。这个插件无需繁琐的Excel操作,仅需选择混凝土等级即可在Abaqus..._abaqus混凝土cdp模型参数

推荐文章

热门文章

相关标签