HashMap的数据结构

众所周知,HashMap 是一个用于存储Key-Value键值对的集合,每一个键值对也叫做 Entry。

这些个键值对(Entry)分散存储在一个数组当中,这个数组就是HashMap的主干。

HashMap 数组每一个元素的初始值都是 Null。

image.png

对于HashMap,我们最常使用的是两个方法:Get 和 Put。

1. Put 方法的原理

调用 Put 方法的时候发生了什么呢?

比如调用 hashMap.put(“apple”, 0) ,插入一个Key为“apple”的元素。这时候我们需要利用一个哈希函数来确定Entry的插入位置(index):

index = Hash(“apple”)

假定最后计算出的index是2,那么结果如下:


image.png

但是,因为 HashMap 的长度是有限的,当插入的 Entry 越来越多时,再完美的 Hash 函数也难免会出现 index 冲突的情况。

比如下面这样:


image.png

这时候该怎么办呢?我们可以利用链表来解决。

HashMap 数组的每一个元素不止是一个 Entry 对象,也是一个链表的头节点。

每一个 Entry 对象通过 Next 指针指向它的下一个 Entry 节点。当新来的Entry映射到冲突的数组位置时,只需要插入到对应的链表即可:


image.png

需要注意的是,新来的Entry节点插入链表时,使用的是“头插法”。至于为什么不插入链表尾部,后面会有解释。

2. Get方法的原理

使用 Get 方法根据 Key 来查找 Value 的时候,发生了什么呢?

首先会把输入的 Key 做一次 Hash 映射,得到对应的 index:

index = Hash(“apple”)

由于刚才所说的 Hash 冲突,同一个位置有可能匹配到多个Entry,这时候就需要顺着对应链表的头节点,一个一个向下来查找。假设我们要查找的Key是 “apple”:

image.png

第一步,我们查看的是头节点 Entry6,Entry6 的 Key是banana,显然不是我们要找的结果。

第二步,我们查看的是 Next 节点 Entry1,Entry1 的 Key 是 apple,正是我们要找的结果。

之所以把 Entry6 放在头节点,是因为 HashMap 的发明者认为,后插入的 Entry 被查找的可能性更大。

HashMap的默认长度是16 ,自动扩展或初始化时,长度必须是2的幂

目的:服务于从Key映射到index的Hash算法
之前说过,从Key映射到HashMap数组的对应位置,会用到一个Hash函数:

index = Hash(“apple”)

如何实现一个尽量均匀分布的Hash函数呢?我们通过利用Key的HashCode值来做某种运算。

Hash算法的实现采用了位运算的方式
如何进行位运算呢?有如下的公式(Length是HashMap的长度):

index = HashCode(Key) & (Length -
1)
下面我们以值为“book”的 Key 来演示整个过程:
1. 计算 book 的 hashcode,结果为十进制的 3029737,二进制的101110001110101110 1001。
2. 假定 HashMap 长度是默认的16,计算Length-1的结果为十进制的15,二进制的1111。
3. 把以上两个结果做与运算,101110001110101110 1001 & 1111 = 1001,十进制是9,所以 index=9。(与运算:和,同位上都为1则为1,否则为0

可以说,Hash 算法最终得到的 index 结果,完全取决于 Key 的 Hashcode 值的最后几位。

为什么长度必须是2的幂

假设 HashMap 的长度是10,重复刚才的运算步骤:

image.png

单独看这个结果,表面上并没有问题。我们再来尝试一个新的 HashCode 101110001110101110
1011 :

image.png

让我们再换一个 HashCode
101110001110101110 1111 试试 :


image.png

是的,虽然 HashCode 的倒数第二第三位从0变成了1,但是运算的结果都是1001。

也就是说,当 HashMap 长度为10的时候,有些index结果的出现几率会更大,而有些index结果永远不会出现(比如0111)!

这样,显然不符合Hash算法均匀分布的原则

反观长度16或者其他2的幂,Length-1 的值是所有二进制位全为1,比如15是1111,7是111,这种情况下,index 的结果等同于 HashCode 后几位的值

只要输入的 HashCode 本身分布均匀,Hash 算法的结果就是均匀的。

HashMap扩容与Rehash

HashMap的容量是有限的。当经过多次元素插入,使得HashMap达到一定饱和度时,Key映射位置发生冲突的几率会逐渐提高。

这时候,HashMap需要扩展它的长度,也就是进行Resize。

image.png

影响发生Resize的因素有两个:

1.Capacity

HashMap的当前长度。上一期曾经说过,HashMap的长度是2的幂。

2.LoadFactor

HashMap负载因子,默认值为0.75f。

衡量HashMap是否进行Resize的条件如下:

HashMap.Size >= Capacity * LoadFactor

HashMap的resize不是简单的把长度扩大,而是经历以下两个步骤
1.扩容

创建一个新的Entry空数组,长度是原数组的2倍。

2.ReHash

遍历原Entry数组,把所有的Entry重新Hash到新数组。为什么要重新Hash呢?因为长度扩大以后,Hash的规则也随之改变。

让我们回顾一下Hash公式:

index = HashCode(Key) & (Length - 1)

当原数组长度为8时,Hash运算是和111B做与运算;新数组长度为16,Hash运算是和1111B做与运算。Hash结果显然不同。

Resize前的HashMap:


image.png

Resize后的HashMap:


image.png

ReHash的Java代码如下:

/** 
 * Transfers all entries from current table to newTable. 
 */  
void transfer(Entry[] newTable, boolean rehash) {  
    int newCapacity = newTable.length;  
    for (Entry<K,V> e : table) {  
        while(null != e) {  
            Entry<K,V> next = e.next;  
            if (rehash) {  
                e.hash = null == e.key ? 0 : hash(e.key);  
            }  
            int i = indexFor(e.hash, newCapacity);//求索引  
            e.next = newTable[i];  
            newTable[i] = e;  
            e = next;  
        }  
    }  
}  

hash函数的原理

链接:https://www.zhihu.com/question/20733617/answer/111577937

这段代码叫“扰动函数”。题主贴的是Java 7的HashMap的源码,Java 8中这步已经简化了,只做一次16位右位移异或混合,而不是四次,但原理是不变的。下面以Java 8的源码为例解释,

//Java 8中的散列值优化函数
static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);    //key.hashCode()为哈希算法,返回初始哈希值
}

大家都知道上面代码里的key.hashCode()函数调用的是key键值类型自带的哈希函数,返回int型散列值。理论上散列值是一个int型,如果直接拿散列值作为下标访问HashMap主数组的话,考虑到2进制32位带符号的int表值范围从-2147483648到2147483648。前后加起来大概40亿的映射空间。只要哈希函数映射得比较均匀松散,一般应用是很难出现碰撞的。但问题是一个40亿长度的数组,内存是放不下的。你想,HashMap扩容之前的数组初始大小才16。所以这个散列值是不能直接拿来用的。用之前还要先做对数组的长度取模运算,得到的余数才能用来访问数组下标。源码中模运算是在这个indexFor( )函数里完成的。bucketIndex = indexFor(hash, table.length);
indexFor的代码也很简单,就是把散列值和数组长度做一个”与”操作,

static int indexFor(int h, int length) {
        return h & (length-1);
}

顺便说一下,这也正好解释了为什么HashMap的数组长度要取2的整次幂。因为这样(数组长度-1)正好相当于一个“低位掩码”。“与”操作的结果就是散列值的高位全部归零,只保留低位值,用来做数组下标访问。以初始长度16为例,16-1=15。2进制表示是00000000 00000000 00001111。和某散列值做“与”操作如下,结果就是截取了最低的四位值。 10100101 11000100 00100101

& 00000000 00000000 00001111

00000000 00000000 00000101    //高位全部归零,只保留末四位

但这时候问题就来了,这样就算我的散列值分布再松散,要是只取最后几位的话,碰撞也会很严重。更要命的是如果散列本身做得不好,分布上成等差数列的漏洞,恰好使最后几个低位呈现规律性重复,就无比蛋疼。这时候“扰动函数”的价值就体现出来了,说到这里大家应该猜出来了。看下面这个图,


这里写图片描述

右位移16位,正好是32bit的一半,自己的高半区和低半区做异或,就是为了混合原始哈希码的高位和低位,以此来加大低位的随机性。而且混合后的低位掺杂了高位的部分特征,这样高位的信息也被变相保留下来。最后我们来看一下Peter Lawley的一篇专栏文章《An introduction to optimising a hashing strategy》里的的一个实验:他随机选取了352个字符串,在他们散列值完全没有冲突的前提下,对它们做低位掩码,取数组下标。

这里写图片描述

结果显示,当HashMap数组长度为512的时候,也就是用掩码取低9位的时候,在没有扰动函数的情况下,发生了103次碰撞,接近30%。而在使用了扰动函数之后只有92次碰撞。碰撞减少了将近10%。看来扰动函数确实还是有功效的。但明显Java 8觉得扰动做一次就够了,做4次的话,多了可能边际效用也不大,所谓为了效率考虑就改成一次了。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 202,723评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,080评论 2 379
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 149,604评论 0 335
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,440评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,431评论 5 364
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,499评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,893评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,541评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,751评论 1 296
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,547评论 2 319
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,619评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,320评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,890评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,896评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,137评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,796评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,335评论 2 342

推荐阅读更多精彩内容