正在加载中,请稍后

HashMap底层原理

HashMap底层原理

HashMap的数据结构:

HashMap底层数据结构是由数组链表红黑树组成的,红黑树是jdk1.8之后加入的,当链表长度超过8时,并且数组长度大于64,链表就会转化成红黑树。

HashMap的工作原理:

HashMap中的每一个元素,也就是键值对 node对象(key,value,hash,next),都是通过put()get()方法来存储和获取的。

存储对象时,调用put()方法,把键和值传给put()方法,如果容器中的元素个数大于设置的容量*扩容因子,就会调用resize()方法进行扩容。然后调用hash(K)计算键的 hash 值,根据hash 值和数组长度计算数组下标index,index=(length - 1)&hash

如果键的HashCode没有发生hash冲突,则根据计算的数组下标直接插入到数组中;

如果发生了hash冲突,就会调用equals()方法,对比键值是否相等,返回ture则替换原来的键值对。如果返回false,就会把键值对插入链表,jdk1.8采用的是尾插法,之前的版本采用的是头插法,如果是红黑树,则采用红黑树的插法。

获取对象时,调用get()方法,计算key的HashCode值,如果没有发生hash冲突,就直接到数组取数据,如果发生了hash冲突,就调用equals()方法对比key,到链表或者红黑树中取查找对象。

HashMap 的tableSizeFor()初始化容器大小:

HashMap的默认初始容器大小是16,默认扩容因子是0.75

当我们指定容器大小size时,会调用tableSizeFor方法对容器大小进行初始化,过程是先将n=size-1,

然后n |=n>>>1;n |=>>>2; n |=>>>4; n|=>>>8; n|=>>>16;最后算出size=n+1;(依次或上自己的无符号右移1、2、4、8、16位)

为什么需要先减1?

因为如果传入的大小为16时,不减1的话,直接算出的大小为32,不符合我们的需求。

HashMap中的hash算法

什么是hash函数:hash 函数,即散列函数,或叫哈希函数。它可以将不定长的输入,通过散列算法转换成一个定长的输出,这个输出就是散列值。需要注意的是,不同的输入通过散列函数,也可能会得到同一个散列值。因此我们不能使用散列函数来获取唯一值。

让hashcode与hashcode的无符号右移16位进行异或运算。将h无符号右移16为相当于将高区16位移动到了低区的16位,再与原hashcode做异或运算,可以将高低位二进制特征混合起来

我们都知道重新计算出的新哈希值在后面将会参与hashmap中数组槽位的计算,计算公式:index=(length - 1)&hash

如果不做刚才移位异或运算,那么在计算槽位时将丢失高区特征也许你可能会说,即使丢失了高区特征不同hashcode也可以计算出不同的槽位来,但是细想当两个哈希码很接近时,那么这高区的一点点差异就可能导致一次哈希碰撞,所以这也是将性能做到极致的一种体现

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

最终目的还是为了让哈希后的结果更均匀的分部,减少哈希碰撞,提升hashmap的运行效率。

HashMap的容量为什么是2的幂次方数?

  • 使数据分布均匀,减少碰撞
  • 当length为2的n次方时,(length - 1)&hash 就相当于对length取模,而且在速度、效率上比直接取模要快得多,因为位运算比取模运算快,位运算是二进制数的运算,取模运算时十进制数的运算,需要进制转化。这个前提是length是2的n次方,所以初始化容器大小的时候就要用tableSizeFor()保证容器大小是2的幂次方。

HashMap的resize()扩容方法

如果HashMap内的元素个数大于容器大小*扩容因子,就会发生扩容

创建一个新的数组,其容量为旧数组的两倍,并重新计算旧数组中结点的存储位置。结点在新数组中的位置只有两种,原下标位置或原下标+旧数组的大小。

resize()方法中,先把旧数组的所有数据移到新数组中,然后巧妙地使用了(node.hash & oldSize)=0,把数组从新分为两类,一类是位置没有发生改变的,另一类是位置发生改变的,当然,前提还是数组的长度是2的n次方

① 等于0时,则将该头节点放到新数组时的索引位置等于其在旧数组时的索引位置,记为低位区链表lo开头-low;
② 不等于0时,则将该头节点放到新数组时的索引位置等于其在旧数组时的索引位置再加上旧数组长度,记为高位区链表hi开头high.

红黑树的特点,HashMap为什么要使用红黑树,链表什么时候转化成红黑树。

红黑树的节点非黑即红,根节点一定是黑的,红黑树根节点左边的数一定小于右边的数。

因为链表边长了之后,遍历速度会急剧下降,所以jdk1.8之后引入了红黑树,因为红黑树的特点,它的遍历速度相对来说比链表快了很多,而且插入元素的速度也不慢,大大提高了查询性能,平均查找长度从O(n)降低到O(logn)

链表在长度大于8并且HashMap的size大于64时,就会把链表转化成红黑树。当size小于6时,红黑树会换成链表

Q: 为什么是8?

A:因为链表的平均查询长度是n/2,红黑树的平均查询长度是log(n);

等于8时:8/2=4;log(8)=3红黑树比链表快;

等于6时:6/2=3;log(6)=2.6;虽然这时红黑树也比链表快,但是这时会导致链表和红黑树之间频繁转化,导致性能下降,得不偿失,所以8最合适。

HashMap线程不安全表现在哪?

在多线程环境下,HashMap在put元素时,容易发生线程不安全,jdk1.7在扩容resize()时可能会形成环链,因为发生hash冲突时,jdk1.7采用的是头插法,在扩容的时候,resize()的链表插入也是用的头插法,同一位置上的新元素会被放在最头部,假如有两个entry,A和B,A.next->B;在扩容时,多线程操作下可能B插入到A头部,B.next->A,这样就形成了环链。jdk1.8采用尾插法,在put元素时,多线程可能运行到同一行代码,都已经计算完hash,这两条数据的hash值一样,并且下一个元素是空时,同时往里面插数据,导致数据被覆盖。所以HashMap不能保证线程安全,如果要在多线程下使用,建议使用ConcurrentHashMap。

ConcurrentHashMap、HashMap、HashTable的一些区别:

HashMap和HashTable在jdk1.7时,底层都是采用数组加链表来实现的,区别在于多线程环境下,HashTable使用了synchronized关键字,来锁住整个对象,保证线程安全。在jdk1.8之后,HashMap底层做了一些优化,加入了红黑树,但还是线程不安全的。

HashMap和ConcurrentHashMap,底层实现基本相同,就是在多线程环境下,ConcurrentHashMap采用了CAS+synchronized关键字来实现锁分段技术保证线程安全。

ConcurrentHashMap和HashTable,HashTable使用synchronized关键字来锁住整个对象,当一个线程在对HashTable进行put操作时,其他线程会来竞争锁,只能等到上一个线程释放锁,这样效率低下。在jdk1.7时,ConcurrentHashMap采用的是数组加链表和分段锁来实现线程安全的,在jdk1.8,加入了红黑树,并且采用了CAS和synchronized关键字来实现线程安全。

总结一下:就是ConcurrentHashMap结合了HashMap和HashTable两者的优势,既保证了性能高,有实现了性能安全。而HashMap只有性能高,没有线程安全。所以一般情况下,我们会优先使用HashMap,在考虑到并发的环境下,使用ConcurrentHashMap。

Q:什么是CAS? Compare and swap

CAS算法的过程是这样:它包含三个参数CAS(V,E,N)。V表示要更新的变量,E表示预期值,N表示新值。仅当V值等于E值时,才会将V的值设为N,如果V值和E值不同,则说明已经有其他线程做了更新,则当前线程什么都不做。

ConcurrentHashMap的一些细节

ConcurrentHashMap不允许键值为空。

ConcurrentHashMap的默认并发数是16,可以通过构造函数设置,当我们设置了并发数n后,实际的并发数会是大于n的2的幂次方数,例如设置15,实际是16。

ConcurrentHashMap的锁锁住的只是链表和红黑树的根节点。

posted @ 2021-04-27 20:59  wode虎纹猫  阅读(702)  评论(0)    收藏  举报
Live2D