jdk1.8 HashMap那些事

一、HashCode算法

hashCode()是如何根据key计算出hashcode值的,要分几种情况进行分析:

​ 1. 如果我们使用的自己创建的对象,在我们没有重写hashCode()方法的情况下,会调用Object类的hashCode()方法,而此时返回就是对象的内存地址值,所以如果对象不同,那么通过hashcode()计算出的hashcode就是不同的。

​ 2. 如果是使用java中定义的引用类型例如String,Integer等作为key,这些类一般都会重写hashCode()方法,有兴趣可以翻看一下对应的源码。简单来说,Integer类的hashCode()返回的就是Integer值,而String类型的hashCode()方法稍稍复杂一点,这里不做展开。总的来说,hashCode()方法的作用就是要根据不同的key得到不同的hashCode值。

抗碰撞能力:对于任意两个不同的数据块,其hash值相同的可能性极小;对于一个给定的数据块,找到和它hash值相同的数据块极为困难。
抗篡改能力:对于一个数据块,哪怕只改动其一个比特位,其hash值的改动也会非常大。

二、hashMap的工作原理

HashMap是基于链地址法的原理,使用put(key, value)存储对象到HashMap中,使用get(key)从HashMap中获取对象。

   当我们给put()方法传递键和值时,我们先对键调用hashCode()方法计算hash从而得到bucket位置,进一步存储,如果bucket没有发生冲突的话则直接放入数组,发生冲突的话则以链表的形式存储,jdk1.8之后引入了红黑树,链表的长度超过8之后,则使用红黑树来替换链表,从而提高查询速度,小于6之后则又转换回来。HashMap会根据当前bucket的占用情况自动调整容量(超过加载因子Load Facotr则resize为原来的2倍),储存Node对象。resize方法兼顾两个职责,创建出事存储表格,或者在容量不满足需求的时候,进行扩容。

   使用get(key)从HashMap中获取对象时,它调用hashCode计算hash从而得到bucket位置,并进一步调用equals()方法确定键值对。

三、hash 算法和寻址算法

1、hash() 方法

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

h = key.hashCode() 表示 h 是 key 对象的 hashCode 返回值;
h >>> 16 是 h 右移 16 位,因为 int 是 4 字节,32 位,所以右移 16 位后变成:左边 16 个 0 + 右边原 h 的高 16 位;
最后把这两个进行异或返回。

异或:二进制位运算。如果一样返回 0,不一样则返回 1。

2、putVal() 寻址

tab[i = (n - 1) & hash]

tab-就是 HashMap 里的 table 数组 Node<K,V>[] table ;
n-是这个数组的长度 length;
hash-就是上面 hash() 方法返回的值;

算法导论:

将键值k映射到m槽位的方法:h(k)=k mod m
通常情况下,要避免m取2的幂,因为假设m=2^p,则h(k)是k的二进制数的低p位…,通常选择m会是一个素数,而且不那么靠近2的幂…
显然 hashmap违背了算法导论,不过通过完善hash算法进行了弥补

①寻址为什么不用取模?

对于上面寻址算法,由于计算机对比取模,与运算会更快。所以为了效率,HashMap 中规定了哈希表长度为 2 的 k 次方,而 2^k-1 转为二进制就是 k 个连续的 1,那么 hash & (k 个连续的 1) 返回的就是 hash 的低 k 个位,该计算结果范围刚好就是 0 到 2^k-1,即 0 到 length - 1,跟取模结果一样。
也就是说,哈希表长度 length 为 2 的整次幂时, hash & (length - 1) 的计算结果跟 hash % length 一样,而且效率还更好。

利用位运算代替取模运算,可以大大提高程序的计算效率。位运算可以直接对内存数据进行操作,不需要转换成十进制,因此效率要高得多。

②为什么不直接用 hashCode() 而是用它的高 16 位进行异或计算新 hash 值?

int 类型占 32 位,可以表示 2^32 种数(范围:-2^31 到 2^31-1),而哈希表长度一般不大,在 HashMap 中哈希表的初始化长度是 16(HashMap 中的 DEFAULT_INITIAL_CAPACITY),如果直接用 hashCode 来寻址,那么相当于只有低 4 位有效,其他高位不会有影响。这样假如几个 hashCode 分别是 210、220、2^30,那么寻址结果 index 就会一样而发生冲突,所以哈希表就不均匀分布了。
为了减少这种冲突,HashMap 中让 hashCode 的高位也参与了寻址计算(进行扰动),即把 hashCode 高 16 位与 hashCode 进行异或算出 hash,然后根据 hash 来做寻址。

四、利用反射获取hashMap的容量、阈值

public static void main(String[] args) throws Exception {
        //未指定初始容量创建一个HashMap
        HashMap m = new HashMap();
        //获取HashMap整个类
        Class<?> mapType = m.getClass();
        //获取指定属性,也可以调用getDeclaredFields()方法获取属性数组
        Field threshold = mapType.getDeclaredField("threshold");
        //将目标属性设置为可以访问
        threshold.setAccessible(true);
        //获取指定方法,因为HashMap没有容量这个属性,但是capacity方法会返回容量值
        Method capacity = mapType.getDeclaredMethod("capacity");
        //设置目标方法为可访问
        capacity.setAccessible(true);
        Field loadFactor = mapType.getDeclaredField("loadFactor");
        loadFactor.setAccessible(true);
        //打印刚初始化的HashMap的容量、阈值和元素数量
        System.out.println("容量:"+capacity.invoke(m)+" 加载因子:"+loadFactor.get(m)+" 阈值:"+threshold.get(m)+" 元素数量:"+m.size());
        for (int i = 0;i<17;i++){
            m.put(i,i);
            //动态监测HashMap的容量、阈值和元素数量
            System.out.println("容量:"+capacity.invoke(m)+" 加载因子:"+loadFactor.get(m)+" 阈值:"+threshold.get(m)+"    元素数量:"+m.size());
        }
	}

运行结果:
在这里插入图片描述

容量:是取大于等于参数的最小2次幂,如果这个最小二次幂小于16的话也是取值16,这个源码中有写。

阈值:在初始化的时候,阈值是等于容量的(未定义容量时,容量为16,阈值为0);当放入第一个元素后,重新计算阈值,新的阈值=容量X负载因子。

五、hash冲突

1、出现hash冲突的原因?两个不同的key计算出相同的数组存放位置;
2、初期是怎么解决的?在出现数组冲突的位置挂一个链表,实现存放多个数据
3、JDK1.8的优化?当数组长度达到一定值后自动转换为红黑树,降低时间复杂度。

TREEIFY_THRESHOLD:树化阈值8,链表转红黑树时,链表的长度,通过概率测试得到;树化时,数组长度必须达到最小树化容量(64)
退化(反树化)阈值6:红黑树转链表。

当 链表长度大于 TREEIFY_THRESHOLD 时:
如果容量小于 MIN_TREEIFY_CAPACITY,只会进行简单的扩容。
如果容量大于 MIN_TREEIFY_CAPACITY ,则会进行树化改造

碰撞处理

通常有两类方法处理碰撞:开放寻址(Open Addressing)法和链接(Chaining)法。前者是将所有结点均存放在散列表T[0…m-1]中;后者通常是把散列到同一槽中的所有元素放在一个链表中,而将此链表的头指针放在散列表T[0…m-1]中。

(1)开放寻址法

所有的元素都在散列表中,每一个表项或包含动态集合的一个元素,或包含NIL。这种方法中散列表可能被填满,以致于不能插入任何新的元素。在开放寻址法中,当要插入一个元素时,可以连续地检查或探测散列表的各项,直到有一个空槽来放置待插入的关键字为止。有三种技术用于开放寻址法:线性探测、二次探测以及双重探测。

<1>线性探测

给定一个普通的散列函数h’:U —>{0,1,…,m-1},线性探测方法采用的散列函数为:h(k,i) = (h’(k)+i)mod m,i=0,1,…,m-1

 探测时从i=0开始,首先探查T[h'(k)],然后依次探测T[h'(k)+1],…,直到T[h'(k)+m-1],此后又循环到T[0],T[1],…,直到探测到T[h'(k)-1]为止。探测过程终止于三种情况: 

(1)若当前探测的单元为空,则表示查找失败(若是插入则将key写入其中);
  (2)若当前探测的单元中含有key,则查找成功,但对于插入意味着失败;
  (3)若探测到T[h’(k)-1]时仍未发现空单元也未找到key,则无论是查找还是插入均意味着失败(此时表满)。

线性探测方法较容易实现,但是存在一次群集问题,即连续被占用的槽的序列变的越来越长。采用例子进行说明线性探测过程,已知一组关键字为(26,36,41,38,44,15,68,12,6,51),用除余法构造散列函数,初始情况如下图所示:
在这里插入图片描述

散列过程如下图所示:
在这里插入图片描述

<2>二次探测

二次探测法的探查序列是:h(k,i) =(h’(k)+i*i)%m ,0≤i≤m-1 。初次的探测位置为T[h’(k)],后序的探测位置在次基础上加一个偏移量,该偏移量以二次的方式依赖于i。该方法的缺陷是不易探查到整个散列空间。

<3>双重散列

该方法是开放寻址的最好方法之一,因为其产生的排列具有随机选择的排列的许多特性。采用的散列函数为:h(k,i)=(h1(k)+ih2(k)) mod m。其中h1和h2为辅助散列函数。初始探测位置为T[h1(k)],后续的探测位置在此基础上加上偏移量h2(k)模m。

(2)链接法

将所有关键字为同义词的结点链接在同一个链表中。若选定的散列表长度为m,则可将散列表定义为一个由m个头指针组成的指针数组T[0…m-1]。凡是散列地址为i的结点,均插入到以T[i]为头指针的单链表中。T中各分量的初值均应为空指针。在拉链法中,装填因子α可以大于1,但一般均取α≤1。

举例说明链接法的执行过程,设有一组关键字为(26,36,41,38,44,15,68,12,6,51),用除余法构造散列函数,初始情况如下图所示:
在这里插入图片描述

最终结果如下图所示:
在这里插入图片描述

六、扩容

HashMap底层是一个数组,当数组满了之后,他会自动进行2倍扩容,用于盛放更多的数据,负载因子为0.75
resize方法兼顾两个职责,创建出事存储表格,或者在容量不满足需求的时候,进行扩容
扩容后还有一步操作:rehash,重新对每个hash值进行寻址,也就是用每个hash值跟新的数组长度 n-1 进行『&』与运算操作,扩容之后的与运算可能会导致之前的发生hash冲突的元素不再发生冲突。根据寻址运算可推测出,原来同一位置冲突的元素将会存放于原下标index位置和扩容后(index+扩容前数组大小size)两个位置。
注:多线程环境下,hashMap扩容会造成死循环问题,消耗CPU资源。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值