java.util.HashMap
源码赏析
HashMap是java.util
包下的一个哈希表,它继承AbstractMap类,实现Map接口
1. 简要介绍
HashMap有三个关键参数capacity、loadFactor以及threshold,这三个参数与HashMap的容量和动态扩容相关,在一开始时HashMap是以链表数组形式保存,当数据量足够大的时候,HashMap就会转换为树节点的形式,而HashMap最大可容纳的条目的个数是2的30次方。
HashMap
的程序结构(只注明本文中涉及部分)如下:
1 | HashMap<k, v>{ |
2. 构造方法
HashMap有三种构造方法:
空参数,不修改HashMap中的任意成员变量。
HashMap(int initialCapacity)
,调用this(int initialCapacity, 0.75)
。HashMap(int initialCapacity, float loadFactor)
,这两个参数都自行定义,需要注意的是如果initialCapacity小于0或者loadFactor不大于0的话会抛出IllegalArgumentException
异常,initialCapacity如果大于最大容量的话会被修改成最大容量。随后更新对象的loadFactor变量和threshold变量,而更新threshold变量时使用的tableSizeFor(int cap)
方法就比较有趣了。1
2
3
4
5
6
7
8
9static final int tableSizeFor(int cap) {
int n = cap - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}这个鬼才看的懂在干什么,笔者以cap为100演示一下,其中
|
是按位与操作,而>>>
是无符号右移。1
2
3
4
5
6
7
8
9cap = 0000,0000,0000,0000,0000,0000,0110,0100
n = cap - 1 = 0000,0000,0000,0000,0000,0000,0110,0011
n >>> 1 = 0000,0000,0000,0000,0000,0000,0011,0001
n |= n >>> 1 = 0000,0000,0000,0000,0000,0000,0111,0011
n >>> 2 = 0000,0000,0000,0000,0000,0000,0001,1100
n |= n >>> 2 = 0000,0000,0000,0000,0000,0000,0111,1111
n |= n >>> 4 = 0000,0000,0000,0000,0000,0000,0111,1111
n |= n >>> 8 = 0000,0000,0000,0000,0000,0000,0111,1111
n |= n >>> 16 = 0000,0000,0000,0000,0000,0000,0111,1111将结果换算回十进制,发现是127, 最后返回的是128。我们大胆推测,这个方法是将输入参数cap向上取到第一个比它大的2的n次方。所以无论你给容器设置的初始化大小M是多少,都会被换为第一个不小于这个M的2的n次方。
如果让笔者写的话
1
2
3
4
5for(int i = 0; i < 31; i++){
if(cap < Math.pow(2, i)){
return (int) Math.pow(2, i);
}
}看起来虽然短,但是效率低多了,远不如位操作高效,看来前辈还是永远滴神。
无论是哪一个构造方法,都只是修改了HashMap的参数,并没有创建实际的容器,所以当你new一个HashMap对象时,它的容量大小是0,只有当你第一次put一个条目进去时,容器才会真的被创建。
3. 动态扩容
与ArrayList,HashSet等容器类似,当HashMap的容量不足时,就会使用动态扩容来增加容器的容量,动态扩容实际上在添加第一个条目就已经发生了,动态扩容主要涉及的是resize()
方法:
1 | final Node<K,V>[] resize() { |
3.1. 扩容后的大小
扩容分3种情况:
原table为空,且原threshold为空
要使得threshold为空就只有一种情况,使用空参构造方法
new HashMap()
构造对象并且还没有put第一个条目,然后resize()
方法就知道了,这是要默认初始化,将capacity设置为默认值,将threshold设置为默认capacity值乘以默认loadFactor值,即容器大小为16,触发动态扩容的阈值为9。原table为空而原threshold不为空
threshold不为空而原table为空说明是用
new HashMap(int initCapacity)
或者new HashMap(int initCapacity)
新建了HashMap对象而还没put第一个条目,这时候成员变量threshold存的值不是触发动态扩容的阈值,而是目标容量将threshold
存的值为初始化容量来初始化容器后,将threshold更新为threshold×loadFactor。原table不为空
如果原table不为空,则说明已经put过条目进容器里了,只是容器里条目的数量超过了threshold而触发了动态扩容。一般而言,动态扩容会把容器的大小翻一番。那为什么是扩到2倍而不是3倍或者1.5倍呢?这就涉及扩容后条目如何重定位。
3.2. 扩容后条目重定位
在重新定位条目之前,先看看之前是如何通过hash值定位条目的。容器存储数据的table的数据类型是Node[]
,一个链表头组成的数组,先通过hash值找到条目所在链表的链表头,也就是table[hash(key) & (table.length-1)]
,然后遍历链表,找到key值相同的node节点。那么扩容table后,hash(key)
虽然没变,但是table.length
变为原来的两倍,则hash(key) & (table.length)
的值会发生变化,需要在扩容后重新定位条目的位置。
那么,需要如何重新定位呢,愚者(例如笔者)就会遍历原来旧table上的所有条目重新按put操作添加到新table上。意思如下:
1 | Map<k, v> oldTable = new HashMap(16); |
显然,在resize()
方法中并不是这样做的因为有更高明的方法。先看看原来定位条目位置的位运算是怎么做的:
1 | hash(key1) = 10110110 |
在二进制下,翻一番就是加了一位,那么原来位置相同的两个条目,扩容后虽然可能位置不同,但仅仅也只有两种可能的位置,在上面的例子中,只要遍历链表,按其与10000的&
操作链表分成两个链表,在添加到新table上即可。
1 | Node<K,V> loHead = null, loTail = null; |
4. 二叉树
4.1. 触发将链表转换为二叉树
当HashMap中一个链表的长度超过8时,HashMap就有可能将为这个链表加一个二叉树检索,将原来的Node类型转换为它的子类TreeNode,为什么说可能呢,因为在putVal
方法里,当链表长度大于TREEIFY_THRESHOLD
时就会触发treeifyBin(Node<K,V>[] tab, int hash)
方法,而treeifBin
方法只有当hashMap大小大于MIN_TREEIFY_CAPACITY
时才会去把Node类型转换为TreeNode类型,否则则会扩容来试图减少链表长度。其中TREEIFY_THRESHOLD
的值为8而MIN_TREEIFY_CAPACITY
的值为64。
4.2. 将Node转换为TreeNode
1 | final void treeifyBin(Node<K,V>[] tab, int hash) { |
treeifBin
方法把Node节点转换为TreeNode节点后将原链表变成双向链表,但是还没有建立二叉树。而是通过调用TreeNode中的treeify(Node<K,V>[] tab)
方法建立二叉树。
4.3. 建立二叉树
1 | final void treeify(Node<K,V>[] tab) { |
treeif
方法遍历链表中的所有节点,然后通过比较节点的key值来建立平衡二叉树,这个方法中有四个关键指针。
- root:当前二叉树的根节点
- x:当前被扫描的待安置的链表节点
- p:当前被扫描来与x进行比较的二叉树上的节点
- xp:p指针的上一个位置
比较两个节点的大小时按3个方法进行比较,将结果存在dir
中
- 比较节点的key的hash值
- 若hash值相同且key的类型实现了comparable接口,即key是可比较的,那么则直接比较key的值。
- 若hash值相同,且key值不可比较,或者key值是可比较的但比较结果相同则调用
tieBreakOrder
方法,即比较两个key作为Object时的hash值(无视key的类型中有没有重写hashCode()
方法)来强行比较大小,若即使如此两个key还是相等,则返回-1。
只有当两个key的类型不同且都不为null是才会出现dir为0的情况。
比较完p和x的大小后,将xp指向p,将p根据dir的值指向p的左孩子或者右孩子,若之后p为空(即找到了x在二叉树中的位置),则将xp作为x的父母,将x作为xp的孩子。然后使用红黑树方法重新平衡二叉树。
在将双向链表做的所有节点加入到二叉树后,将二叉树的根节点移动到双向链表的链表头。
在动态扩容后,如果原来的树需要被拆成两棵树(链表数量不少于UNTREEIFY_THRESHOLD
并且重定位后存在两个节点属于不同的位置),则要在拆开链表后重新建立二叉树。
4.4. 平衡二叉树
HashMap平衡二叉树的算法是红黑树,红黑树的规则如下:
- 每个节点要么是黑色,要么是红色
- 根节点是黑色
- 每个叶子节点(NIL)是黑色
- 每个红色结点的两个子结点一定都是黑色
- 任意一结点到每个叶子结点的路径都包含数量相同的黑结点
如下图所示就是一个典型的红黑树。
4.4.1. 插入
HashMap中插入树节点的代码如下:
1 | final TreeNode<K,V> putTreeVal(HashMap<K,V> map, Node<K,V>[] tab, |
putTreeVal
方法通过比较hash值来在二叉树中检索该key在二叉树上的位置,如果发现hash值相同且两个key通过比较发现是相同的,则返回原有的节点,如果key是不能比较的,则使用find方法。如果发现该key在二叉树上没有,则新建一个节点放在对应的位置上,随后调用balanceInsertion
方法和moveRootToFront
方法来调整二叉树。
HashMap中处理插入后平衡二叉树的算法如下:
1 | static <K,V> TreeNode<K,V> balanceInsertion(TreeNode<K,V> root, |
其中root是二叉树的根节点,x是本次插入二叉树的节点,xp是x的父节点,xpp是xp的父节点,xppl是xpp的左孩子,xppr是xpp的右孩子。
- 初始化x为红色。
- 如果x没有父节点,则说明x是根节点,将x变为黑色后返回直接返回。
- 若x的父节点xp为黑色或者没有父节点,则说明该红黑树符合规则,返回根节点。
- 如果xp是xpp的左孩子,则到5,否则到。
- 若xppr存在且为红色(x可以是xp的左孩子或右孩子),则经过变色操作后将x的指针指向xpp然后回到2
若xppr为null且x是xp的右孩子,在经过左旋转、变色、右旋转后在下一次循环中返回根节点
如果xppr存在但为黑色,但我们已知xppl原右孩子为nulll,x是后来插入的,那么在x插入前xpp倒xp的右边的叶子节点所经过的黑色节点的数量不等于xpp到xppr的叶子节点所经过的黑色节点的个数
若xppr为null且x是xp的左孩子(同理xppr不可能存在且为黑色),在经过变色和右旋转后在下一次循环返回根节点
如果xp是xpp的右孩子那刚好跟5、6、7步镜像翻转。
4.4.2. 删除
HashMap中在二叉树中删除节点的算法如下:
1 | final void removeTreeNode(HashMap<K,V> map, Node<K,V>[] tab, |
上面的moveable参数在HashMap永远为true,是在CurrentHashMap中才会用到。
处理步骤如下:
在双向链表中删除该节点
对二叉树进行调整(假设要删除p)
如果p的左右孩子存在,将p和以它的右孩子为根节点的森林中key值(hash值或key实现comparable接口后的比较值)最小的节点调换位置和颜色。
若sr为null,则replacement为p,否则replacement为sr
如果p的右孩子为null,则replacement为pl
如果p的左孩子为null,则replacement为pr
如果p的左右孩子为null,则replacement为p
如果replacement不为p,则先用repalcement代替原来p在二叉树中的位置,随后消除p在二叉树中的各种指针,即
p.parent.left
、p.parent.right
、p.left
、p.right
、p.parent
等。如果p是红色节点,那删了就删了,如果不是,则调用
balanceDeletion(root, replacement)
方法调整二叉树。如果replacement为p则消除p在二叉树中的各种指针,即
p.parent.left
、p.parent.right
、p.left
、p.right
、p.parent
等。
总而言之,由于第3步中调整位置和颜色的步骤存在所以在第8步中,当时二叉树存在以下六种情况(忽略左右孩子的区别)
前四种为p的左右孩子有且只有一个不为null。但是,根据红黑树规则,情况①和情况③并不符合任意节点(节点p)到所有叶子节点的路径上黑色节点的数量相同。因为p到左边的叶子所经过的黑节点至少比到replacement下的叶子节点少1。
后两种为p的左右孩子为空。如果是情况⑥,那么删了后依然满足红黑树的规则,如果是⑤,就要通过旋转和变色来使得红黑树满足规则。
HashMap中处理删除后平衡二叉树的算法如下:
1 | static <K,V> TreeNode<K,V> balanceDeletion(TreeNode<K,V> root, |
参数中的x就是上一个算法中的replacement,算法步骤如下:
若替换用的x节点是根节点,则说明被删除的p节点原来就是根节点,只需将x变成黑色(如果本来就是黑色则不用换),二叉树就满足红黑树的规则(①根节点是黑色,②任意节点到任意叶子节点经过的黑色节点数不变)。
当x不是根节点时,有以下六种情形:
绿色空心节点代表节点有可能为红色或黑色
当二叉树为情况①,那么只要把x变成黑色即可,因为如果x是红色,那么被删除的p必为黑色,则删除后,root到leafA的路径上的黑色节点个数减一,不再等于到leafB的路径上的黑色节点个数,需要把x变成黑色来满足红黑树条件。
若二叉树为情况②~⑥就比较复杂,首先因为x是由
removeTreeNode
方法传进来的,由于被删除的p节点在移动位置后左右孩子必有一个为空,所以如果是传进来的x不是被删除的节点本身而是替换的节点,那么必定为红色。相反,如果为黑色,则x必定是被删除的节点且x左右孩子必为空。而且,如果x是黑色节点,则说明二叉树要删除某一个黑色节点。删除一个黑色节点必然会使得原来经过该节点得路径上得黑色节点数减一(这不废话吗),使得与其他路径不等,假设根节点(或者x上的所有父辈)r到所有叶子节点的路径上黑色节点数量为N,那么就通过变色和旋转来试图使其保持为N,或者令所有路径的黑色节点数量变为N-1。
- 当二叉树为情况②时,通过变色和旋转使其变为③~⑥情况。
- 当二叉树为情况③时,无法使得路径上的黑色节点数量保存不变,于是将路径上的黑色节点数调整为N-1,然后将xp变为x再次进入循环。
当二叉树为情况④时,先通过变色旋转转换成⑤~⑥情况,随后通过变色旋转是路径上的黑色节点数量保持为N。
当二叉树为情况⑤或情况⑥,通过变色旋转使路径上的黑色节点数量保持为N。
5. LinkedHashMap
LinkedHashMap继承于HashMap,主要是实现了有序的HashMap。在原HashMap中,使用迭代器对条目进行迭代的顺序并不遵循插入或访问顺序。因为在插入进HashMap后就失去了插入和访问的先后信息。
LinkedHashMap的Entry节点继承自原来HashMap中的Node节点,增加了before和after变量来记录插入或者访问的先后顺序。
在构造方法中通过指定accessOrder
来指定是否需要因为访问而更新顺序。如果为false,则是插入顺序,后插入的在后。如果为true,则是访问顺序(插入的时候当作访问了一次),后访问的在后。