HashMap原理解析

一、hashmap的数据结构

底层数组+单向链表,jdk1.8后,链表长度大于8,并且数组长度大于64,将转为用红黑树存储

二、hashmap用了hash为什么还要equals

因为hash也会重复,hash重复但是值不一定相等,所以要使用equals进行判断

三、hashmap中put方法的过程

  1. 计算hash值, key.hashCode()>>>16,用高16位和低16位进行异或运算(二进制按位比较,如果相同则为 0,不同则为 1)。
  2. 然后根据hash值计算出数组下标(跟数组长度进行与运算),将元素放入数组中,如果没有出现哈希冲突,则直接放入数组,如果出现哈希冲突,则以链表的方式放在链表最后(jdk1.7使用头插法,也就是放在链表最前面,jdk1.8使用了尾插法,放在了链表最后面)。
  3. 如果链表的长度超过8,并且数组长度大于64,就把链表转成红黑树,如果链表长度低于6,就把红黑树转回链表;
  4. 如果数组中键值对的总量超过了阈值(负载因子0.75),会进行动态扩容。

四、负载因子是什么

负载因子用来实现hashmap动态扩容,默认0.75是在时间和空间上折中的一个选择。

扩容阈值计算公式:threshold = capacity * loadFactor (capacity是数组容量,初始化默认16,loadFactor是负载因子,初始化默认0.75)

负载因子对hashmap的影响

  • 负载因子越大,意味着容器中元素存得越满,虽然节省了空间,但是也增加了元素哈希冲突的概率
  • 负载因子越小,容器中空余的位置越多,虽然减少了元素哈希冲突的概率,但也造成了大量的空间浪费

默认的负载因子 DEFAULT_LOAD_FACTOR = 0.75,这是 jdk 设置的一个比较合适的负载因子,一般情况下不推荐去修改它。特殊情况下:

  • 对内存空间要求比较苛刻,而对元素查找速度要求不高,可以把负载因子调高(时间换空间)
  • 对程序运行速度要求苛刻,而内存空间充足的情况,可以把负载因子调低(空间换时间)

五、hash冲突有什么解决方式

  • 开放定址法:当冲突发生时,使用某种探查(探测)技术再散列表中形成一个探查(探测)序列。沿此序列逐个单元的查找,直到找到给定的关键字,或者碰到一个开放的地址(地址单元为空)为止。

    • 线性探查法
    • 线性补偿探测法
    • 随机探测
  • 链地址法(拉链法):将所有冲突的值,都存到同一个链表里。(HashMap就是用的这种方式)

  • 再哈希法:当发生冲突时,使用第二个、第三个......等哈希函数计算地址,一直到不冲突为止。

  • 公共溢出区:将哈希表分为基本表和溢出表两部分,凡是和基本表发生冲突的元素,都记录到溢出表。

六、hashmap为什么在jdk1.8后引入了红黑树?为什么不一直使用?

之所以引入红黑树是为了解决二分查找树的缺陷,二分查找树在特殊情况下会变成一条线性结构(这就跟原来的链表结构一样了,造成很深的问题),遍历查询会非常慢。

而红黑树在插入新数据后会通过左旋、右旋、变色这些操作来保持平衡,引入红黑树就是为了查询数据快,解决链表查询深度的问题,红黑树属于平衡二叉树,但是为了保持“平衡”是要付出代价的,但是该代价所损耗的资源要比遍历线性链表要少,所以当长度大于8的时候,会使用红黑树,如果链表长度很短的话,根本不需要引入红黑树,引入反而会慢。

七、为什么hashmap的容量必须是2的n次幂

数组大小必须为 2 的 n 次幂,也就是 16、32、64….不能为其他值。因为如果不是 2 的 n 次幂,那么经过计算的数组下标会增大碰撞的几率。

如果hash值的二进制是:
10000(十进制16)
10001(十进制17)
10010(十进制18)

和10100做&运算后(与运算,二进制按位比较,都为1结果才为1,否则为0),结果都是10000,也就是都被映射到数组16这个下标上。这三个值会以链表的形式存储在数组16下标的位置。这显然不是我们想要的结果。

但如果数组长度n为2的n次幂,2进制的数值为10,100,1000,10000……n-1后对应二进制为1,11,111,1111……这样和hash值低位&后,会保留原来hash值的低位数值,那么只要hash值的低位不一样,就不会发生碰撞。

同时 (n - 1) & hash 等价于 hash%n,那么为什么不直接用hash%n呢?这是因为按位的操作效率会更高。

1. 为什么不直接用 key 的 hashCode?

其实说到底还是为了减少碰撞的概率。我们先看看 计算hash值方法中的代码做了什么事情:

h ^ (h >>> 16)

这个意思是把 h 的二进制数值向右移动 16 位。我们知道整形为 32 位,那么右移 16 位后,就是把高 16 位移到了低 16 位。而高 16 位清0了。

^为异或操作,二进制按位比较,如果相同则为 0,不同则为 1。
这行代码的意思就是把高低16位做异或。如果两个hashCode值的低16位相同,但是高位不同,经过如此计算,低16位会变得不一样了。

2. 为什么要把低位变得不一样呢?

这是由于哈希表数组长度n会是偏小的数值,那么进行 (n - 1) & hash 运算时,一直使用的是hash较低位的值。那么即使hash值不同,但如果低位相当,也会发生碰撞。而进行 h ^ (h >>> 16) 加工后的hash值,让hashCode高位的值也参与了哈希运算,因此减少了碰撞的概率。

八、红黑树特点

红黑树是一种近似平衡的二叉查找树,他并非绝对平衡,但是可以保证任何一个节点的左右子树的高度差不会超过二者中较低的那个的一倍。

  1. 每个节点要么是红色,要么是黑色。
  2. 根节点必须是黑色。叶子节点必须是黑色NULL节点。
  3. 红色节点不能连续。
  4. 对于每个节点,从该点至叶子节点的任何路径,都含有相同个数的黑色节点。
  5. 能够以O(log2(N))的时间复杂度进行搜索、插入、删除操作。此外,任何不平衡都会在3次旋转之内解决。

九、hashmap扩容机制

  1. 如果table == null, 则为HashMap的初始化, 生成空table返回即可;
  2. 如果table不为空, 需要重新计算table的长度, newLength = oldLength << 1(注, 如果原oldLength已经到了上限, 则newLength = oldLength);
  3. 遍历oldTable:
    1. 首节点为空, 本次循环结束;
    2. 无后续节点, 重新计算hash位, 本次循环结束;
    3. 当前是红黑树, 走红黑树的重定位;
    4. 当前是链表, JAVA7时还需要重新计算hash位, 但是JAVA8做了优化, 通过 (e.hash & oldCap) == 0 来判断是否需要移位; 如果为真则在原位不动, 否则则需要移动到当前 hash槽位 + oldCap 的位置;

再来看下 e.hash & oldCap == 0为什么可以判断当前节点是否需要移位, 而不是再次计算hash;

  • 首先我们知道 HashMap 计算 key 所对应数组下标的公式是 (length - 1) & hash,其中 length 是数组长度,hash 是 hash值,这个公式等价于 hash % length (当 length 是 2 的 n 次幂时) 。

  • 从下图中我们可以看出,hash % length 的结果只取决于小于数组长度的部分,这个 key 的 hash 值的低四位就是当前所在数组的下标。扩容后 新数组长度 = 旧数组长度 * 2,也就是左移 1 位,而此时 hash % length 的结果只取决于 hash 值的低五位,前后两者之间的差别就差在了第五位上。

  • 如果第五位是 0,那么只要看低四位 (也就是当前下标);如果第五位是 1,只要把二进制数 10000+1110 ,就可以得到新数组下标。前面的部分刚好等于 旧数组长度 ,后面的部分刚好是 当前的下标 。那么我们就得出来了为什么把 low 插入扩容后 新数组[当前坐标] 的位置,把 high 插入扩容后 新数组[当前坐标 + 旧数组长度] 的位置

  • 那为什么根据 (e.hash & oldCap) == 0 来做判断条件呢?是因为旧数组的长度 length 的二进制数的第五位刚好是 1 其它位全为 0,而 & 运算相同为 1 不同为 0,因此 hash & length 的目的就是为了计算 hash 值的第五位是 0 还是 1。

部分源码:

//把原链表中的元素插入到 low 和 high 链表中,依靠 (e.hash & oldCap) == 0 判断
do {
next = e.next;
if ((e.hash & oldCap) == 0) {
    if (loTail == null)
        loHead = e;
    else
        loTail.next = e;
    loTail = e;
}
else {
    if (hiTail == null)
        hiHead = e;
    else
        hiTail.next = e;
    hiTail = e;
}


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

推荐阅读更多精彩内容