粗解HashMap
Posted 谦冲斋
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了粗解HashMap相关的知识,希望对你有一定的参考价值。
HashMap粗解
HashMap从JDK1.2起就存在,从98年Playground走到今天,已经有20年了。HashMap从某种角度上而言,是对于散列表的完美发挥。本文主要讲解JDK8中的HashMap(本文并非科普文,也并非HashMapAPI讲解文,无数据结构基础者可能有不适)。
HashMap实现三个接口,Map, Cloneable, Serializable。后两者老生常谈,所以重点在Map接口上。
图1.HashMap不完全且不标准类图
Map接口的原型是从1.0时代起便存在的抽象类——Dictionary。Dictionary起初的定位就是为散列表而服务,从名字也可以看出。它包含了基础的七大方法。但是为了向后兼容,最为原始的Abstract Class Dictionary在创建的初始就注定它不能被长时间广泛的投入使用,所以在Map接口出世之后,Dictionary便已经废弃(废弃一词在JDK1.8的文档中已经写明。值得一提的时虽然HashTable继承了Dictionary,但是HashTable已经很少使用甚至被替代,如synchronizedMap)。
不过从类图中可以很正常的得到一个有趣疑问:为什么HashMap已经继承了AbstractMap还要实现Map接口?这是一个非常有趣的问题,Stackoverflow上一位名叫Sotirios Delimanolis的人回答道:“I’ve asked JoshBloch, and he informs me that it’s was a mistake”。不过也有人提到这样的做法,可以在一次Reflect时便得到Map的存在,也有部分的道理。
作为Dictionary的“继承人“,Map接口自然而然的包含了基础的方法,isEmpty,containsKey,containsValue,get,put,remove,putAll,clear。然而Java对于散列表中最重要的散列码计算方式依托于所投入的特定类,因此在HashMap这样的散列类中,散列函数显得并不是格外出众。值得一提的是从JDK1.8起,提供了接口的static method和default method。因此在Map接口中直接或间接实现了占据Map部分的方法,如getOrDefault,forEach等。而这些方法中,computeIfAbsent,computeIfPresent等几个方法都尤为重要。其中,有两个基础接口显得有些耀眼,那就是Consumer和Function。对于JDK1.8中的新星——lambda表达式,Consumer和Function是为了lambda量身定做的接口。对于如何更好的使用Consumer和Function,熟悉Lambda表达式的同学会更有些得心应手。在Map的文档解释中写到:The Map interface provides threecollection views…,这三个视图器便是keySet,values,entrySet。并且内置视图接口Entry<K, V>,这样的方法在collection开发中几乎是必备的操作,不再赘述。对于Map为什么提供三个视图器,也是可想而知,便也不再赘述。
光知道Map接口或许还不足以我们更深入了解HashMap的jdk思想,我们还需要知道HashMap的父抽象类——AbstractMap。
AbstractMap解释中有一句话格外重要:This class provides a skeletal implementation of the map interface,to minimize the effort required to implement this interface.
AbstractMap使得子类可以方便的选择是可更改类型还是不可更改类型(只要实现相应的方法即可)。AbstractMap也实现了部分从Map而来的方法。因为这样种种的优异性,文档中才使用了skeletal描述它。
至此已经初步了解了HashMap的父类和实现接口,那么接下来了解HashMap之前或许还需要知道一些散列表的基本数学知识才能更好的理解HashMap。
在数据结构中都知道,散列表的映射方式有基本的两种——拉链法和线性探测法。但是为什么HashMap选择前者而不是后者,事实上并没有太多的解释,那么如下我将从数学角度来阐述一些个人的意见(注意:并非官方意见)。
首先我们需要先确认一个假设:均匀散列假设:我们所使用的散列函数能够均匀并独立地将所有地键散布于0到M-1之间(假设散列表大小为M)。然而事实上,我们无法验证均匀散列假设地正确性,因为从数学角度出发,要找到一个计算简单但拥有一致性和均匀性地散列函数是很困难的,但是我们仍然可以将此依靠散列函数在设计之初的可靠性(即它可以避免大量的碰撞),所以姑且将均匀散列假设认为是正确的。接下来我们需要分析线性探测的性能。首先在一张大小为M并且含有N=αM个键的散列表(基于线性探测)中,我们要得到命中的查找次数,因为命中概率可以代表线性探测的性能指标。在半满情况下,针对箭簇的两种极端,可以认为最好情况下:奇数位置有值,偶数位置无,最坏情况下:前半张表箭簇装满,后半表为空。这两种情况下,箭簇的平均长度都是1/2。最好情况下,情况为1+1 / 2 , (1/2 ->(1+0+1+0+…)/(2 * N)) ,最坏情况下,1 + N / 4, (N / 4 -> (N+ (N - 1) + …) / (2 * N) = N/ 4),所以得知比较次数和箭簇长度的平方成正比。所以提炼公式并且化简可得,命中所需的探测次数大约是,根据公式可得,探测次数取决于α,当α小于1/2时探测次数大约在1.5和2.5之间。所以通过动态调整数组可以让α保持在0.5以下,但是动态调整数组又是一笔不小的开销。在内存使用方面,通过数学实验得知,基于拉链法的散列表N个引用类型元素所需的内存大于为48N+32M,而线性探测在32N~128N之间。在经过一些抉择后,HashMap选择了以拉链法为基础并且进行扩展。
有了以上的理论基础,我们便可以开展HashMap的剖析了。剖析的部分我分为三个部分,重要方法的算法分析,重要的数据结构分析,方法的组合。
一.部分方法实现算法分析
在HashMap源码中(JDK8版本)中,有一个重要的参数是capacity,当然HashMap提供了默认的大小,即16,但是在DEFAULT_INITIAL_CAPACITY的注释中写了这样一句话:”MUST be a power of two”.初学者一定很好奇为什么一定是powerof two?当的桶的大小为2次幂时,设此时大小为M,则M-1正好可以作为一个低位掩码,进行与操作时可以让高位归零保留低位(去高保低)。讲到这里或许仍不清楚这样做的好处。所以我们需要看到HashMap中的hash方法的源码。对于hash方法,如果自行实现,或许难以有数学支撑。但是通俗的想法是取尾数的思想。但是如果Hash的重心依靠于低位显然时非常不合理的,这样有可能在分布上造成等差数列的结果,所以要进行前后扰动。将得到的原类支持的hash值进行扰动,混合hash值得高低位,加大低位的随机性,然后做一次保留低位的操作(即上述的去高保低),如此也可以做到变相的保留高位信息,虽然不完全(在JDK7中如此扰动反复4次,在JDK8中保留为1次,可能是出于效率大于效果的角度)。我认为2次幂的作用就在于此,至于是否在其他方法中也起到作用,我暂时还不知,有了解的同学可以留言告知。如下是HashMap的hash算法(java中int占位为4字节,即32位,因此右移16位,异或保留高位残缺信息):
图2.HashMap的hash算法源码
第二个需要讲解的算法是HashMap的扩容大小计算算法。还记得第一次看到该算法时,惊叹于怎能如此精致。接下来我将分析tableSizeFor方法所包含的算法意义。对于2次幂扩容计算,首先从二进制入手,如果获得的数字M已经是二次幂,则M-1的位数全部为1,此时无论如何右移或操作,都不会影响该值,如果M不是二次幂,则经过反复强度增大的右移和或操作后,可以将原M中的0通过或操作变为1,则可以得到全位数为1的结果N,再将N加1即可将得到的数字转化为二次幂(N + 1)。整个算法相当精巧且完美。jdk运用这个算法为HashMap的扩容提供基础服务。
图3.Hash Map的tableSizeFor方法源码
二.部分数据结构分析
众所周知的是HashMap在JDK8中比之之前有较大的改变。改变在于性能的提升,而提升的基础是,使用红黑树替代后期的链表。如何界定这个度,源码中的两个不可变阈值已经决定了。关于为什么阈值分别是8和6以及64的桶限制,本人猜测是数学理论支撑,有兴趣的同学可以自行探究,本人没有深入挖掘下去。HashMap中比较明显的两个数据结构是用于模拟List的Node,另一个是为RBTree服务的TreeNode。所以在此我们需要分析为什么添加了RBTree(RBT)。众所周知的是RBT其实是2-3树的另一种实现,然而事实上RBT的真实性能如何呢?网上关于红黑树性能的测评都是模糊且一概而过的,于是我在此处也粗略分析RBT的性能,以说明为什么只在HashMap的中后期使用RBT而不是从一开始就使用。首先我们需要知道一棵大小是N的RBT的hight不会超过2lgN,然而这个数字仍然过于保守,在随机序列的RBT中查询的平均比较次数大约是1.001lgN-0.5.因为是Balance Tree,所以推测可得到从root开始到任意节点的平均长度大约是1.001lgN,比之BST低了大约0.38lgN,而常规无序链表的查找效率为N / 2,所以前期的效率不及常规链表,而后期的效率大于List。RBT是每一个科班本科生应该都会的数据结构,所以不在此赘述RBT的实现。
三.方法的组合
至此大部分有难度的方法已经讲解了,随后就是方法的拼接。首先根据标准API,程序员可以自定义capacity和loadFactor。根据构造函数完成后,需要添加元素。在元素添加的过程中,如果容量达到上限,就需要扩容。因为table size的变化,之前的Hash计算必须重新定位。扩容完后,如果已经超越默认表转树阈值,则还需要观测桶阈值,二者皆满足时可以进行表转树的操作。
至此,HashMap的粗略解析到此接近尾声。值得一提的是,当初在学习数据结构时,无论是标准书本(严蔚敏版本)还是Algorithms Fourth Edition,都说明了散列表中不可放置null key,但是HashMap明确允许了null key的存在。限于篇幅本次并没有讲述关于HashMap的迭代器更新、fail-fast机制以及相关的Consumer和Function,或许下次会再写一篇专门关于此的文章。
以上是关于粗解HashMap的主要内容,如果未能解决你的问题,请参考以下文章