HashMap作为我们平常使用很频繁的一个存储工具,我们都知道它的存储效率很高。现在我来对他的源码进行一下分析。
HashMap数据结构
可以直观的看到,HashMap用的是开链法来解决hash冲突。
其中,table是一个数组,类型是Node类型(TreeNode类型最终也是Node类型的子类),约定,每一个数组元素称为桶,每个桶中装的及桶之后的元素称为bin。比如,上图中,0号桶中只有一个bin,而1号桶中有5个bin。
还有一些比较难区分的名词,例如:size,capacity,loadFactor,threshold。
size指的是:HashMap中存放KV的数量(包括了数组,链表,还有红黑树中的)。
capacity指的是:HashMap中桶的数量,也就是数组的长度,默认是16,之后会进行扩容,容量都是2的幂次方。
loadFactor指的是:加载因子,用来衡量HashMap满的程度,默认值为0.75f。
threshold指的是:阈值,它等于capacity*loadFactor。
HashMap的成员
// 这是他的序列号,有关序列号,在之前的博文中已经写道,这里不再赘述
private static final long serialVersionUID = 362498820763181265L;
// 默认的桶的数量 16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
// 默认的桶的最大数量 1 << 30
static final int MAXIMUM_CAPACITY = 1 << 30;
// 默认加载因子 0.75f
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// 将每个桶中的链转化为红黑数的bin个数的阈值:8
static final int TREEIFY_THRESHOLD = 8;
// 将每个桶中的红黑数转化为链的bin个数的阈值:6
static final int UNTREEIFY_THRESHOLD = 6;
// 最小的转化为红黑树的数组容量(也就是桶的个数): 64
static final int MIN_TREEIFY_CAPACITY = 64;
// 数组类型是Node类型,
transient Node<K,V>[] table;
transient Set<Map.Entry<K,V>> entrySet;
// 总的KV的数量
transient int size;
// 会随着KV的增多而增大,减小而减小,(这个值多用在迭代器的快速失败)
transient int modCount;
// 阈值,它等于loadFactor * capacity;
int threshold;
// 加载因子,会和capacity一起影响整个hashmap的结构(两者乘积大于阈值时,会进行扩容)
final float loadFactor;
HashMap的构造方法
// 传输数组大小和加载因子
public HashMap(int initialCapacity, float loadFactor) {
if (initialCapacity < 0)
// 如果传入的数组的大小小于0,抛出异常
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
// 如果传入的数组的大小最大值就是MAXIMUM_CAPACITY
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
// 加载因子比0小,或者加载因子不是一个number,抛出异常
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
this.loadFactor = loadFactor;
this.threshold = tableSizeFor(initialCapacity);
}
// 只传输一个数组的大小,加载因子是默认的0.75
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
// 无参构造,加载因子是默认的0.75
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
// 传递一个Map类型的m,加载因子还是默认的0.75
public HashMap(Map<? extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
putMapEntries(m, false);
}
影响HashMap效率的因素
关于hash函数:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
在putValue()函数中,会有这样一条判断(putValue()函数我们会在后面进行分析)
if ((p = tab[i = (n - 1) & hash]) == null)
可以看出,我们通过将hash值与数组的(长度-1)进行与运算,将这个结果作为table数组的下标,在这个下标对应的桶中进行元素的存储。
为什么这样做呢?
还是上面图片的结果,它的返回值是 0000 0000 0000 1101 0001 0101 0101 0010 假设现在的数组的长度为16,将它与16-1相与,
0000 0000 0000 1101 0001 0101 0101 0010
&
0000 0000 0000 0000 0000 0000 0000 1111
结果是 0000 0000 0000 0000 0000 0000 0000 0010
那么取到的结果是2。
为了得到这个下标,我们先进行了hash()函数,又进行了运算,目的就是位了将它进行位扰动,从而增加散列度,减少哈希碰撞。与(长度-1)的结果相与,是因为,如过和长度相与,长度是2的次幂,转换成位运算,只有一个位置是1,那么将它与哈希函数的返回值相与,只有两种结果,一种是0,一种是长度的值。那么它发生哈希碰撞的可能性就大大增加。与长度-1相与,取到的结果是在0 - (长度-1)之间。
那取下标的时候为什么不用 hash结果 % 长度呢?
因为位运算的效率要比取余运算高,取余最后还是要转换成位运算进行计算。
关于数组的capacity的大小为什么一定 是2的幂次:
static 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为9;
转化为二进制就是:1001
那么n = cap - 1:1000
n >>> 1 : 0100
n |= n >>> 1 : 1100
n >>> 2 : 0011
n |= n >>> 2 : 1111
n >>> 4 : 0000
n |= n >>> 4 : 1111
n >>> 8: 0000
n |= n >>> 8 : 1111
n >>> 16: 0000
n |= n >>> 16 : 1111
这时的n的值为:15,return的结果时 n + 1,所以返回值为16;
HashMap通过一些列的为运算,保证了,最后的结果一定是2的次幂,而其值都是比cap大的最小的2的整数次幂。之所以要用2的次幂,这是和上面所讲的hash()函数是相挂钩的。因为hash函数就是为了进行位干扰,干扰的结果再与长度-1进行与运算,如果长度是2的整数次幂,那么,长度减一,除了最高为为0,剩下的都为1,只要其中有一位不是1,那么结果就会有很大的变化,这样一来,散列度全部由hash函数所决定,也符合java分而治之的原则。否则,其中有的位不是1,那一位的值就必然是0,这就造成了位的浪费。
为什么加载因子是0.75:
加载因子如果定的太大,比如1,这就意味着数组的每个空位都需要填满,即达到理想状态,不产生链表,但实际是不可能达到这种理想状态,如果一直等数组填满才扩容,虽然达到了最大的数组空间利用率,但会产生大量的哈希碰撞,同时产生更多的链表,显然不符合我们的需求。
但如果设置的过小,比如0.5,这样一来保证了数组空间很充足,减少了哈希碰撞,这种情况下查询效率很高,但消耗了大量空间。
因此,我们就需要在时间和空间上做一个折中,选择最合适的负载因子以保证最优化,取到了0.75
HashMap的扩容和树化过程
先说结论:
1.所有的树化和扩容都是针对某个桶里的bin而言的,而不是所有的桶全部进行扩容或者树化。
2.当某个桶的bin的个数(也就是node节点的个数),大于 TREEIFY_THRESHOLD 值,但是,capacity小于 MIN_TREEIFY_CAPACITY ,只对当前链表进行扩容,不进行树化。
3.当某个桶的bin的个数(也就是node节点的个数),大于 TREEIFY_THRESHOLD 值,并且capacity满足了 MIN_TREEIFY_CAPACITY ,对当前链表进行树化。
来看具体的源码分析:
put方法
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
put方法调用了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;
// 判断当前数组是否已经初始化
if ((tab = table) == null || (n = tab.length) == 0)
// 若没有初始化,则去初始化它
n = (tab = resize()).length;
// 现在的tab表示Node数组,n表示其长度
if ((p = tab[i = (n - 1) & hash]) == null)
// 执行到这里,表明当前的索引到的index下标,还未存放元素
tab[i] = newNode(hash, key, value, null);
else {
// 执行到这里,表明当前index下标已经存放了元素
Node<K,V> e; K k;
// 检测要放的元素的key和已经存放在index下标的元素的key是否相等
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
// 这里表明两个key是相等的,那么进行覆盖,替换原来的旧值
e = p;
else if (p instanceof TreeNode) // 若两个key不相等,那么检测当前下标所形成的非线性结构是否是红黑树?
// 执行到这里,表明非线性结构是红黑树
// 那就把它插入到红黑树里面
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
// 执行到这里,表明存储结构是链表
// 那就先对链表进行遍历,检测是否存过在这个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
// 执行到这里,表明已经 >= 8,那么将这个链表转化成红黑树进行存储
// 当然,我们前面提到,转化成红黑树需要两个条件,这里只满足了其中之一
// 第二个条件在treeifyBin()函数里面进行判断
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k)))) // 这里判断是否存在key相等的节点
// 相等,跳出循环
break;
p = e;
}
}
// 由于e!=null,那么就说明存在key,
if (e != null) { // existing mapping for key
// 这里进行旧值的替换
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
// 开始执行子类覆盖节点之后的方法
afterNodeAccess(e);
// 因为是覆盖,所以长度并未增加,可以直接返回
return oldValue;
}
}
// 这个modCount是作为迭代器的总长度来用,
++modCount;
// 先对hashmap的总容量进行+1,然后比较它和阈值的大小
if (++size > threshold)
// 已经比阈值大,那么进行扩容
resize();
afterNodeInsertion(evict);
return null;
}
对于put方法,我们可以进行一个简单概括,如果在数组中找到该节点,那就进行覆盖,否则,检测当前是否是红黑树结构,如果是,就直接进行存储,否则就是链表,就进行遍历,如果找到,就进行覆盖,如果遍历完还没有找到,那就在链表的末尾进行增加,增加之后进行判断,当前链上的bin是否已经达到阈值,如果达到,转化为红黑树(当然,我们前面提到,转化成红黑树需要两个条件,这里只满足了其中之一,第二个条件在treeifyBin()函数里面进行判断)。最后,如果是覆盖了原来的节点,那么直接返回,如果是在原来的基础上增加了,那么进行modCount++,如果这时已经超过了阈值,那就进行扩容(通过resize()方法)。
resize()方法:
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
// 判断旧的数组容量是否大于0(也就是table是否已经初始化过了)
if (oldCap > 0) {
// table已经初始化过了
// 判断旧的数组的容量是否已经达到或多于默认的最大容量(1 << 30)
if (oldCap >= MAXIMUM_CAPACITY) {
// 改变阈值为整型量的最大值:0x7fffffff
threshold = Integer.MAX_VALUE;
return oldTab;
}
// 执行到这里表明,那就是还未达到默认的最大容量
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
// 左移1位,表示扩大到原来的两倍
newThr = oldThr << 1; // double threshold
}
// 判断旧的数组的阈值是否大于0
else if (oldThr > 0) // initial capacity was placed in threshold
// 新的容量设置为阈值
newCap = oldThr;
// 两者都不大0.那就证明数组从未初始化过,只能使用默认值
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);
}
// 更新当前的最佳容量
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
// 重新申请一个新的数组
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
// 更新当前的数组
table = newTab;
// 从这里开始,是将旧的数组下的链,移到新的数组中去
if (oldTab != null) {
// 开始循环遍历原来的数组
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
// 如果当前的数组元素不为null,把值赋值给e
if ((e = oldTab[j]) != null) {
// 把当前的数组元素赋值为null
oldTab[j] = null;
if (e.next == null)
// 表明数组元素没后后继节点,该桶中只有一个节点
// 将该节点放到新数组中(下标通过hash运算和长度-1相与得到)
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;
// 这条语句表明,当前的bin在新数组中是否改变了位置
// 因为假设在原来链中,e.hash()的结果转化为二进制是 1 0010;
// 原来数组的长度是 16;
// 那它存放的数组下标就是 0010,也就是2
// 在新数组中,因为e.hash()的结果仍是不变的,而且数组长度是扩大为原来的2倍
// 那么当前数组的长度也就是32,长度减一转化为二进制就是:01 1111
// 两者相与的结果 1 0010 ,和原来相比较,只有最高为进行变动,如果最高为不变,那么数组下标就和原来的一样,正因为最高为变动,所以数组下标才进行了变动
// 可见,在新数组中的位置是取决于原来长度的二进制的最高一位,恰好长度全部是2的整数次幂,所以,只需要hash值和长度相与,便可知道在新数组中的位置是否进行了变动。
if ((e.hash & oldCap) == 0) {
// 结果为0,表明在新数组中的位置没有变动
if (loTail == null)
// 当前的低位的头指向e所指向的空间,也就是链表的头部
loHead = e;
else
// 当前低位的尾的next指向e所指向的空间
loTail.next = e;
// 当前的低位的尾指向了e所指向的空间
loTail = e;
// 上述这几条语句,目的就是为了让头指针指向链表的头部,尾指针一直指向e所指向的空间
}
else {
// 这个else里面的语句的意思与上面if里面的意思一样,只不过是这个是存放在数组中下标高的链
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);// 这个do-while循环,目的就是进行链的遍历,并自行判断应该放在原来的位置还是新的位置。
// 然后下面这些语句是将这条链放在新的数组中,
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
// 执行完一次,继续循环执行,直到循环完旧的数组
}
}
// 将新生成的数组进行返回,这也是等于是将旧的数组进行了扩容
return newTab;
}
其中,通过对每个bin的hash值与旧的数组的长度进行再次相与的结果与0作比较,这样一来,会改变某些bin的位置,会使得散列更加的均匀。画个图来进行说明:
上图可见,原来下标为1的数组,在新数组中只存放了两个,显然,散列度更高。
get()方法:
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
get方法会调用getNode()方法,
来看getNode()方法:
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
// 先判断table数组是否存在,table数组的长度是否大于零,
// 根据当前的hash值与(长度-1)相与得到当前的数组元素,检测其值是否为null,
// 若都是否,那直接return null
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
// 再检测当前(也就是数组元素,也是头节点)节点是否是要找的key,
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
// 如果是,直接返回当前节点
return first;
// 检测下一个节点是不是null,如果是,直接返回null,未找到
if ((e = first.next) != null) {
// 判断当前存储结构是否是红黑树结构
if (first instanceof TreeNode)
// 是,直接交由红黑树的get函数处理,并将结果返回
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
do {
// 否则,执行循环,循环遍历链表,直到遍历到末尾或者找到key为止
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
// 如果找到,将该节点返回
return e;
} while ((e = e.next) != null);
}
}
// 否则,未找到,返回null
return null;
}
再来看remove()方法,
public V remove(Object key) {
Node<K,V> e;
return (e = removeNode(hash(key), key, null, false, true)) == null ?
null : e.value;
}
同样,remove方法调用了removeNode()方法,我们来分析removeNode()方法:
final Node<K,V> removeNode(int hash, Object key, Object value,
boolean matchValue, boolean movable) {
Node<K,V>[] tab; Node<K,V> p; int n, index;
// 这个判断和getNode()方法类似,先检测数组的性质是否满足
if ((tab = table) != null && (n = tab.length) > 0 &&
(p = tab[index = (n - 1) & hash]) != null) {
Node<K,V> node = null, e; K k; V v;
// 满足,开始判断该数组下标下的第一个bin是否满足
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
// 第一个满足,直接将node指向它
node = p;
// 否则,判断下一个是否为null
else if ((e = p.next) != null) {
// 若不是null,先检测是否为红黑树,
if (p instanceof TreeNode)
// 若是,交由红黑树处理,把查找的结果返回给node(这里,和getNode方法的处理一样)
node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
else {
// 若不是红黑树,那么开始对当前链表进行遍历
do {
if (e.hash == hash &&
((k = e.key) == key ||
(key != null && key.equals(k)))) {
node = e;
// 一旦找到,就直接break,此时node已经指向了要删除的目标
break;
}
p = e;
} while ((e = e.next) != null);
// 执行完循环后,p始终是e的上一个节点,如果找到了,那么node就等于e,
// 若找不到,node还是null
}
}
// 检测当前node的值,若为null,直接返回null
//
if (node != null && (!matchValue || (v = node.value) == value ||
(value != null && value.equals(v)))) {
if (node instanceof TreeNode)
// node是个红黑树节点类型,交由红黑树删除节点的函数处理
((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
else if (node == p)
// node == p 是指数组里面存放的就是要删除的节点,
// 那样直接改变数组里面存放的节点就好
tab[index] = node.next;
else
// 执行到这里会有两种情况,一种是找到了,切p一定是node的上一个节点
// 另一种是没有找到,node为null,p为末节点
p.next = node.next;
++modCount;
--size;
afterNodeRemoval(node);
// 若找到,node不为null,如找不到,node就为null
return node;
}
}
return null;
}
其实,get和remove方法,两个方法很多相似的地方,尤其是查询的时候。
对于HashMap的这四个方法,他们的存取原则,整体上都是先从数组入手,然后在判断是否是红黑树,是的话,按红黑树处理,不是的话,按链表处理。这样的处理方法,很有逻辑。这次看源码的收获还是很大的,看到了HashMap里面对于位运算,存储结构等的巧妙应用,很受启发。