深入了解HashMap
HashMap简介
HashMap是日常开发中常用的一种数据结构。在JDK 1.8中,HashMap采用数组+链表+红黑树的方式实现。本篇基于JDK 1.8源码进行解读。
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
//...
}
基础节点是单链表,存储在Node<K,V>[] table数组中。当满足一定的条件,则会转换成红黑树,以避免链表过长查询效率低下的问题。
构造方法
1、默认构造会把loadFactor方法设置为DEFAULT_LOAD_FACTOR即为0.75f,loadFactor会影响threshold,当表容量达到了最大容量的百分比之后,就会触发扩容。一般来说,0.75f是经过验证后的一个比较合适的数值。
2、指定容量的构造方法,会针对传入的initialCapacity进行计算
/**
* Returns a power of two size for the given target capacity.
*/
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;
}
tableSizeFor会把传入的数据的低位全变成1,再加上1之后就会变成2的幂,返回的容量赋值给threshold,为什么要让容量为2的幂这里是有讲究的。
put
put方法会先计算key的hash,再调用putVal方法来实际的存储数据。
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
hash会用该key的hash值与高16位进行异或得来,减少hash碰撞。
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)
//第一次存储 初始化tab
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null)
//tab标对应位置没有数据,创建新Node存入,使用方法创建,方便被重写
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;//第一个节点与插入元素的key相同
else if (p instanceof TreeNode)
//尝试插入红黑树节点中
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1)
//链表长度过长,尝试转换红黑树
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;
//给LinkedHashMap 保留的埋点
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
if (++size > threshold)
//达到扩容的临界点了 去扩容
resize();
afterNodeInsertion(evict);
return null;
}
putVal方法首先去判断当前tab有无初始化,如果没有则调用resize方法初始化。
取下标的方法采用(n - 1) & hash来处理,如果tab数组对应下标位置没有数据,则创建一个新的节点存入。如果对应节点是一个红黑树节点,那么则尝试把此数据存入红黑树中。如果有数据,而且节点是普通的链表节点,则会对链表进行遍历,存储数据到链表的末尾。如果链表长度达到了TREEIFY_THRESHOLD(8),则会尝试转换为红黑树。来看看treeifyBin方法:
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
//如果tab长度小于MIN_TREEIFY_CAPACITY(64),则优先扩容,避免碰撞太多
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
else if ((e = tab[index = (n - 1) & hash]) != null) {
TreeNode<K,V> hd = null, tl = null;
do {
TreeNode<K,V> p = replacementTreeNode(e, null);
if (tl == null)
hd = p;
else {
p.prev = tl;
tl.next = p;
}
tl = p;
} while ((e = e.next) != null);
if ((tab[index] = hd) != null)
hd.treeify(tab);
}
}
可以知道当链表长度>=8并且数组长度>=64的时候才会进行红黑树转换。putVal方法操作了数据之后,都会调用一些埋点方法,为LinkedHashMap的实现提供了基础。
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;
//之前的tab容量不为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)
//可以进行扩容则扩容一倍
newThr = oldThr << 1;
}
else if (oldThr > 0)
//构造方法中设定了容量
newCap = oldThr;
else {
//未初始化数组 则设为默认容量16
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
if (newThr == 0) {
//扩容容量界标为数组容量*loadFactor
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;
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 {
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 {
//此节点的下标相较于原数组+oldCap
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;
}
来详细解读一下,resize方法会先对旧的数组大小进行一次判断,如果已经初始化过后,则尝试扩容。如果数组容量已经达到上限,则不会继续扩容,否则会尝试扩容为之前大小的两倍。如果构造方法中自定了容量,则会把新的容量设定为此容量,然后重新计算扩容临界点。如果采用了默认的构造方法,会初始化数组容量为16。
扩容之后,不是单纯的复制就数组的数据到新数组,而是会重新取新下标。如果之前的下标位置的节点没有子节点,则会在取新节点之后直接存入。如果有子节点,这时候的处理就比较有意思了。
为什么表的大小取2的幂?
这是JDK 1.8里一个比较巧妙的设计,因为容量为2的幂,所以以二进制来看,tabCap除了高位,别的数值都为0,那么在-1后低位都会变为1,而取下标的方式为hash & (n -1)。以表初始容量16为例子,假设一个key的hash值为11001100,另外一个hash值为11011100,分别计算下标:
int index0 = 11001100 & 1111;//hash & (n -1) = 1100
int index1 = 11011100 & 1111;//hash & (n -1) = 1100
在旧的数组里,这两个key的hash值虽然不同,但是计算得的下标都是一样的,为12。如果数组扩容,则容量会变成32,这时候取下标就变成如下了:
int index0 = 11001100 & 11111;//hash & (n -1) = 1100
int index1 = 11011100 & 11111;//hash & (n -1) = 11100
可以看出index0下标不会改变,还是12,但是index1的下标会发生改变,变成28。
这里就可以看出数组容量为2的幂大小设计的巧妙之处了。当数组扩容之后,我们只要关心hash在oldCap的那一位的数值是0还是1即可确认新下标了,如果是0那么新下标也不会发生改变,如果是1则新下标就是旧下标的基础上加上oldCap,这样设计即可解决原数组里的hash碰撞的问题,也避免了过多的运算,实在是很巧妙。
TreeNode节点的split
红黑树节点在扩容会也会进行切割,针对不同的hash计算生成新树,取下标的逻辑也是和上面说到的方法一样。
if (loHead != null) {
if (lc <= UNTREEIFY_THRESHOLD)
tab[index] = loHead.untreeify(map);
else {
tab[index] = loHead;
if (hiHead != null) // (else is already treeified)
loHead.treeify(tab);
}
}
if (hiHead != null) {
if (hc <= UNTREEIFY_THRESHOLD)
tab[index + bit] = hiHead.untreeify(map);
else {
tab[index + bit] = hiHead;
if (loHead != null)
hiHead.treeify(tab);
}
}
切割完红黑树之后,会判断新树的节点树,如果节点树小于UNTREEIFY_THRESHOLD(6),则会重新转为链表。
get
get方法相对比较简单,在一个数据结构的实现里一般比较困难的都是插入和删除操作,这里简单说说一下:
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
get方法实际会调用getNode方法来查找元素:
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
if ((e = first.next) != null) {
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
getNode会先根据hash取出数组下标,然后找到对应的节点,但是hash值相同,key不一定一样,所以还会调用equals来匹配。对于TreeNode红黑树节点,则会用红黑树二叉查找树的特性快速查找,如果是普通链表节点则只能依次遍历匹配了。红黑树的引入也是JDK 1.8相较于之前版本的一个改动。
remove
一般来说remove是数据结构中比较难实现的方法。在HashMap中,调用remove方法实际会调用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;
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;
//先尝试找到需要删除的元素
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
node = p;
else if ((e = p.next) != null) {
if (p instanceof TreeNode)
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;
}
p = e;
} while ((e = e.next) != null);
}
}
if (node != null && (!matchValue || (v = node.value) == value ||
(value != null && value.equals(v)))) {
//针对不同的节点类型进行删除
if (node instanceof TreeNode)
((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
else if (node == p)
tab[index] = node.next;
else
p.next = node.next;
++modCount;
--size;
afterNodeRemoval(node);
return node;
}
}
return null;
}
HashMap的remove方法看起来并不复杂,首先会查找节点是否存在,如果找到了对应的节点则会进行删除。如果是链表类型的节点,删除逻辑会比较简单,改动一下引用即可。实际上HashMap比较复杂的插入删除逻辑在TreeNode红黑树节点里,这里就不再细说了。
遍历
HashMap可以通过keySet、values、entrySet三种方式遍历,keySet方法直接遍历
HashMap中存储的key,values则遍历HashMap存储的value,entrySet则会取出HashMap中存储的各个节点信息。
final class KeyIterator extends HashIterator
implements Iterator<K> {
public final K next() { return nextNode().key; }
}
final class ValueIterator extends HashIterator
implements Iterator<V> {
public final V next() { return nextNode().value; }
}
final class EntryIterator extends HashIterator
implements Iterator<Map.Entry<K,V>> {
public final Map.Entry<K,V> next() { return nextNode(); }
}
通过观察源码,我们会发现这三个方法的迭代器的next方法的实现都是基于nextNode方法,因此我们只需要关心这个方法的实现即可。
final Node<K,V> nextNode() {
Node<K,V>[] t;
Node<K,V> e = next;
//检查并发修改
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
if (e == null)
throw new NoSuchElementException();
//如果链表节点的下一个不为空取next
if ((next = (current = e).next) == null && (t = table) != null) {
//从数组中取下一个节点
do {} while (index < t.length && (next = t[index++]) == null);
}
return e;
}
遍历方法也不复杂,首先判断是否并发修改,然后判断当前的节点是否为空。如果不为空,则尝试获取该节点的下一个元素,如果获取不到,则遍历数组尝试找到下一个不为空的节点返回。
为什么遍历HashMap时候不用迭代器的remove方法删除元素,而是调用HashMap的remove方法会造成并发修改异常(ConcurrentModificationException)呢?首先迭代器初始化的时候会保存当前的HashMap的modCount为expectedModCount,每次遍历到下一个元素都会进行检测,如果直接调用了HashMap的remove方法,modCount会发生改变,但是迭代器里的expectedModCount不会发生改变,这时候就会出现异常。而调用迭代器的remove方法删除元素,会在完成删除操作后更新expectedModCount,这样就不会出现错误。
性能分析
JDK中最常用的两个基础Map就是HashMap和TreeMap了,那我们通常该如何选择呢?
首先HashMap的实现是基于数组+链表+红黑树实现。通过散列函数计算下标,可以快速的在数组中获取到根节点。较短的节点采用链表存储,插入和删除效率都比较高。对于比较长的节点采用红黑树存储,可以解决链表过长时候查询效率低下的问题。
而TreeMap底层采用了红黑树来实现,红黑树是一个平衡二叉查找树,因此我们存储的key需要是可以排序的。存储的元素通过中序遍历可以有序的获取。但是因为红黑树需要实现平衡,在插入和删除操作都需要进行一些操作来保持,因此会有一定的性能损耗。
因此,当我们不需要关心存储的元素的顺序的时候,我们基本都可以选择HashMap,而如果我们需要获取排序后的元素,我们可以选择TreeMap。
线程安全
HashMap不是线程安全的,在不同的线程对HashMap进行操作会有线程安全问题。如果需要多个线程操作,我们应该选择ConcurrentHashMap,ConcurrentHashMap会对put和remove操作加锁,但是get方法不会,因此读操作不会受到性能影响。