Java 集合 --- HashMap的底层原理

HashMap的下标计算

计算步骤

第一步: 计算hash值

  • 将h 和 h右移十六位的结果 进行XOR操作
  • 操作说明:
    高16位不动, 低16位与高16位做异或运算,
    也就是高十六位 + (低十六位 ^ 高十六位)
static final int hash(Object key) {
    int h;
    //hashCode()是native方法, 用 C/C++实现
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

第二步: 通过hash值计算下标值

  • 将hash值 和 hash数组减一 之后的数值进行AND操作
//n为 HashMap的长度
//这里 & 操作等同于取余操作
i = (n - 1) & hash 

Example:

  • HashMap的默认长度为16, 所以n-1这里取1111
  h = key.hashCode()     01101010 11101111 11100010 11000100
             h >>> 16    00000000 00000000 01101010 11101111 
------------------------------------------------------------
hash = h ^ (h >>> 16)    01101010 11101111 10001000 00101011
  (n - 1) = (2^4 - 1)    00000000 00000000 00000000 00001111
------------------------------------------------------------
     (2^4 - 1) & hash    00000000 00000000 00000000 00001011

为什么要 h ^ (h >>> 16)

假如没有做h ^ (h >>> 16)运算, 则hash的计算过程为:

hash = key.hashCode()    01101010 11101111 11100010 11000100
              (n - 1)    00000000 00000000 00000000 00001111
------------------------------------------------------------
     (n - 1) & hash =    00000000 00000000 00000000 00000100
  • 结合以上示例会发现,整个hash值,除了低四位参与了计算,其他全部没有起到任何的作用… 全部被0覆盖掉了.
  • 而大部分情况下, n的值(map的大小) 一般都会小于2^16次方,也就是65536. 则全部集中在低16位
  • 则如果key的hash值低位相同,计算出来的槽位下标都是同一个,大大增加了碰撞的几率;
  • 但如果使用h ^ (h >>> 16),将高位参与到低位的运算,整个随机性就大大增加了;
  • 结论: 增加离散性, 降低碰撞概率

为什么数组长度必须是2^n

增加离散性, 降低碰撞概率

  • 根据源码可知,无论是初始化,还是保存过程中的扩容,map的长度始终是2^n
  • 假如默认n的长度不是16(2^4),而是17,会出现什么效果呢?
hash           01101010 11101111 10001000 00101011
&
(17 - 1) = 16  00000000 00000000 00000000 00010000
----------------------------------------------
               00000000 00000000 00000000 00000000
  • 由于16的二进制是00010000,最终参与&(与运算)的只有1位,其他的值全部被0给屏蔽了;导致最终计算出来的下标只会是0或16.
  • 所以n的二进制值中必须尽可能多的出现1, 否则在&操作时不管hash值为多少都为0.
  • 二进制中出现1最多的数就是 2^n - 1

使用&替代 %, 提高计算效率

  • 还有一个原因是当 length = 2^n 时,X % length = X & (length - 1)
  • 而在计算机中 & 的效率比 % 高很多.

HashMap的树化

背景补充

  • JDK 1.7及之前HashMap的结构为: 数组 + 链表
  • Java7中Hashmap底层采用的是Entry对数组,而每一个Entry对又向下延伸是一个链表,在链表上的每一个Entry对不仅存储着自己的key/value值,还存了前一个和后一个Entry对的地址.
  • JDK 1.8: 数组+链表+红黑树
  • Java8中的Hashmap底层结构有一定的变化,还是使用的数组,但现在换成了Node对象(存储时也会存key/value键值对、前一个和后一个Node的地址),
  • 以前所有的Entry向下延伸都是链表,Java8变成链表和红黑树的组合,数据少量存入的时候优先还是链表,当链表长度大于8,且总数据量大于64的时候,链表就会转化成红黑树,
  • 所以你会看到Java8的Hashmap的数据存储是链表+红黑树的组合,如果数据量小于64则只有链表,如果数据量大于64,且某一个数组下标数据量大于8,那么该处即为红黑树

树化的条件

  • 条件一: 一个Node中链表的节点数量大于等于树化阈值 (也就是8). 源码如下
  • 必须满足第一个条件才能进入下一个条件
  • HashMap触发判断第一个条件的位置主要有4个方法,分别是putVal方法、computeIfAbsent方法、compute方法、merge方法
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
    treeifyBin(tab, hash);
  • 条件二: HashMap的Capacity大于等于最小树化容量值
  • 如果capacity小于64, 则选择扩容
  • 如果capacity大于等于64, 则进行树化
final void treeifyBin(Node<K,V>[] tab, int hash) {
	int n, index; Node<K,V> e;
    if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
    	//这里选择扩容
    	resize();
    else if ((e = tab[index = (n - 1) & hash]) != null) {
        TreeNode<K,V> hd = null, tl = null;
    do {
        TreeNode<K,V> p = replacementTreeNode(e, null);
        if (tl == null)
        	hd = p;
        else {
            p.prev = tl;
            tl.next = p;
        }
        tl = p;
     } while ((e = e.next) != null);
     if ((tab[index] = hd) != null)
        hd.treeify(tab);
    }
}

总结

  • 如果数据量小于64则只有链表,如果数据量大于64,且某一个数组下标数据量大于8,那么该处即为红黑树

为什么链表变树的阈值为8

  • 在HashMap中, TreeNode的大小是普通node大小的两倍, 所以只有当链表里的node足够多时再树化 (平衡时间和空间) ’
  • 对于一个well-distributed的HashMap, node基本不会树化
  • HashMap的节点数量分布符合泊松分布:

0: 0.60653066
1: 0.30326533
2: 0.07581633
3: 0.01263606
4: 0.00157952
5: 0.00015795
6: 0.00001316
7: 0.00000094
8: 0.00000006

  • 链表长度为8的概率为0.00000006,在这种比较罕见和极端的情况下, 才会把链表转变为红黑树,转变为红黑树也是消耗性能的,是一个权衡的措施.
  • 当k=9时,也就是发生的碰撞次数为9次时,概率为亿分之三,碰撞的概率已经无限接近为0。
    如果设置为9,意味着,几乎永远都不会再次发生碰撞,基本永远都不会变树,因为概率太小了。因此设置为9,实在没必要。

为什么使用红黑树 而不是 AVL树

  • 红黑树相比avl树,在检索的时候效率其实差不多,都是通过平衡来二分查找。
  • AVL树是一种高度平衡的二叉树,所以查找的效率非常高,但是,有利就有弊,AVL树为了维持这种高度的平衡,就要付出更多代价。每次插入、删除都要做调整,就比较复杂、耗时。所以,对于有频繁的插入、删除操作的数据集合,使用AVL树的代价就有点高了。
  • 红黑树只是做到了近似平衡,并不严格的平衡,所以在维护的成本上,要比AVL树要低
  • 所以,红黑树的插入、删除、查找各种操作性能都比较稳定。对于工程应用来说,要面对各种异常情况,为了支撑这种工业级的应用,我们更倾向于这种性能稳定的平衡二叉查找树。在现在很多地方都是底层都是红黑树的天下.

HashMap的扩容

背景补充:

  • HashMap的初始容量16, 每次扩容都是两倍, 所以HashMap的容量必为2的n次幂
  • 负载因子默认为0.75, 也就是当数组的密度大于百分之75时会进行扩容
  • 当链表的长度大于8, 但数组长度小于64的时候, 会进行扩容
  • JDK1.7在链表中的插入方式为头插法, 这样可以避免遍历链表, 但是在多线程的情况下会有死循环问题, JDK1.8则改为尾插法
  • JDK1,7 中部会新建一个数组,然后通过transfer方法, 将原数组中的键值对依次加入到新数组中。transfer方法会遍历旧数组,对于每个数组元素,会遍历其中的每个节点,并重新计算其hash值,然后使用头插法将其插入到新的索引位置上

JDK1.8的扩容方式 – 不需要重新计算hash的值
第一步:

  • 由于扩容直接加了1倍,因此相当于length-1原来的最右侧的0变为了1, 比如:
16 -> 32
16-1 = 15   0000000000000000 0000000000001111
32-1 = 31   0000000000000000 0000000000011111

第二步:

  • 下标的计算是 hash & (n-1), 所以新下标取决于变为1的那个bit所对应的hash中的bit是0还是1
长度为16 = 10000
hash        xxxxxxxxxxxxxxxx xxxxxxxxxxxyxxxx
16 - 1      0000000000000000 0000000000001111

下标为xxxx
长度为32 = 100000
hash        xxxxxxxxxxxxxxxx xxxxxxxxxxxyxxxx
16 - 1      0000000000000000 0000000000011111

如果y = 1 则新下标为 1xxxx 也就是 10000 + xxxx = 原来的capacity + 原位置
如果y = 0 则新下标不变, 为 xxxx
  • 这个设计非常的巧妙,既省去了重新计算hash值的时间,而且同时,由于新增的1bit是0还是1可以认为是随机的,因此resize的过程,均匀的把之前的冲突的节点分散到新的bucket了。这一块就是JDK1.8新增的优化点

HashMap的put流程

  • 首先判断table是否为空或者length = 0
  • 如果是则进行扩容
  • 通过key计算下标位置
  • 如果node为空直接插入
  • 如果node不为空(说明发生冲突)
  • 如果为TreeNode则插入红黑树
  • 如果为node则判断链表是否大于8, 小于8直接插入链表
  • 大于8则进行树化
  • 加入新元素后, 判断是否需要扩容, 然后结束
    在这里插入图片描述

HashMap的线程安全问题

  • HashMap是线程不安全的
  • 线程安全的hashmap为 ConcurrentHashMap, 或者HashTable (不常用)

多线程下 put 会导致元素丢失

  • 多线程同时执行 put 操作时,如果计算出来的索引位置是相同的,那会造成前一个 key 被后一个 key 覆盖,从而导致元素的丢失

put 和 get 并发时会导致 get 到 null

  • 线程 A 执行put时,因为元素个数超出阈值而出现扩容,线程B 此时执行get。当线程 A 执行完 table = newTab 之后,线程 B 中的 table 此时也发生了变化,此时去 get 的时候就会 get 到 null 了,因为元素还没有迁移完成

JDK1.7中头插法带来的死循环问题

https://blog.csdn.net/littlehaes/article/details/105241194

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值