在java语言中,HashMap是一个非常重要的数据结构,它被广泛用于存储具有key-value映射关系的数据,HashMap提供了高效的数据结构来实现key-value的映射;从HashMap的设计与实现中,我们可以学到很多巧妙的计算机思维,对我们日常工作中进行编码及方案设计存在很高的参考价值,学习和掌握HashMap就成了非常有必要的一件事情了。
学习HashMap,有这么几个关键问题需要搞明白:
HashMap的数据结构是什么样的,不同jdk版本架构是如何的;
HashMap关键属性,属性的含义及如何设置等问题;
HashMap的key-value插入流程是怎样的;
HashMap的数据查询流程是怎样的;
HashMap的扩容机制是怎么工作的;
HashMap的数据更新流程是什么样的(比如:删除);
HashMap的并发问题产生原因及正确的并发用法(比如并发环境下如何产生cpu 100%);
搞明白这几个关键问题,HashMap就算是掌握得差不多了,下面,从源码级来分析一下这些关键问题的答案是什么。
一、 HashMap的数据结构
HashMap的数据结构由数组+链表组成,从java 8开始,会新增红黑树这种数据结构,也就是在java8中,链表超过一定长度后就会变为红黑树。
在下文的分析中,如果不是特别说明,都是指的是java 8中的HashMap。
二 、HashMap关键属性
有几个关键的属性需要我们知道:
/**
* The default initial capacity - MUST be a power of two.
*/
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
默认的table的大小,table的大小只能是2的幂次方,这和HashMap的哈希槽寻址算法存在着很大的关系,当然,HashMap执行resize的时候也会得益于这个table数组的幂次方大小的特性,这些问题稍后再分析;
/**
* The maximum capacity, used if a higher value is implicitly specified
* by either of the constructors with arguments.
* MUST be a power of two <= 1<<30.
*/
static final int MAXIMUM_CAPACITY = 1 << 30;
table的最大长度;
/**
* The load factor used when none specified in constructor.
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;
默认的负载因子,负载因子用于扩容,当HashMap中的元素数量大于:capacity * loadFactor后,HashMap就会执行扩容流程。
/**
* The bin count threshold for using a tree rather than list for a
* bin. Bins are converted to trees when adding an element to a
* bin with at least this many nodes. The value must be greater
* than 2 and should be at least 8 to mesh with assumptions in
* tree removal about conversion back to plain bins upon
* shrinkage.
*/
static final int TREEIFY_THRESHOLD = 8;
当链表的长度超过一定长度之和,链表就会被升级为红黑树,用于解决因为哈希碰撞非常严重的情况下的数据查询效率低下问题,最坏情况下,如果没有引入红黑树的情况下,get操作的时间复杂度将达到O(N),而引入红黑树,最坏情况下get操作的时间复杂度为O(lgN);8是默认的链表树化阈值,当某个hash槽内的链表的长度超过8之后,这条链表将演变为红黑树。
/**
* The bin count threshold for untreeifying a (split) bin during a
* resize operation. Should be less than TREEIFY_THRESHOLD, and at
* most 6 to mesh with shrinkage detection under removal.
*/
static final int UNTREEIFY_THRESHOLD = 6;
红黑树也有可能会降级为链表,当在resize的时候,发现hash槽内的结构为红黑树,但是红黑树的节点数量小于等于6的时候,红黑树将降级为链表。
/**
* The smallest table capacity for which bins may be treeified.
* (Otherwise the table is resized if too many nodes in a bin.)
* Should be at least 4 * TREEIFY_THRESHOLD to avoid conflicts
* between resizing and treeification thresholds.
*/
static final int MIN_TREEIFY_CAPACITY = 64;
链表升级为红黑树还需要一个条件,就是table的长度要大于64,否则是不会执行升级操作的,会转而执行一次resize操作。
/**
* The table, initialized on first use, and resized as
* necessary. When allocated, length is always a power of two.
* (We also tolerate length zero in some operations to allow
* bootstrapping mechanics that are currently not needed.)
*/
transient Node[] table;
table和上文中的结构图中的数组对应。
/**
* The next size value at which to resize (capacity * load factor).
*
* @serial
*/
// (The javadoc description is true upon serialization.
// Additionally, if the table array has not been allocated, this
// field holds the initial array capacity, or zero signifying
// DEFAULT_INITIAL_CAPACITY.)
int threshold;
下次数组扩容阈值,这个值是通过:capacity * loadFactor计算出来的,每次扩容都会计算出下一次扩容的阈值,这个阈值说的是元素数量。
三、 HashMap数据插入流程
下面来跟着源码来学习一下HashMap的put操作流程:
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
首先需要注意的是hash这个方法:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
如果key是null,则hash的计算结构为0,这里就有一个关键信息,如果一个HashMap中存在key为null的entry,那么这个entry一定在table数组的第一个位置,这是因为hash计算的结果直接影响了数据插入的hash槽位置,这一点稍后再看。
如果key不为null,则会拿key对象的hashCode来进行计算,这里的计算称为“扰动”,为什么要这样操作呢?本质上是为了降低哈希碰撞的概率,这里需要引出HashMap中定位哈希槽的寻址算法。
HashMap的table数组容量只能是2的幂次方,这样的话,2的幂次方的数有一个特性,那就是:hash & (len - 1) = hash % len,这样,在计算entry的哈希槽位置的时候,只需要位运算就可以快速得到结果,不需要执行取模运算,位运算的速度非常快,要比取模运算快很多。
回到上面的问题,为什么要对key对象的hashCode执行扰动呢?因为计算哈希槽位置的时候需要和table数组的长度进行&运算,在绝大部分场景下,table数组的长度不会很大,这就导致hashCode的高位很大概率不能有效参加寻址计算,所以将key的hashCode的高16位于低16位执行了异或运算,这样得到的hash值会均匀很多。
接下来继续看put操作的流程:
/**
* Implements Map.put and related methods
*
* @param hash hash for key
* @param key the key
* @param value the value to put
* @param onlyIfAbsent if true, don't change existing value
* @param evict if false, the table is in creation mode.
* @return previous value, or null if none
*/
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict
// 1
Node[] tab; Node p; int n, i;
// 2
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 3
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
// 4
else {
// 5
Node e; K k;
// 6
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
// 7
else if (p instanceof TreeNode)
e = ((TreeNode)p).putTreeVal(this, tab, hash, key, value);
// 8
else {
// 9
for (int binCount = 0; ; ++binCount) {
// 10
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
// 11
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
// 12
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
// 13
p = e;
}
}
// 14
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
return oldValue;
}
}
// 15
if (++size > threshold)
resize();
return null;
}
下面根据注释的编号来看一下对应位置的含义:
(1)这里主要关注tab和p两个变量,tab是table数组的一个引用,p是当前拿到的Node引用,这个Node可能为null;
(2)这里将table赋值给了tab变量,并且判断了tab数组是否为空,如果为空,表示是首次执行put操作,table还没有被初始化出来,需要执行初始化操作,这里直接调用resize方法就可以完成初始化操作,关于resize,下一小节再重点分析。
(3)(n - 1) & hash计算出来的就是这个key对应的哈希槽,这个算法上面已经分析过,p变量拿到了当前哈希槽的头节点,并进行了判断,如果是null,则说明此时这个哈希槽内部没有哈希冲突,直接创建一个新的Node插入这个槽即可;
(4)此时,说明p变量不为null,这个时候问题比较复杂,这个p可能是一个链表头节点,也可能是一个红黑树根节点,这是结构上的可能性,接下来需要做的,就是判断p代表的结构上是否存在即将要插入的key,如果存在,则说明节点已经存在,执行更新操作即可,如果不存在,则需要执行插入操作,看接下来的流程;
(5)同样,e变量存储的是代表存储key的Node,可能为null,如果key压根没有被存储过,那么e最终就是null,否则就是存储key的Node的一个引用;
(6)这里是判断哈希槽的头结点是否是存储key的节点,这是典型的判断方法,先对比hash,然后对比key,对比key的时候要特别注意,除了使用“==”来进行比较,还使用了key对象的equals方法;如果判断通过,则e就指向了已经存在的代表存储key的Node;
(7)如果执行到这,说明p(哈希槽的头结点)不是代表存储key的Node,那么就要继续后面的流程,这里首先判断了一下p的结构,如果是TreeNode,说明p代表的是红黑树的头结点,那就是要红黑树的节点插入方法putTreeVal来进行,关于红黑树,后续再仔细学习,本文点到为止。
(8)执行到这里,说明p代表的是一条链表的头结点,需要在p这条链表中查找一下是否存在表示key的Node;
(9)开始迭代链表,来查找key;
(10)e此时表示的是p的next,如果e为null,说明链表迭代到了末尾,此时依然没有发现key,则说明链表中根本不存在key节点,直接把key节点插入到末尾即可;
(11)这里有一个判断,binCount如果超过了TREEIFY_THRESHOLD,则需要将链表升级为红黑树,通过treeifyBin方法来实现这个功能;
(12)如果e节点就是key节点,那么就可以结束了,e就是key节点的一个引用;
(13)p = e,就是将p向前移动,继续判断,简单的链表迭代;
(14)如果此时e不为null,说明链表中存在key节点,那么本次put操作其实是一次replace操作;
(15)执行到这里,说明put操作插入了一个新的Node,如果插入后HashMap中的Node数量超过了本次扩容阈值,那么就要执行resize操作,resize操作将在下一小节详细展开分析;
四、 HashMap扩容机制
扩容对于HashMap是一个很重要的操作,如果没有扩容机制,因为有哈希碰撞的发生,会使得链表或者红黑树的节点数量过多,导致查询效率较低。下面,就从源码的角度来分析一下HashMap的扩容是如何完成的:
/**
* Initializes or doubles table size. If null, allocates in
* accord with initial capacity target held in field threshold.
* Otherwise, because we are using power-of-two expansion, the
* elements from each bin must either stay at same index, or move
* with a power of two offset in the new table.
*
* @return the table
*/
final Node[] resize() {
// 1
Node[] oldTab = table;
// 2
int oldCap = (oldTab == null) ? 0 : oldTab.length;
// 3
int oldThr = threshold;
// 4
int newCap, newThr = 0;
// 5
if (oldCap > 0) {
// 6
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
// 7
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
// 8
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
// 9
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
// 10
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
// 11
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
// 12
Node[] newTab = (Node[])new Node[newCap];
// 13
table = newTab;
// 14
if (oldTab != null) {
// 15
for (int j = 0; j < oldCap; ++j) {
// 16
Node e;
// 17
if ((e = oldTab[j]) != null) {
// 18
oldTab[j] = null;
// 19
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
// 20
else if (e instanceof TreeNode)
((TreeNode)e).split(this, newTab, j, oldCap);
// 21
else { // preserve order
// 22
Node loHead = null, loTail = null;
Node hiHead = null, hiTail = null;
Node next;
do {
next = e.next;
// 23
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
// 24
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
// 25
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
// 26
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
resize操作很复杂,下面根据代码注释来看一下每个位置都在做什么:
(1)oldTab变量指向table,这个没有特别难以理解;
(2)oldCap就是当前table的大小,也就是扩容前的table大小,table可能未初始化,则oldCap就是0;
(3、4)变量赋值,新老容量和扩容阈值;
- (5、6、7)oldCap大于0,则说明table已经初始化过,扩容的时候,新的容量是老的table的两倍,这里需要处理一下超过最大容量的问题,如果table数组已经达到最大了,那么就不要再继续扩容了,生死有命富贵在天吧
;
(8)这种情况下是说在创建HashMap的时候指定了初始化大小,那新的table的容量就是当前的扩容阈值;
(9)执行到这里,说明table还没创建,但是创建HashMap的时候没有指定初始容量,那么本次其实就是执行初始化table的工作;
(10)计算新的扩容阈值;
(11、12、13)创建好新的数组,大小是原来的两倍,并使用newTab表示;
(14)执行到这里,如果本次只是初始化table数组,那么其实resize的工作已经完成了,但是如果不是初始化table,而是执行正常的扩容操作,那么就需要执行数据迁移的工作,所谓数据迁移,就是将Node从原来的table中迁移到扩容出来的数组中;
(15)循环原来的table数组,逐个迁移数据;
(16)e变量用来表示当前遍历到的Node;
(17)原table数组中当前哈希槽可能是空的,如果是空的,就说明没有数据需要迁移,继续处理下一个哈希槽就可以,如果当前哈希槽有节点,那么就需要对当前哈希槽内的数据执行迁移操作;
(18)e变量已经拿到了当前需要迁移的哈希槽的头结点引用,执行oldTab[j] = null,就是为了减少引用,尽快让垃圾得到回收;
(19)这种情况很简单,当前需要迁移的哈希槽内部只有一个节点,那么就直接将该节点迁移到新的table中正确的位置就可以了;
(20)执行到这,表示哈希槽内是一颗红黑树,需要使用红黑树的节点迁移方法,这部分暂时不做分析;
(21)执行到这,说明需要迁移的是一条链表,下面就开始将这条链表上的节点迁移到新table中正确的位置上去;
(22)这里需要引出一个关键知识点,哈希槽数据迁移方案,得益于哈希数组的大小是2的幂次方这个特性,对于一个节点,在扩容后,它对应的哈希位置只可能存在两种情况,要么还是当前位置(在新数组中),要么是当前位置+oldCap;这是为什么呢?来看一个例子:
我们假设扩容前数组长度为2,则扩容后数组的长度为4,原数组的数组下标为1的位置上存在一条链表需要迁移到新数组中去,这条链表长度为3,根据哈希槽位置计算方法:hash & (len -1),原来的len = 2, len - 1 = 1,新的len = 4, len - 1 = 3;用二进制表示为:01 => 11,如果继续扩容,则(len - 1)的变化规则为:01 => 11 => 111 => 1111 => ...,可以看到,没扩容一次,hash值的高位就会多一位来参与哈希计算,多一位的这位hash数二进制表现为:要么是0,要么是1,只有这两种可能,如果为0,则相当于计算出来的哈希槽位置和原来一样,如果为1,则哈希槽位置会+oldCap,比如对于k1和k2,假设k1的hash计算结果为3,二进制表示为:11,则原来的下标为 (11 & 01) = b01 = 1,扩容后下标计算为:(11 & 11) = b11 = 3,3 = 1 + 2 = oldIndex + oldCap;有了这个知识点,那么就可以继续来看22这个位置上的代码了,这里有两组变量,loHead和loTail是一组,hiHead和hiTail是一组,这两组分别表示上面分析的两种情况,也就是loHead和loTail表示那些扩容后依然在原来下标的Node,hiHead和hiTail表示那些扩容后需要移动到oldIdex + oldCap位置上的Node;
(23)这里,如果e节点的hash&oldCap==0,说明本次新参与的高位二进制位0,那这个节点扩容后还是在当前的index;
(24)这里表示e节点扩容后需要移动到index + oldCap的位置上去;
- (25、26)到这里,需要将两条链表放到新数组正确的位置上去,这样就完成了扩容操作
;
五、 HashMap数据查询机制
数据查询比较简单,我们来分析一下HashMap的get操作是如何完成的;
public V get(Object key) {
Node e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
上来首先计算了key的hash,然后在调用getNode来实现节点查找,接下来看一下getNode方法的实现;
final Node getNode(int hash, Object key) {
// 1
Node[] tab; Node first, e; int n; K k;
// 2
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
// 3
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
// 4
if ((e = first.next) != null) {
// 5
if (first instanceof TreeNode)
return ((TreeNode)first).getTreeNode(hash, key);
// 6
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
// 7
return null;
}
(1)tab变量指向当前table,first是当前哈希槽的第一个节点的引用,e用于迭代链表;
(2)首先tab赋值,然后需要判断当前table是否为空,以及first赋值及检测是否为空,如果这些检测没通过,则说明当前HashMap中不可能存在key节点,直接返回null即可;
(3)如果first节点就是需要找到的key节点,则直接返回first节点;
(4)如果当前哈希槽只有一个节点,那么到此搜索结束,没有找到key节点,否则,继续在first结构上查找;
(5)如果当前槽内是一颗红黑树,则通过红黑树的查找方法来查找,这个分支暂时不看;
(6)否则,当前槽内就是一条链表,那么就需要迭代这条链表来找到目标节点;
(7)执行到这里,说明HashMap中不存在key节点;
六、 HashMap数据更新机制
数据更新操作主要看一下remove操作是如何实现的:
public V remove(Object key) {
Node e;
return (e = removeNode(hash(key), key, null, false, true)) == null ?
null : e.value;
}
还是会先计算key的哈希值,然后调用removeNode方法来执行删除操作;下面来看一下removeNode方法的实现细节:
/**
* Implements Map.remove and related methods
*
* @param hash hash for key
* @param key the key
* @param value the value to match if matchValue, else ignored
* @param matchValue if true only remove if value is equal
* @param movable if false do not move other nodes while removing
* @return the node, or null if none
*/
final Node removeNode(int hash, Object key, Object value,
boolean matchValue, boolean movable) {
// 1
Node[] tab; Node p; int n, index;
// 2
if ((tab = table) != null && (n = tab.length) > 0 &&
(p = tab[index = (n - 1) & hash]) != null) {
// 3
Node node = null, e; K k; V v;
// 4
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
node = p;
// 5
else if ((e = p.next) != null) {
// 6
if (p instanceof TreeNode)
node = ((TreeNode)p).getTreeNode(hash, key);
// 7
else {
do {
// 8
if (e.hash == hash &&
((k = e.key) == key ||
(key != null && key.equals(k)))) {
node = e;
break;
}
p = e;
} while ((e = e.next) != null);
}
}
// 9
if (node != null && (!matchValue || (v = node.value) == value ||
(value != null && value.equals(v)))) {
// 10
if (node instanceof TreeNode)
((TreeNode)node).removeTreeNode(this, tab, movable);
// 11
else if (node == p)
tab[index] = node.next;
// 12
else
p.next = node.next;
++modCount;
// 13
--size;
return node;
}
}
return null;
}
(1)tab是当前table的引用,p指向定位到哈希槽的头结点,index是当前key节点的哈希槽位置;
(2)获取到p节点,如果p节点为null,说明当前HashMap中不可能存在key,所以删除失败,返回null,结束删除流程;
(3)node表示找到的key节点,也就是指向将要被删除掉的Node;
(4)这个分支表示当前槽内的头结点就是所要删除的Node,赋值给node变量;
(5)表示槽内的头节点并不是所要删除的节点,那么就要继续在p结构中查找,需要判断一些槽内是否就一个p节点,如果是,那么就可以结束删除流程,当前HashMap中不可能存在key节点;
(6)如果p结构是一颗红黑树,那么就要使用查找红黑树的方法查找节点;
(7)否则,就要在链表p中找到key节点;
(8)迭代整个p链表,找到key节点,并赋值给node变量,但可能没找到,此时node为null;
(9)如果node为null,则说明没有找到需要删除的数据,也就是不存在需要删除的节点,否则,就要执行删除操作;
(10)如果需要删除的node节点是一个红黑树节点,那么就调用红黑树的节点删除方法;
(11)如果node和p相等,那么就说明需要删除的节点是链表的头结点,只需要将头结点移动到next即可实现节点删除;
(12)否则,就要删除node节点,此时,p节点就是node节点的前一个节点,删除node节点只需要执行 p.next = node.next就可以实现;
(13)删除一个节点之后,需要将size减1;
七、 HashMap并发安全问题分析
我们都知道,HashMap是线程不安全的,所谓线程不安全,就是使用不当在并发环境下使用了HashMap,那么就会发生不可预料的问题,一个典型的问题就是cpu 100%问题,这个问题在java 7中是因为扩容的时候链表成环造成的,这个成环是因为java 7在迁移节点的时候使用的是头插法,在java 8中使用了尾插法避免了这个问题,但是java 8中依然存在100%的问题,在java 8中是因为红黑树父子节点成环了。
下面,来简单分析一下java 7中链表成环的问题:
/**
* Transfers all entries from current table to newTable.
*/
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for (Entry e : table) {
while(null != e) {
// 1
Entry next = e.next;
// 2
int i = indexFor(e.hash, newCapacity);
// 3
e.next = newTable[i];
// 4
newTable[i] = e;
// 5
e = next;
}
}
}
如图所示,在扩容前,位置1上有一条长度为3的链表,扩容后数组的长度为4,这条链表的key=5的节点会被迁移到新数组位置为1的位置上,其余两个节点会按照尾插法迁移到新数组位置为3的位置上。
假设两个线程同时指向扩容,thread1执行到代码位置(1)的时候失去cpu,thread2此时获得cpu并完成了数据迁移,之后,thread1重新获得cpu,开始执行迁移操作,此时e执行key=3的节点,next指向key=7的节点,而此时thread2完成迁移后,key=7的节点的next为key=3的节点,此时已经成环,此时如果有线程执行get等查询操作,那么就可能陷入死循环;如果thread1可以继续执行迁移,执行注释中(3)这行代码后,就将key=3的节点的next指向了key=7的节点,执行(4)后,key=3的节点成了头结点,执行(5)后,e指向了key=7的节点,接着继续下一轮迁移;这样,这个迁移永远也完成不了,只会不断在更新槽位的头结点,死循环了。所以,如果是在并发环境下,我们应该使用线程安全的并发HashMap,
ConcurrentHashMap是最好的选择,当然还有其他的方案,但是如果可以使用
ConcurrentHashMap,其他方案都不推荐。
参考资料:
为什么HashMap线程不安全 https://www.jianshu.com/p/e2f75c8cce01
java 8HashMap源码分析 https://www.jianshu.com/p/d5f0a99966f7
Java 7 HashMap put多线程并发操作导致cpu 100%
Java HashMap源码深度分析