HashMap源码学习

3 篇文章 0 订阅

带着几个问题:
众所周知在jdk 1.8 hashmap的数据结构变成了链表+数组+红黑树,那么

  • 相比于原本的链表和数组的结构,优点在哪?
  • 哈希碰撞如何解决?
  • 扩容时机和扩容原理?
  • ConcurrentHashMap原理?
    直接看代码吧
    几个参数:
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; //默认初始容量
 static final int MAXIMUM_CAPACITY = 1 << 30;  //最大容量2^30
static final float DEFAULT_LOAD_FACTOR = 0.75f; //默认加载因子 也就是0.75F
static final int TREEIFY_THRESHOLD = 8;  // 链表长度大于8时树化,数据8是由hashmap作者计算而出
static final int UNTREEIFY_THRESHOLD = 6;  //还原成链表的阈值
static final int MIN_TREEIFY_CAPACITY = 64;//最小树化容量

由上面几个参数和英文注释,个人得出以下结论:
如果冲突的节点数已经达到8个,看是否需要改变冲突节点的存储结构,treeifyBin首先判断当前hashMap的长度,如果不足64,只进行resize,扩容table,同时最小树形化容量阈值:即 当哈希表中的容量 > 该值时,才允许树形化链表 (即 将链表 转换成红黑树), 否则,若桶内元素太多时,则直接扩容,而不是树形化,为了避免进行扩容、树形化选择的冲突,这个值不能小于 4 * TREEIFY_THRESHOLD,这里就是64

一定是在大于MIN_TREEIFY_CAPACITY的时候,才会树化,无论链表的长度是否大于 TREEIFY_THRESHOLD

思考第一个问题,优点在哪?

相比于jdk1.7的数组+链表结构,先看看hshMap在jdk 1.8的结构,如下图,用的是数组+链表+红黑树的结构,也叫哈希桶,在jdk 1.8之前都是数组+链表的结构,因为在链表的查询操作都是O(N)的时间复杂度,而且hashMap中查询操作也是占了很大比例的,如果当节点数量多,转换为红黑树结构,那么将会提高很大的效率,因为红黑树结构中,增删改查都是O(log n)。
在这里插入图片描述
但阈值的存在毫无疑问是必要的,红黑树虽然效率高,但红黑树本身是一个复杂的数据结构,涉及到自旋的操作,在链表节点数量比较少的时候,很明显链表增删改查更为合适,所以并不能直接采用红黑树

再来看看hashmap存入数据的源码分析

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        if ((tab = table) == null || (n = tab.length) == 0) //首次初始化的时候table为null
            n = (tab = resize()).length; //对HashMap进行扩容
        if ((p = tab[i = (n - 1) & hash]) == null) //根据hash值来确认存放的位置。如果当前位置是空直接添加到table中
            tab[i] = newNode(hash, key, value, null);
        else {
            //如果存放的位置已经有值
            Node<K,V> e; K k;
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p; //确认当前table中存放键值对的Key是否跟要传入的键值对key一致
            else if (p instanceof TreeNode) //确认是否为红黑树
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            else {//如果hashCode一样的两个不同Key就会以链表的形式保存
                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 判断链表长度是否大于8
                            treeifyBin(tab, hash);
                        break;
                    }
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }
                
                if (e != null) { // existing mapping for key
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value; //替换新的value并返回旧的value
                afterNodeAccess(e);
                return oldValue;
            }
        }
        ++modCount;
        if (++size > threshold)
            resize();//如果当前HashMap的容量超过threshold则进行扩容
        afterNodeInsertion(evict);
        return null;
    }

第二个问题 哈希碰撞

搬运一下 哈希碰撞浅谈

扩容时机和扩容原理

扩容的时机:

存在两种情况:

  • 设定threshold, 当threshold = 默认容量(16) * 加载因子(0.75)的时候,进行resize()
  • 如上文所说,treeifyBin首先判断当前hashMap的长度,如果不足64,只进行resize,扩容table,同时最小树形化容量阈值:即 当哈希表中的容量 > 该值时,才允许树形化链表

扩容原理

扩容一般是把长度扩为原来2倍,所以,元素的位置要么是在原位置,要么是在原位置再移动2次幂的位置。

这里有一个tableSizeFor方法,主要作用是会return一个大于给定整数的2的幂次方树,例如给定10,就会返回16,通过位运算可以验证在这里插入图片描述
至于为什么一定要是2的幂次方呢?


static int indexFor(int h, int length) {
    // assert Integer.bitCount(length) == 1 : "length must be a non-zero power of 2";
    return h & (length-1);
}

看下图,左边两组是数组长度为16(2的4次方),右边两组是数组长度为15。两组的hashcode均为8和9,但是很明显,当它们和1110“与”的时候,产生了相同的结果,也就是说它们会定位到数组中的同一个位置上去,这就产生了碰撞,8和9会被放到同一个链表上,那么查询的时候就需要遍历这个链表,得到8或者9,这样就降低了查询的效率。同时,我们也可以发现,当数组长度为15的时候,hashcode的值会与14(1110)进行“与”,那么最后一位永远是0,而0001,0011,0101,1001,1011,0111,1101这几个位置永远都不能存放元素了,因为hashmap在计算存放位置时,会发现最后一位一直是0,自然最后一位是0的位置就无法再放入元素,空间浪费会相当大,更糟的是这种情况中,数组可以使用的位置比数组长度小了很多,这意味着进一步增加了碰撞的几率,减慢了查询的效率!
在这里插入图片描述
所以说,当数组长度为2的n次幂的时候,不同的key算得得index相同的几率较小,那么数据在数组上分布就比较均匀,也就是说碰撞的几率小,相对的,查询的时候就不用遍历某个位置上的链表,这样查询效率也就较高了。
这也是为什么默认值选为16,在小数据量的时候,16作为2的4次方,更能减少key之间的碰撞,提高查询的效率。

同时在Jdk 1.8中,在扩容HashMap的时候,不需要像1.7中去重新计算元素的hash,只需要看看原来的hash值新增的哪个二进制数是1还是0就好了,如果是0的话表示索引没有变,是1的话表示索引变成“oldCap+原索引”,这样即省去了重新计算hash值的时间,并且扩容后链表元素位置不会倒置。这也是扩容为2的幂次方的好处。配合源码理解:

final Node<K,V>[] resize() {
        Node<K,V>[] oldTab = table;
        int oldCap = (oldTab == null) ? 0 : oldTab.length;//获取当前数组大小oldCap
        int oldThr = threshold;//获取阈值
        int newCap, newThr = 0;
        //判断是初始化还是扩容
        if (oldCap > 0) {//说明已经初始化
            if (oldCap >= MAXIMUM_CAPACITY) {//说明底层数组长度已经达到最大容量
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)//数组和threshold扩大为原来的两倍
                newThr = oldThr << 1; // double threshold
        }
        else if (oldThr > 0) // initial capacity was placed in threshold
            newCap = oldThr;//说明调用了hashmap的有参构造函数,因为无参构造函数并没有对threshold进行初始化
        else {               // zero initial threshold signifies using defaults
            newCap = DEFAULT_INITIAL_CAPACITY;//调用无参构造函数
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
        }
        if (newThr == 0) {
            float ft = (float)newCap * loadFactor;
            newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                      (int)ft : Integer.MAX_VALUE);
        }
        /*以上代码总结:
          1.如果已经对底层数组初始化就进行扩容
          2.如果数组长度已经是最大整数值了,最大值赋给threshold,不会在进行扩容
          3.如果没有达到,数组长度扩展两倍,threshold扩招为原来的两倍
        */
        threshold = newThr;//把上面计算来的newThr赋值给threshold
        @SuppressWarnings({"rawtypes","unchecked"})
            Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
        table = newTab; //扩容后新数组给底层table
        if (oldTab != null) {//若是扩容,执行以下方法,不是扩容则revise()结束
            for (int j = 0; j < oldCap; ++j) {//对老数组像新数组迁移数组
                Node<K,V> e;
                if ((e = oldTab[j]) != null) {
                    oldTab[j] = null;
                    if (e.next == null)
                        **newTab[e.hash & (newCap - 1)] = e;** 这一步决定了数据存放位置的算法,上文已经有介绍
                    else if (e instanceof TreeNode)
                        ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);//至此 说明下标是红黑树
                    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;
    }

注释感觉写的不便于阅读,这里对后半段代码关于三种情况存储 做一个解释:

  • e.next == null的时候,也就是只有一个元素时最简单,直接通过(newCap-1)&hash找到需要放入的新下标,然后直接放入即可。
  • if(e instanceof TreeNode)也就是e是红黑树结构时,这个方法并没有直接操作,而是调用了红黑树的split方法对此条件进行处理,源码从2133行开始是红黑树的相关操作,后面再进行红黑树的学习。
  • 当指定下标的一个链表的时候
                        Node<K,V> loHead = null, loTail = null;//lohead用户存储低位(位置不变)的key链表头,loTail用于存储链表尾
                        Node<K,V> hiHead = null, hiTail = null;//高位存储
                        Node<K,V> next;
                        do {
                            next = e.next;
                            //与原数组长度相与之后,得到结果为0,意味着在新数组中下标不变,组成新的链表
                            if ((e.hash & oldCap) == 0) {
                                if (loTail == null)
                                    loHead = e;
                                else
                                    loTail.next = e;
                                loTail = e;
                            }
                            else {
                            //结果非0的时候,需要存储在新增的数组中的一个新的位置,形成一个链表
                                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;//将位置加上原数组的长度,就是新的位置坐标
                        }

ConcurrentHashMap

ConcurrentHashMap是位于JUC包下的类
在JDK1.7中,ConcurrentHashMap 是一个 Segment 数组,Segment 通过继承ReentrantLock 来进行加锁,所以每次需要加锁的操作锁住的是一个 segment,这样只要保证每个 Segment 是线程安全的,也就实现了全局的线程安全。
在这里插入图片描述

concurrentHashMap的默认大小同样为16,大多数参数和hashmap完全一致,可以理解为线程安全的
在这里插入图片描述
下图为查找到的jdk1.7的concurrenthashmap的数据结构
在这里插入图片描述
可以看到是由一个segment数组和16个hashEntry组成,每个hashEntry看作为一个table,单独加锁。

JDK1.8的实现已经摒弃了Segment的概念,而是直接用Node数组+链表+红黑树的数据结构来实现,并发控制使用Synchronized和CAS来操作,整个看起来就像是优化过且线程安全的HashMap,虽然在JDK1.8中还能看到Segment的数据结构,但是已经简化了属性,只是为了兼容旧版本。
btw,此处diss一下网上多数文章,还是以jdk1.7的segment作为concurrenthashmap的数据结构,极易误导人(例如当初的我)
下图是JDK1.8的concurrenthashmap数据结构
在这里插入图片描述
Node是ConcurrentHashMap存储结构的基本单元,继承于HashMap中的Entry,用于存储数据
在这里插入图片描述
至于concurrenthashmap的原理分析,本人学习于ConcurrentHashMap原理分析,还在学习总结中,就不误人子弟了。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值