从JDK源码学习Hashmap

这篇文章记录一下hashmap的学习过程,文章并无涉及hashmap整个源码,只学习一些重要部分,若有表述错误还请在评论区指出~html

基本概念

Hashmap采用key算hash映射到具体的value,所以查找效率为o(1),为防止hash冲突,在数组的基础上加入链表、红黑树,为无序非线程安全的存储结构java

jdk1.8以前采用如下方式存储数据:node

左边实际上就是一个数组,右边则是key值相同的元素放到同一个链表中(图片侵删)数组

可是这种数组加单链表也存在问题,即单链表长度过长时,搜索值将耗费时间复杂度为o(n),所以jdk1.8中提出数组+链表+红黑树的方法安全

 

 

源码解析

该类是实现map接口的,而且也支持序列化、支持浅拷贝多线程

 

构造方法

第一种能够本身指定容量大小与负载因子,那么此时阈值已经肯定,使用tableSizefor来找到大于等于指定容量的最小2的次方数做为阈值,其中输入的值先-1,保证返回的值要大于等于输入值app

 

第二种能够仅指定容量,使用默认的负载因子,此时也会初始化阈值post

 

第三种使用默认的容量16以及默认的负载因子0.75性能

 

第四种是由map来建立一个hashmap,使用默认的负载因子,以及可以将map放进hashmap的容量建立(不经常使用)学习

 

默认容量1左移4位位16,这里容量大小必须为2的次方,颇有讲究 ,后面解释缘由

最大容量为2的30次方

 

默认的负载因子0.75,和扩容相关,主要表示当前hashmap的填充度

 

node表,真正存储元素的表,为2的次方,其为hashmap的一个内部类

 

    static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;  //key的hash值
        final K key;  
        V value;
        Node<K,V> next; //存储下一个节点的地址

        Node(int hash, K key, V value, Node<K,V> next) {
            this.hash = hash;
            this.key = key;
            this.value = value;
            this.next = next;
        }

<key,value>元素的个数,包括数组中的和链表中的元素

 

关键方法

 put方法,放入键值对:

首先将放入的键计算hash,而后调用putVal方法

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        Node<K,V>[] tab; Node<K,V> p; int n, i;
     //若是当前hash表为空,即尚未放入任何元素,则进行扩容操做,至关于初始化
if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length;
     //根据当前key的hash算出当前元素应该放到hash表中的下标,若是改位置为null,则放入
if ((p = tab[i = (n - 1) & hash]) == null) tab[i] = newNode(hash, key, value, null); else { Node<K,V> e; K k; //不然发生hash冲突,而且若是当前位置元素的hash和要放入元素的hash相同而且当前元素的key和要放入的key同样,则暂时保存当前冲突的node节点 if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) e = p;
//若仅仅键的hash同样,可是key并不同则首先判断是不是红黑树节点,若是是的话则将当前的键放进红黑树中,更新当前的hash表的冲突节点
else if (p instanceof TreeNode) e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value); //不然当前节点为链表
        
else {
//遍历链表(由于咱们以前已经知道每一个node节点都存储了下一个节点的地址,因此P.next变量即表明相对于当前node的下一个node,那么遍历到一个链表的尾部放入新的节点便可)
for (int binCount = 0; ; ++binCount) { if ((e = p.next) == null) { p.next = newNode(hash, key, value, null); if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st //放入后判断,若是当前hash表的长度>=7,则将当前hash位置处转为红黑树表示从而替换链表表示 treeifyBin(tab, hash); break; }
//若是遍历过程当中发现链表中存在相同的key则break退出
if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) break; p = e; //不然更新p节点为e,从而实现循环遍历链表 } }
       //若是保存冲突节点的e变量不为null,则取冲突的值,根据onlyIfAbsent没有设置或者当前value为null,都将
if (e != null) { // existing mapping for key V oldValue = e.value; //取到冲突节点的value if (!onlyIfAbsent || oldValue == null) e.value = value; afterNodeAccess(e); return oldValue; } } ++modCount; //hashmap修改次数,防止多线程冲突的 if (++size > threshold) //判断当前node节点的多少有没有到扩容的阈值 resize(); afterNodeInsertion(evict); return null; }

因此整个put的流程为:

①.首先根据要放入的key计算hash,而后根据hash获取table中的放入位置,若是当前table为空,则进行初始化

②.判断放入位置是否为空,为空则直接放入,不然判断是否为红黑树节点,不是则为链表,则遍历链表查找是否存在相同的key,没找到则放入链表尾部并判断是否须要转为红黑树(TREEIFY_THRESHOLD)

③.若查找链表找到相同key则替换,放入后要判断node节点数是否超过threshold,判断是否须要resize

 

resize方法,扩充当前容量:

final Node<K,V>[] resize() {
        Node<K,V>[] oldTab = table; //保存旧的hash表
        int oldCap = (oldTab == null) ? 0 : oldTab.length; //判断hash表的长度,如果第一次初始化则为0
        int oldThr = threshold; //取旧的阈值
        int newCap, newThr = 0; //定义新的长度和阈值
        if (oldCap > 0) { //若是以前长度大于零
            if (oldCap >= MAXIMUM_CAPACITY) { //若是以前的长度大于等于2的30次
                threshold = Integer.MAX_VALUE; //则将node节点阈值设置为2的31次-1
                return oldTab; //返回旧的hash表,再也不扩容
            }
        //不然知足扩容条件,进行扩容
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && //若是旧的容量扩大一倍小于2的30次而且旧的容量大于默认的初始化容量大小16,阈值也变为原来的2倍 oldCap >= DEFAULT_INITIAL_CAPACITY) newThr = oldThr << 1; // double threshold 则容量扩大一倍 }
//若是旧的容量为0,可是旧的阈值大于零,则多是初始化hashmap时指定了容量,则直接将新的容量设置为旧的阈值
else if (oldThr > 0) // initial capacity was placed in threshold newCap = oldThr;
//对于没有设置初始容量的状况
else { // zero initial threshold signifies using defaults //若是是第一次初始化,则设置容量为16,阈值为16*0.75=12,即hashmap能够放12个node节点 newCap = DEFAULT_INITIAL_CAPACITY; newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); }
//若是新的阈值为0,则进行修正,令新的阈值为新的hash表容量长*负载因子
if (newThr == 0) { float ft = (float)newCap * loadFactor; newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ? (int)ft : Integer.MAX_VALUE); }
//设置完新的容量和新的阈值后,则开始进项node节点元素转移 threshold
= newThr; //先将新生成的阈值赋值给成员变量threshold @SuppressWarnings({"rawtypes","unchecked"}) Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap]; //而后声明一个新的节点数组,容量即为扩充后的大小 table = newTab; //替换成员标量table为新表 if (oldTab != null) { //遍历旧的容量大小,取其每一个node节点 for (int j = 0; j < oldCap; ++j) { Node<K,V> e; if ((e = oldTab[j]) != null) { //若是该节点不为null oldTab[j] = null; //则让旧表的该位置为null,进行垃圾回收 if (e.next == null) //若是当前遍历的节点下一个为null,说明为尾节点(单个node节点,无链表,无红黑树) newTab[e.hash & (newCap - 1)] = e; //则直接将该节点放到新的hash表中 //若是下一个节点不为null,则判断当前节点是不是红黑树节点,如果,则将新标的该节点转为红黑树节点
else if (e instanceof TreeNode) ((TreeNode<K,V>)e).split(this, newTab, j, oldCap); //不然为单链表节点,则遍历当前链中的节点决定要放入新hash表的位置
else { // preserve order Node<K,V> loHead = null, loTail = null; Node<K,V> hiHead = null, hiTail = null; Node<K,V> next;
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; } } while ((e = next) != null); if (loTail != null) { loTail.next = null; newTab[j] = loHead; } if (hiTail != null) { hiTail.next = null; newTab[j + oldCap] = hiHead; } } } } } return newTab; }

这里设计很妙,原来的容量为2的次方,则只有1位为1,原来的下标是容量-1,则新增的一位bit,决定了节点hash新增的一位为1仍是为0,来决定其存放位置,其也为随机的,从而均匀地将节点放到新的hash表中,新增一位为0则放到低位中,即索引值不变,新增一位为1,则放到高位中,这样本来在一条链中的节点就可以分布到两条链上,也减小了搜索的开销

jdk1.7和1.8的Hashmap区别

1.jdk1.7中发生hash冲突新节点采用头插法,1.8采用的为尾插法

2.1.7采用数组+链表,1.8采用的是数组+链表+红黑树

3.1.7在插入数据以前扩容,而1.8插入数据成功以后扩容

总结

1.在算key的hash时将key的hashcode和与hashcode的高16位作异或下降hash冲突几率

2.HashMap 的 bucket (数组)大小必定是2的n次方,便于后面等效取模以及resize时定节点分布(low或者high)

3.HashMap 在 put 的元素数量大于 Capacity * LoadFactor(默认16 * 0.75)=12 以后会进行扩容,负载因子大于0.75则会减少空间开销,

4.影响hashmap性能的两个参数就是负载因子和初始容量,扩容影响性能,所以最好能提早根据负载所以估算hashmap大小,扩容其实是将当前node节点放入一个新的node数组

5.tab[i = (n - 1) & hash] 实际上用与运算代替取模操做,性能更好,n即为容量大小,n为2的次方,则n-1则其二进制位为全1,从而代替模运算,e.hash & oldCap 用与运算决定hash增长的一位为0或者为1

关于负载因子设置:

负载因子的大小决定了HashMap的数据密度。
负载因子越大密度越大,发生碰撞的概率越高,数组中的链表越容易长,形成查询或插入时的比较次数增多,性能会降低。
负载因子越小,就越容易触发扩容,数据密度也越小,意味着发生碰撞的概率越小,数组中的链表也就越短,查询和插入时比较的次数也越小,性能会更高。可是会浪费必定的内容空间。并且常常扩容也会影响性能,建议初始化预设大一点的空间。
按照其余语言的参考及研究经验,会考虑将负载因子设置为0.7~0.75,此时平均检索长度接近于常数

参考

https://zhuanlan.zhihu.com/p/72296421

http://www.javashuo.com/article/p-ftqonsun-kq.html

http://www.javashuo.com/article/p-abqiemse-gx.html

https://tech.meituan.com/2016/06/24/java-hashmap.html

 http://www.javashuo.com/article/p-niykhntj-n.html

http://www.javashuo.com/article/p-eiwmdbul-cu.html 1.7和1.8区别

相关文章
相关标签/搜索