HashMap注释/源码解读笔记(基于JAVA8)

2019-03-25  本文已影响0人  神驱一梦

数据结构:

  为一个Node<K,V>[] 数组,其中Node是一个链表节点,数组元素要么是空要么是一个 链表/树 的头

注释翻译及记录:

  • 1.传入的 Map 对象 m
  • 2.一个名为 mutexObject 对象

它保持线程安全的方式很简单,其实就是在 Map 接口的实现方法内通过 synchronizedmutex 对象加锁,然后在内部再调用 m 相应的方法,实际上感觉跟 HashTable 差不多?区别只在于 HashTable 直接在方法上加锁,而这里是用了一个对象作为锁,但是没实际测过性能,这里暂时留空

问题:注释中没有解释设置这两个数的原因,但个人认为,树结构和链表结构在空间复杂度上是一样的,之所以转成树也是为了在哈希碰撞严重时加强访问效率,因此应该与链表的访问时间复杂度O(n)和树的节点的访问时间复杂度O(log2n)有关系,可能是在 log2n=n 这个我猜出来的方程式中算出一个数作为转变的临界点(请原谅我已经忘记对数方程怎么解了,这个方程也不知道猜得对不对),长度大于这个数字时树结构访问速度比链表快。而设置解除树结构的数字为6则是为了做一个缓冲,避免长度在临界点附近+1-1重复操作而频繁更换数据结构

源码查看记录:

1. 构造方法: HashMap 的构造方法有4个重载

  1. 无参,会将 load_factor 赋值为默认的 0.75f (也是最常用的方法)
  2. ( int initialCapacity ) 传入一个初始化的数组长度,然后调用了方法3
  3. ( int initialCapacity,float loadFactor)传入初始化的数组长度(会通过tableSizeFor转换成2的次方)和 loadFactor
  4. ( Map<K,V> m)将loadFactor设置成默认值,然后将map对象的全部元素加入

2. hash方法

  1. keynull时返回0
  2. key不为空,取得其hash值,将hash与无符号右移16位的hash做异或运算
    此动作相当于将hash值的高16位与低16位进行异或运算,此举有助于在数组长度较小时减少哈希碰撞的现象,
      例如 00 1100 0011
       与 00 0000 0011
    在数组长度较小时(例如刚初始化,数组长度只有16,则16-1=1111),在计算数组下标(看put方法)的时候算出来```index是一样的,但如果先将较大的数自身的高低二进制段先进行异或,则可以将这种碰撞减少

3. put方法:

  put方法其实内部直接调用了putVal方法,其有4个参数

  final V putVal(int hash, K key, V value, boolean onlyIfAbsent,boolean evict)

  evict不用管,onlyIfAbsent是确认当key已存在的时候,false为替换掉已存在的valuetrue则不替换

执行过程

  1. 检查数组table是否为空,是则通过resize方法初始化;
  2. 通过(size-1)&hash获得一个<size的数字作为数组下标;
  3. 如果下标所在元素为空,直接new Node放进去;
  4. 如果下标不为空,检查元素是否为树节点,是则通过插树的方式插入putTreeVal
  5. 否则遍历链表,遍历过程中如果找到相同的key,且onlyIfAbsentfalse,替换已存在的value,并返回oldValue
  6. 无重复key则在链表尾插入,插入后若链表长度大于TREEIFY_THRESHOLD,通过treeifyBin将当前链表转为树结构(红黑树,节点类为TreeNode<K,V> extends LinkedHashMap.Entry<K,V>
  7. 插入结束后,modCount+1size+1
  8. size>threshold(即capacity*loadFactor得出的阈值),则进行resize

4. tableSizeFor(int)方法:

可以将传入的capacity转成最接近比本身大的的2的指数,例如传入5会变成16,10也会变成16,17变成32

5.resize方法:

  1. 注释:将table数组初始化或将其长度加倍。如果为null,则根据初始capacity所转换的threshold变量而分配容量;
    否则,因为我们是使用2的指数对其进行膨胀,所以HashMap里每个桶的元素都应该留在原位,或移动到其原来+膨胀前的数组长度的下标
  2. 过程:
  1. 用一个oldcap存储当前数组长度,一个oldthr存储当前的threshold
  2. 如果oldcap>0(表示当前数组不为空)
  • 1). 判断是否大于最大长度,是则以最大长度值赋值;
  • 2). ① 小于则将当前oldcap<<1(相当于*2)赋值给newcap,且如果newcap >= DEFAULT_INITIAL_CAPACITY,则将newthr = oldthr<<1
    ②如果cap=0,且oldthr>0,则将oldthr赋值到newcap作为初始化长度(因为在构造的时候暂时将initCap赋值给了threshold,参考构造方法)
    ③上述条件都不通,则使用默认cap(16)和thr(16*0.75=12)赋值
  • 3). 如果newthr为0,则将cap*loadfactor
    P.S.此处主要用于2.分支中的①(1)(即cap已经达到最大长度时)和②(cap指定了初始值时),这两个分支中newthr未指定值
  • 4). 将newthr赋值给threshold成员
  • 5). 计算出newcapnewthr之后,使用newcap初始化Node数组
  • 6).if(oldTab!=null) 通过判断oldTab数组不为空可知道此时处于扩容阶段,扩容阶段才是resize方法中的精髓,
    以下为扩容时的逻辑:
  • (1). 遍历oldTab数组,遍历下标为 jif当前元素e不为null时执行下一步,否则跳过
  • (2). if(e.next==null),即当前元素的链表只有e这一个元素,将 e.hash &(newTab.size-1) 获得e的新下标并存入newTab因为只有一个元素了,也没什么必要做特殊处理
  • (3). else if(e instanceof TreeNode)e为一个红黑树表,执行e.split方法,将这个树拆开
    P.S.红黑树部分放到后面再讨论
  • (4). else,只剩下e为一个大于1的链表时的情况了,接下来是在下思考计算了很久才弄明白的部分:
  • ①. 定义一个loHead链表,一个hiHead链表,根据变量名可以得知,一个要放在低位,一个放在高位,那么这个高位低位分别指什么呢?继续往下看;
  • ②. 遍历当前下标的链表,对每一个元素计算 e.hash & oldCap
  • a. 如果这个值 == 0 则将当前元素e其放入loHead链表
  • b. 否则,将这个元素e放入hiHead链表
  1. 遍历结束,如果loHead链表不为空,则将其放入当前的遍历下标 newTab[j]
  2. 同时,如果hiHead链表不为空,将其放入newTab[j+oldCap]
  • 第 4.2~4.4 步的原因:
    1. 因为这里之所以是 & oldCap,是为了检查元素的hash值在扩容后左移的那一位是否有1,如果没有,则计算得出0,则其即便是计算 hash & (newCap-1) 计算出来的新数组下标也是与扩容前一样,所以可以直接沿用原下标;
    1. 那么同理,如果 & oldCap 不为0,则相当于在 原有下标+原有长度=新下标,这一步需要通过实际的运算,请看下面的运算验证过程
      P.S.由于在下愚笨,第一次看这里的时候非常不解,经过多次debug和计算之后才明白,
      在此将在下的验证计算过程记录如下:

首先取一个数57,二进制为0011 1001
数组初始化时,数组长度为16,
二进制为 0001 0000,16-1=15=0000 1111

计算过程 二进制数
57 0011 1001
&(16-1) 0000 1111
9 0000 1001

数组扩容,新的数组长度为16<<1=32,二进制为 0010 0000 ,32-1=31=0001 1111

计算过程 二进制数
57 0011 1001
&(32-1) 0001 1111
=9+16=25 0001 1001

观察上列二进制数其实可以得出一个特点就是,由于扩容的过程都是在不断左移,所以也可以看作数组的长度值capacity是不停左移然后尾部加“1”,由于高位依然是一堆0,所以在hash值高位的1依然是没意义的,而低位&计算出来的值不变,所以关键在于数组扩容时左移的一位【1】的位置,是否刚好hash值在那一个位置也有【1】,如果有,那么直接将oldCap加上,其实就相当于在【原有数组下标数字的二进制数,的同样一位加了一个“1”】,可以此计算出新的数组下标

上面这段描述写得有点蛋疼……不知道看本文的大家能不能明白,如果看不明白的话请结合表格中的二进制计算过程一起思考,实在不行就自己算一遍吧,表达能力有限而且对位运算不熟悉,望见谅

那么resize方法到这里就结束了。

6.treeifyBin方法(先读懂树结构的构造过程,再看如何get,put树节点就简单了)

这里开始之前先看看TreeNode类的结构:

  • TreeNode继承了LinkedHashMap.Entry,这个Entry又继承了HashMap.Node类,所以TreeNode也具有链表的结构特点
  • TreeNode定义了parentleftrightprev 这4个引用可以看出其具有二叉树的特点,
  • parent记录上级节点;prev用于记录上一个节点,相当于双向链表
  • TreeNode还定义了一个boolean red变量,从这里应该可以知道它就是传说中的 红黑树 的数据结构

执行过程

  1. 如果tab数组为null,或者tab.length < MIN_TREEIFY_CAPACITY(常量值为64),则先resize
    1.) 不大明白为什么调用treeifyBin的时候tab数组会为空;
    2.) 后面的条件应该是为了避免在数组长度很小时也转换成树,还不如进行数组扩容来减低哈希碰撞
  2. 第二步,当 (tab.length-1) & hash 获得的数组下标所得元素e不为空,
    进行转换成树结构的步骤:
  3. 定义两个TreeNodehd(head)tl(tail)
  4. 进入do while循环,遍历元素e所在链表
    1). 将当前的Node e通过replacementTreeNode转换成TreeNode p
    这个方法就是new TreeNode,不必深究
    2). 一个if判断,
  1. 如果tl==nullhd=p,就是一个初始化动作,将hd指向第一个TreeNode
  2. 否则将p.prev=tl,记录上一个节点
  3. tl=p,存放当前节点为尾部
  4. e=e.next,进入下一个节点
    第二步实际上建立起来的还是一个链表,只是变成了一个双向链表而已
  1. hd头结点赋值给tab[index],如果hd头结点不为null,则调用hd.treeify(tab)方法,将hd链表转换成真正的红黑树结构

7. get(getNode)方法

  • 其实把putresize的过程搞懂了之后这里很简单了,取keyhash值,通过 hash & (size-1) 得到数组下标,然后遍历下标对应的链表找到key相同的node,返回node.value



最后来一波小总结

从上面的解读笔记可以看到那么几个点:

  1. HashMap内部的各种计算操作中大量运用了位运算,对于位运算不熟悉的朋友(包括在下)在初读的时候会造成很多疑惑,至于为什么不使用更简单的数学运算,因为位运算运行效率比一般的数学运算高得多,特别是在并发量特别高,同时应用HashMap又比较频繁的应用内,这一点一滴的性能提升统计起来其实也是一个非常惊人的提升量。对此不理解的童鞋回去复习下编译原理(还是说操作系统原理?其实我也忘了)吧,这里就不多解释了
  2. 在理解1.的基础上再去读它内部的代码,特别是resize,put操作会发现很多操作非常的秒。例如resize扩容时,如果一般人仅仅为了实现功能了事的话,估计简单的写个循环遍历所有的node,然后调用put方法加到新的数组就了事了,这样明显是非常低效的做法(但不可否认这种做法最偷懒),而在HashMap内则不是,它结合了自己进行位运算时数据的变化特点而直接将整个链表拆分开放到正确的下标内,看明白之后感觉这一步简直是妙到毫巅……
  3. 同时还可以看到JDK源码内部的注释都写得非常的详细清楚,可以说是【将用户当成SB一样去写我的用户手册】的一种典型,这是作为开发者的我们所需要学习的精神



红黑树相关部分:

红黑树太难了……先留空,后续有精力了再补

8. TreeNode.treeify(Node[] tab)方法:

9. TreeNode.split方法

10. TreeNode.putTreeVal方法

11. TreeNode.getTreeVal方法

上一篇下一篇

猜你喜欢

热点阅读