数据结构:
为一个Node<K,V>[] 数组,其中Node是一个链表节点,数组元素要么是空要么是一个 链表/树 的头
注释翻译及记录:
- HashMap 的遍历效率,与 HashMap 实例的capacity和 size 有关系,所以如果在重视遍历效率的场景下,不能在实例初始化时将 capacity 设置过高或者将 load factor 设置过低
-
默认的 load factor 设置为0.75,是一个时间空间复杂度互相平衡的数字,如果>0.75,会使空间复杂度更低,但加大大部分操作(特别是 get 和 put )的时间复杂度;同理,如果<0.75,则会减轻整体的时间复杂度,但增加空间复杂度
-
HashMap 不是线程安全的集合类,如果想使用线程安全的 HashMap ,同时又不想使用 ConcurrentHashMap ,可以通过
Map m=Collections.synchronizedMap(new HashMap(...));
这种方法实例化 HashMap
P.S.去看了下这个方法内的源码,发现其实际上内部定义了一个内部类 SynchronizedMap 在调用后会返回这个类的实例,其内部有两个成员
- 1.传入的 Map 对象 m ,
- 2.一个名为 mutex 的 Object 对象
它保持线程安全的方式很简单,其实就是在 Map 接口的实现方法内通过 synchronized 对 mutex 对象加锁,然后在内部再调用 m 相应的方法,实际上感觉跟 HashTable 差不多?区别只在于 HashTable 直接在方法上加锁,而这里是用了一个对象作为锁,但是没实际测过性能,这里暂时留空
-
在 iterator (遍历器)创建后,若 HashMap 发生了结构化的变更(即 size 发生了变更),那么原有 iterator 在调用任何方法都会抛出
ConcurrentModificationException
P.S.由于 JAVA 内所有 foreach 的语法糖(不管是老式的 foreach 还是新的 lambda 流),实际上在内核都是使用了 iterator 来实现的,所以如果在使用 foreach 语法块内对集合类进行了任何会对 size 产生变化的操作,都会抛出上述异常 -
TREEIFY_THRESHOLD=8 ,当数组某个下标 i 的链表长度大于这个常量,则将其转变为树结构(红黑树)以加强访问性能
-
UNTREEIFY_THRESHOLD=6 ,当 HashMap 进行 resize 时,某个下标的树 size 小于这个常量,则将其转回去成为链表
问题:注释中没有解释设置这两个数的原因,但个人认为,树结构和链表结构在空间复杂度上是一样的,之所以转成树也是为了在哈希碰撞严重时加强访问效率,因此应该与链表的访问时间复杂度O(n)和树的节点的访问时间复杂度O(log2n)有关系,可能是在 log2n=n 这个我猜出来的方程式中算出一个数作为转变的临界点(请原谅我已经忘记对数方程怎么解了,这个方程也不知道猜得对不对),长度大于这个数字时树结构访问速度比链表快。而设置解除树结构的数字为6则是为了做一个缓冲,避免长度在临界点附近+1-1重复操作而频繁更换数据结构
源码查看记录:
1. 构造方法: HashMap 的构造方法有4个重载
- 无参,会将 load_factor 赋值为默认的 0.75f (也是最常用的方法)
( int initialCapacity )
传入一个初始化的数组长度,然后调用了方法3( int initialCapacity,float loadFactor)
传入初始化的数组长度(会通过tableSizeFor转换成2的次方)和 loadFactor( Map<K,V> m)
将loadFactor设置成默认值,然后将map对象的全部元素加入
2. hash方法
key
为null
时返回0key
不为空,取得其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
为替换掉已存在的value
,true
则不替换
执行过程
- 检查数组
table
是否为空,是则通过resize
方法初始化;- 通过
(size-1)&hash
获得一个<size
的数字作为数组下标;- 如果下标所在元素为空,直接
new Node
放进去;- 如果下标不为空,检查元素是否为树节点,是则通过插树的方式插入
putTreeVal
;- 否则遍历链表,遍历过程中如果找到相同的
key
,且onlyIfAbsent
为false
,替换已存在的value
,并返回oldValue
- 无重复
key
则在链表尾插入,插入后若链表长度大于TREEIFY_THRESHOLD
,通过treeifyBin
将当前链表转为树结构(红黑树,节点类为TreeNode<K,V> extends LinkedHashMap.Entry<K,V>
)- 插入结束后,
modCount+1
,size+1
- 若
size>threshold
(即capacity*loadFactor
得出的阈值),则进行resize
4. tableSizeFor(int)方法:
可以将传入的
capacity
转成最接近比本身大的的2的指数,例如传入5会变成16,10也会变成16,17变成32
5.resize方法:
- 注释:将
table
数组初始化或将其长度加倍。如果为null
,则根据初始capacity
所转换的threshold
变量而分配容量;
否则,因为我们是使用2的指数对其进行膨胀,所以HashMap
里每个桶的元素都应该留在原位,或移动到其原来+膨胀前的数组长度的下标- 过程:
- 用一个
oldcap
存储当前数组长度,一个oldthr
存储当前的threshold
- 如果
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). 计算出
newcap
和newthr
之后,使用newcap
初始化Node
数组- 6).
if(oldTab!=null)
通过判断oldTab
数组不为空可知道此时处于扩容阶段,扩容阶段才是resize
方法中的精髓,
以下为扩容时的逻辑:
- (1). 遍历
oldTab
数组,遍历下标为j
,if
当前元素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
链表
- 遍历结束,如果
loHead
链表不为空,则将其放入当前的遍历下标newTab[j]
中- 同时,如果
hiHead
链表不为空,将其放入newTab[j+oldCap]
中
- 第 4.2~4.4 步的原因:
- 因为这里之所以是
& oldCap
,是为了检查元素的hash
值在扩容后左移的那一位是否有1,如果没有,则计算得出0,则其即便是计算hash & (newCap-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
定义了parent
,left
,right
,prev
这4个引用可以看出其具有二叉树的特点,parent
记录上级节点;prev
用于记录上一个节点,相当于双向链表TreeNode
还定义了一个boolean red
变量,从这里应该可以知道它就是传说中的 红黑树 的数据结构
执行过程
- 如果
tab
数组为null
,或者tab.length < MIN_TREEIFY_CAPACITY
(常量值为64),则先resize
,
1.) 不大明白为什么调用treeifyBin
的时候tab
数组会为空;
2.) 后面的条件应该是为了避免在数组长度很小时也转换成树,还不如进行数组扩容来减低哈希碰撞- 第二步,当
(tab.length-1) & hash
获得的数组下标所得元素e
不为空,
进行转换成树结构的步骤:- 定义两个
TreeNode
,hd(head)
,tl(tail)
- 进入
do while
循环,遍历元素e
所在链表
1). 将当前的Node e
通过replacementTreeNode
转换成TreeNode p
,
这个方法就是new TreeNode
,不必深究
2). 一个if
判断,
- 如果
tl==null
,hd=p
,就是一个初始化动作,将hd指向第一个TreeNode
- 否则将
p.prev=tl
,记录上一个节点tl=p
,存放当前节点为尾部e=e.next
,进入下一个节点
第二步实际上建立起来的还是一个链表,只是变成了一个双向链表而已
- 将
hd
头结点赋值给tab[index]
,如果hd
头结点不为null
,则调用hd.treeify(tab)
方法,将hd
链表转换成真正的红黑树结构
7. get(getNode)方法
- 其实把
put
和resize
的过程搞懂了之后这里很简单了,取key
的hash
值,通过hash & (size-1)
得到数组下标,然后遍历下标对应的链表找到key
相同的node
,返回node.value
最后来一波小总结
从上面的解读笔记可以看到那么几个点:
- HashMap内部的各种计算操作中大量运用了位运算,对于位运算不熟悉的朋友(包括在下)在初读的时候会造成很多疑惑,至于为什么不使用更简单的数学运算,因为位运算运行效率比一般的数学运算高得多,特别是在并发量特别高,同时应用HashMap又比较频繁的应用内,这一点一滴的性能提升统计起来其实也是一个非常惊人的提升量。对此不理解的童鞋回去复习下编译原理(还是说操作系统原理?其实我也忘了)吧,这里就不多解释了
- 在理解1.的基础上再去读它内部的代码,特别是resize,put操作会发现很多操作非常的秒。例如resize扩容时,如果一般人仅仅为了实现功能了事的话,估计简单的写个循环遍历所有的node,然后调用put方法加到新的数组就了事了,这样明显是非常低效的做法(但不可否认这种做法最偷懒),而在HashMap内则不是,它结合了自己进行位运算时数据的变化特点而直接将整个链表拆分开放到正确的下标内,看明白之后感觉这一步简直是妙到毫巅……
- 同时还可以看到JDK源码内部的注释都写得非常的详细清楚,可以说是【将用户当成SB一样去写我的用户手册】的一种典型,这是作为开发者的我们所需要学习的精神
网友评论