HashMap是我们平时开发中接触得比较多的数据容器之一。jdk1.8之后还对HashMap进行了优化。本篇将首先介绍jdk1.6版本的HashMap相关知识,涉及到jdk1.8的后续章节补充。我们将围绕如下几点展开:HashMap数据结构、存储和resize过程、fail-fast机制。
1. HashMap的基本数据结构
HashMap是K-V型容器,对于每一个K-V值,保存在内部HashMap.Entry对象中。对于不同的Entry对象,依据哈希值,保存在数组字段table中。针对Hash冲突的情况,采用链式地址法,即Entry对象可以形成一个链表结构,指向相同哈希的下一个值。
public class HashMap<K,V>
extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable
{
/**
* The table, resized as necessary. Length MUST Always be a power of two.
*/
transient Entry[] table;
//......
static class Entry<K,V> implements Map.Entry<K,V> {
final K key;
V value;
Entry<K,V> next;
final int hash;
}
}
2. put的基本流程
2.1 大致流程:
(0). 根据key的hash值,获取对应的位置索引;
(1). 判断k-v值是否已存在,存在则更新,结束;
(2). k-v值不存在,插入节点,采用头插法(插入到已有链表元素的前面);
(3). 判断当前是否需要扩容,否,结束;
(4). 如果需要扩容,创建新表,把旧表数据依次按照头插法,插入对应的bucket及链表。
注意: 如果是putAll,则会事先判断是否需要resize,然后再循环执行put方法。
2.2 步骤详解
public V put(K key, V value) {
if (key == null)
return putForNullKey(value);
// 对应步骤2.1-(0),根据key哈希,获取索引位置。注意之所以要再hash一次,是为了增加扰动,提高哈希值的随机性和均匀分布性
int hash = hash(key.hashCode());
int i = indexFor(hash, table.length);
//对应步骤2.1-(1),判断对应的k值是否存在,存在则更新
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
Object k;
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
modCount++;
//对应步骤2.1-(2)(3)(4),插入节点,并判断新添加元素后,是否需要扩容
addEntry(hash, key, value, i);
return null;
}
void addEntry(int hash, K key, V value, int bucketIndex) {
Entry<K,V> e = table[bucketIndex];
// 获取当前索引的头部元素,并且把新值的next指针指向头部元素,然后新插入的元素就做为新的头部元素-头插入法
table[bucketIndex] = new Entry<K,V>(hash, key, value, e);
// 判断是否需要扩容,后续介绍
if (size++ >= threshold)
resize(2 * table.length);
}
3. resize过程
resize的基本思想还是比较简单的。就是把旧的table[]中的元素,一个一个地根据原先的哈希规则,保存到新的扩容好的table[]对象中。然后使用新的table[]对象即可。当然,这些都是基于单线程的,在多线程的条件下,会出问题,此处先不分析。我们直接从代码入手,看下resize的具体过程。
void resize(int newCapacity) {
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
// 边界修正
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}
// 存储扩容后的新值的数组
Entry[] newTable = new Entry[newCapacity];
// 旧数据转移,真正扩容的方法
transfer(newTable);
// 指向新数组
table = newTable;
threshold = (int)(newCapacity * loadFactor);
}
/**
* Transfers all entries from current table to newTable.
*/
void transfer(Entry[] newTable) {
Entry[] src = table;
int newCapacity = newTable.length;
// 外层for循环,按顺序读取每一个bucket的Entry对象
for (int j = 0; j < src.length; j++) {
Entry<K,V> e = src[j];
if (e != null) {
// 旧数组的bucket指针置null
src[j] = null;
// 遍历当前bucket的所有链表值,并根据最新的容量,计算新的索引值,然后采用头插法,插入新值
do {
Entry<K,V> next = e.next;
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
} while (e != null);
}
}
}
4. fail-fast机制
fail-fast机制,就是指在使用迭代器遍历元素的过程当中,如果在其它线程中对HashMap进行元素增加或删除时,迭代器会抛ConcurrentModificationException。需要注意的是,fail-fast机制是一种尽最大努力抛异常的机制,业务开发中,不能依赖这种机制。
fail-fast机制的实现,直观上理解还是比较简单的,其主要依赖如下俩字段,modCount和expectedModCount,并且判断两者是否相等:
public class HashMap<K,V>
extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable
{
transient volatile int modCount;
private abstract class HashIterator<E> implements Iterator<E> {
// ......
int expectedModCount; // For fast-fail
HashIterator() {
// 在新建一个迭代器的时候,会保存新建时的修改次数
expectedModCount = modCount;
// ......
}
final Entry<K,V> nextEntry() {
// 因为modeCount是跟随HashMap的,所以如果有别的线程对HashMap进行了元素的增删(注意,不包括修改),那么modeCount就会计数加一;而expectedModCount是初始化时就定下来的,那么通过比较修改次数,就可以发现HashMap被修改了。
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
// ......
}
}
6. 总结
6.1 需关注的点
(1) capacity一定是2的n次方。在初始化的时候指定了capacity大小,则会挑选一个比指定capacity大的最小的2的n次方的数。
int capacity = 1;
while (capacity < initialCapacity)
capacity <<= 1;
6.2 可能产生的问题
(1) 并发put后,在get时可能产生死循环,消耗CPU:
这个主要是因为resize的原因,resize完后,新的链表相对于原来的链表,是倒置过的,即,原来某个bucket的链表是:bucket[i]=A->B->C,因为HashMap扩容新元素采用的是头插法,这里我们假设A\B\C三个元素,扩容后还是属于同一个bucket。那么新的链表中就会变成:newBucket[i]=C->B->A。
接下来,我们加入并发操作的情况。假设另一个线程也引起了resize操作,线程1顺利地修改了链表,线程2之前已经读取了A元素,现在准备插入A元素到newBucket[i],此时的newBucket[i]=C->B->A,因为线程2的操作,又变成了newBucket[i]=A->C->B->A,出现是循环了。
(2) 并发put时,可能导致元素丢失:
我们知道,要插入新元素,会调addEntry方法,该方法会在某个bucket的头部插入新值,具体是插入新值后,再修改bucket的指向指针,如果有两个线程都同时执行完了new Entry()方法,那么就会出现两次table[bucketIndex]的赋值,显然有一次的插入就会丢失了。
void addEntry(int hash, K key, V value, int bucketIndex) {
Entry<K,V> e = table[bucketIndex];
table[bucketIndex] = new Entry<K,V>(hash, key, value, e);
// ......
}
(3)put非null元素后get出来的却是null:
transfer方法中,会先把bucket置null,这时候如果刚好有线程去读这个bucktet,那拿出来的就是null了。
void transfer(Entry[] newTable) {
// ......
// 外层for循环,按顺序读取每一个bucket的Entry对象
for (int j = 0; j < src.length; j++) {
Entry<K,V> e = src[j];
if (e != null) {
// 旧数组的bucket指针置null
src[j] = null;
// ......
}
}
}
网友评论