HashMap源码分析
我们先大致的了解一下HashMap。
从使用的角度上看,它使用起来非常简单,put操作时,保存的是键值对,get操作时,通过键就即可拿到值,它的键值对都是支持null的,这在一定程度上提升了容错率,另外,HashMap实际上是非线程安全的,从内在角度看,它使用了hash算法+数组+链表的结构存储数据,而在JDK 1.8及之后的版本增加红黑树的数据结构来存储数据。
构造方法(1.8)
HashMap的构造方法有四个,分别如下:
1、public HashMap(int initialCapacity, float loadFactor)
2、public HashMap(int initialCapacity)
3、public HashMap()
4、public HashMap(Map<? extends K, ? extends V> m)
以上四个构造方法,第二个实际上调用了第一个,而第三个,没啥好讲的,第四个嘛,就是传入一个map,将整个map存储的数据赋值给构建出来的HashMap,也没有啥好讲的。
那么我们就直接看第一个构造方法:
public HashMap(int initialCapacity, float loadFactor) {
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
this.loadFactor = loadFactor;
this.threshold = tableSizeFor(initialCapacity);
}
它传入两个参数并将它们赋值给内部成员变量,分别是初始容量和加载因子,方法的最后通过tableSizeFor(initialCapacity)
计算出阈值threshold
的大小,注意哦,是threshold !我们看看tableSizeFor方法:
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;
}
这个方法用于找到大于等于initialCapacity的最小的2的幂(initialCapacity如果就是2的幂,则返回的还是这个数)。 我们举个例子大概看看它的流程是怎样的:
假设 cap = 10,则 n = 9
n = 9 ——> 二进制1001,
右移1位为0100
n = 1001 | 0100, 结果是 1101
右移2位为0011
n = 1101 | 0011, 结果是 1111
右移4位为0000
n = 1111 | 0000, 结果是 1111
右移8位为0000
n = 1111 | 0000, 结果是 1111 ——> 转为10进制,即为15
所以,方法最后会返回:
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1; 也就是16
由此,我们可以看出tableSizeFor(10)
将会return 16。为什么要用位运算呢?当然是为了提高运行效率啦,据说,位运算能比取模运算快了大概27倍。
PUT方法(1.8)
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
可以看到,HashMap的put方法需要传入两个参数,分别是key和value,然后put方法又调用了putVal方法,而putVal方法的传参中还有一个hash(key)
方法,那我们就先看看hash(key)
方法:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
hash(Object key)
方法中,如果key为null,则直接return 0,否则执行(h = key.hashCode()) ^ (h >>> 16)
,这个段稍微有点长,我们对它分解一下,流程如下:
1、通过key.hashCode()
计算key的hashCode,并保存在h
变量中
2、将h
和h的右移16位的值
进行亦或计算
3、返回步骤2计算得到的值
其中,hashCode()方法是Object.java类的方法,它是用来计算对象的hashCode值,这里要明确一下:
- 一个对象有且只有一个hashCode
- 两个对象的hashCode不同,它们肯定不是同一个对象
- 两个对象的hashCode相同,它们可能是同一个对象,也可能不是同一个对象
hashCode是int型,我们知道,int的范围是[-2147483648, 2147483647],因此hashCode是有限的,而对象是无限的。
那么可想而知,有限的hashCode映射在无限的对象上,那么必定会有不同的对象使用同一个hashCode的情况。
h的右移16位怎么理解呢?举个简单的例子:
比如一个int型HashCode数据h,它的二进制如下:
h1: 0000 0010 0011 1001 1101 1111 1010 0101
执行>>>16,右移16位后,它二进制如下:
h2: 0000 0000 0000 0000 0000 0010 0011 1001
h1 ^ h2,执行h1和h2的亦或后(同为0不同为1),得到hash值,它的二进制如下:
h3: 0000 0010 0011 1001 1101 1101 1001 1100
可以看到,亦或的过程中,h3的高16位恒等于h1的高16位,h3的低16位本质上是h1自己的高16位和自己的低16位进行亦或得到值。为什么要这么做呢,其实是为了让key的hashCode的32位都参与到运算中,增加随机性,会让HashMap计算得到的数组下标更加散列。
由此,我们已经完了对hash(Object key)
方法的分析。
那么我们继续回到put(K key, V value)
方法:
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
我们跟进putVal(hash(key), key, value, false, true)
方法看看:
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;
if ((p = tab[i = (n - 1) & hash]) == null)
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;
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) // -1 for 1st
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;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
1、如果数组tab为null或者数组的大小为0,则执行resize()
方法进行扩容
2、将数组的大小-1和hash值进行与运算,得到元素应存放的数组下标i
假设数组的容量是16,则15的二进制为:
15: 0000 0000 0000 0000 0000 0000 0000 1111
h3即为h1和h2的亦或运算得到的hash值,二进制表示如下:
h3: 0000 0010 0011 1001 1101 1101 1001 1100
将h3和hash值进行与运算(都为1时为1,否则就为0)的结果如下:
h4: 0000 0000 0000 0000 0000 0000 0000 1100 ——> 转为十进制为12
可见,通过&运算得到的HashMap数组的下标,必定在数组的大小以内,因为当数组容量为15时,整个hash值只有低4位能参与运算。
3、如果数组下标i对应的元素p为null,则新建一个链表节点,放置在该数组下标处
4、如果数组下标i对应的元素p不为null,分四种情况:
- 如果p的hash等于传入的hash,且p的key等于传入的key,或传入的key的值和p的key的值相同,则将元素p赋值给元素e。
- 如果p的hash不等于传入的hash,或p的key不等于传入的key,且传入的key的值和p的key的值不相同,且p为树节点,
- 如果p的hash不等于传入的hash,或p的key不等于传入的key,且传入的key的值和p的key的值不相同,且p不为树节点,则进入for循环,遍历链表,找到p的next节点为null,则新建一个链表节点赋值给p的next结点,从这里也可以看出,是以尾插法的形式添加链表新结点的,如果链表结点的数量大于等于树化阈值TREEIFY_THRESHOLD-1的值,则将链表转化为红黑树,其中TREEIFY_THRESHOLD的为8。也就是说,
添加新链表结点后,如果链表结点数量大于等于7,则将链表转化为红黑树
。
5、把新添加的元素的value值赋值为oldValue,然后put方法传入的value赋值为新添加的元素,然后将oldValue返回。
6、判断此时HashMap的数组大小是否大于扩容阈值threshold,是的话就对数组进行扩容。其中threshold=容量 * 加载因子,加载因子默认为0.75
GET方法(1.8)
再来看它的get方法:
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
可以看到,先根据key计算出key的hash值,然后将其传入getNode(hash(key)
方法:
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;
}
1、可以看到,跟put方法一样,get方法也有将数组的大小-1的值和hash值进行与运算,得到元素所在的数组下标,我们可以暂且认为该下标对应的元素即为链表的头结点first
2、如果first的hash值与传入的hash值,且first的key与传入的key是同一个对象或它们值相等,则直接将first返回
3、如果头结点不是想要拿到的结点,则进入循环,遍历整个链表(或红黑树),找到符合hash值相同,且key相同的元素,然后返回
4、如果经历以上流程都找不到,则return null。
HashMap的扩容机制
HashMap在什么时候需要扩容呢?在put方法的流程中我也看到了,有两个地方可能会发生扩容:
-
执行put方法时,如果结点数组tab为null或者它的大小为0,则执行
resize()
方法进行扩容(一般来说,刚初始化完HashMap,结点数组tab就会为null),所以这次扩容发生在初始化HashMap后的第一次执行put方法。 -
将结点元素添加到HashMap后,如果此时结点数组大于扩容阈值threshold,就进行扩容
扩容是指哪里的扩容呢?
其实就是对结点数组这个容器进行扩容,我们都知道,数组在初始化的时候就已经固定大小了,当存储满了,就无法继续存放元素了,而链表和树并没有扩容这个概念,因为它们的容量就是无限的。
为什么要扩容呢?
我们可能会有个疑问:HashMap的结构是数组+链表,每个数组下标都对应着一条链表,那么我们只要保证每个新添加的元素都在数组下标对应的链表上,就永远不会发生数组越界问题,为啥还要扩容呢?其实,HashMap的扩容更多的是从性能方面上考虑的,我们知道,链表的查询效率是O(n),一旦链表过长,那么HashMap查找效率将会变得很低,当然,jdk1.8后引入了红黑树,使得链表达到一定长度后就树化,优化了一定的查找效率。但是,为了保证较高的查找效率,对结点数组进行扩容仍是必不可少的。
但是,扩容是个特别耗时的操作,所以在我们使用HashMap时,最好能估算出它的大致容量并使用这个容量来创建HashMap,避免HashMap内部频繁进行扩容。
JDK1.7的扩容
下面我们进入JDK1.7的HashMap源码,看看它是如何扩容的:
Jdk1.7:
void resize(int newCapacity) {
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
//如果旧容量已经达到了最大,将阈值设置为最大值,与1.8相同
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}
//创建新哈希表
Entry[] newTable = new Entry[newCapacity];
//将旧表的数据转移到新的哈希表
transfer(newTable, initHashSeedAsNeeded(newCapacity));
table = newTable;
//更新阈值
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
resize方法的大致流程如下:
1、旧数组存入oldTable变量,旧容量大小存入oldCapacity变量
2、如果旧容量已经达到了最大,将阈值threshold设置为最大值,并且return,说明无法继续扩容了。与1.8相同
3、根据oldCapacity值创建新结点数组newTable
4、执行transfer
方法将旧数据转移到新的哈希表上
5、更新扩容阈值
下面重点来了,我们继续跟进transfer
方法:
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
//遍历旧表
for (Entry<K,V> e : table) {
while(null != e) {
Entry<K,V> next = e.next;
//如果hashSeed变了,需要重新计算hash值
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
//得到新表中的索引
int i = indexFor(e.hash, newCapacity);
//将新节点作为头节点添加到桶中
e.next = newTable[i];
newTable[i] = e;
e = next;
}
}
}
1、先获取到新数组的大小
2、遍历旧的HashMap
3、每遍历到一个HashMap中的一个结点数组索引,就对该索引下的链表进行遍历
4、判断链表结点 e 是否需要重新计算hash值
5、计算得到链表结点 e 应该放在数组中的哪个索引处,即索引 i 处
6、将结点 e 以头插法的形式插入该数组索引下
正是因为1.7的扩容使用了头插法的形式进行,因此在多线程环境使用HashMap将发生死循环问题,我们在下面的篇幅也会重点讲解这个点!
JDK1.8的扩容
那么如何扩容呢?下面我们进入源码看看,定位到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;
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; // double threshold
}
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
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;
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 { // preserve order
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 {
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;
}
1、将当前的结点数组赋值给oldTab,
2、获取旧的结点数组的容量oldCap,如果oldCap已经已经大于HashMap支持的最大容量,则直接返回oldTab,也就是说不能再扩容了,因为已经达到最大容量。
3、新的容量大小等于oldCap << 1的值,即容量扩大1倍后,新容量如果小于最大容量,将新的扩容阈值newThr在旧的扩容阈值基础上扩大一倍。
4、如果旧容量大小小于等于0且旧扩容阈值大小oldThr大于0,则将oldThr赋值给newCap,注意这里是将阈值大小赋值给结点数组的容量大小。我们回忆一下,在初始化HashMap时,传入的map容量,最终会找到大于等于容量的最小的2的幂,赋值给扩容阈值,而这个扩容阈值的值就是在这个时候赋值给数组容量的。
5、如果旧容量大小小于等于0且旧扩容阈值大小oldThr小于等于0,那么newCap将被赋值为默认容量大小,newThr将被赋值为默认加载因子*默认容量大小的值,即newThr = 0.75 * 16 = 12
6、HashMap的成员变量threshold赋值为newThr。构造一个容量大小为newCap的结点数组newTab,并将newTab赋值给HashMap的成员变量table。
7、进入一个循环,这个循环是用来遍历结点数组的,先拿到oldTab[0]的链表的头结点e,如果该结点不为null,将oldTab[0]赋值为null,然后分三种情况处理:
(1)如果头结点的next结点为null,也就是说该链表只有一个结点的情况,则执行newTab[e.hash & (newCap - 1)] = e;
,计算得到一个新的数组下标,把链表头结点e放置到这个下标对应的数组位置。
(2)如果头结点的next结点不为null,且e是一个树节点,说明链表已变成红黑树,则执行split()
方法进行扩容
(3)如果头结点的next结点不为null,且e不是一个树节点,也就是说该链表结点不止一个的情况下,再开一个循环,这个循环就是遍历链表结点,如果(e.hash & oldCap) == 0
,则表示扩容后结点在数组中的位置跟扩容前一样,而当(e.hash & oldCap) != 0
,则表示扩容后结点在数组中的位置在扩容前的(原索引+oldCap)处,例如扩容前结点所在索引是4,容量是16,则扩容后将在20处。这是二进制与运算的一个规律,这也是HashMap在Java8才用到的一个新的规律,运用这个规律,省去了重新计算hash值的时间,一定程度上提高了扩容的效率。另外,扩容前后,链表结点的顺序跟扩容前是一致的(1.7扩容会出现链表结点倒置)。这部分我们单独举个例子理解一下。
我觉得第7点的第三种情况有必要单独讲一下,它是扩容机制种需要重点理解的东西:
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 {
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;
}
首先我们看这个操作e.hash & oldCap
,为什么它只有0和非0两种情况呢?我们举个简单的例子:
假设e结点此时的hash是1234,oldCap为16:
hash二进制为 :0000 0000 0000 0000 0000 0010 1001 1100
oldCap二进制为:0000 0000 0000 0000 0000 0000 0001 0000
它们进行
&运算的结果 :0000 0000 0000 0000 0000 0000 0001 0000,其10进制表示为16
可见,由于oldCap的低5位为"10000",所以无论hash值为多少,它都只有倒数第5位能参与运算,其他位数无论如何只能是0,而倒数第5位,有可能是0,也有可能是1。因此e.hash & oldCap
计算出来的值,只有非0和0两种结果,并且我们可以认为0和非0出现的概率是相等的,这样就可以将扩容前的链表均匀的分散在两个数组索引下了,同时也省去了重新计算hash值的时间,一定程度上提高了扩容的效率。
doWhile循环的代码怎么理解呢?
首先看到四个变量
- loHead:低位头指针,指向低位数组索引下的链表头结点
- loTail:低位尾指针,指向低位数组索引下的链表尾结点
- hiHead:高位头指针,指向高位数组索引下的链表头结点
- hiTail:低位尾指针,指向高位数组索引下的链表头结点
我们依旧是举个例子看看:
假设当前正在比遍历到数组的某索引,该索引下有如下链表:
e:n1 -> n2 -> n3 -> n4 -> null
现在开始对这个链表进行遍历,
遍历到n1, 计算(e.hash & oldCap) == 0的值为true,
此时,loHead指向n1,loTail也指向n1,
遍历到n2, 计算(e.hash & oldCap) == 0的值为false,
此时,hiHead指向n2,hiTail也指向n2,
遍历到n3, 计算(e.hash & oldCap) == 0的值为true,
此时,loTail.next指向n3,因为上次循环后,loTail和loHead的指向是一样的,因此,loHead.next也指向n3,最后,loTail再指向n3
遍历到n4, 计算(e.hash & oldCap) == 0的值为false,
此时,hiTail.next指向n4,因为上次循环后,hiTail和hiHead的指向是一样的,因此,hiHead.next也指向n4,最后,hiTail再指向n4
循环结束..
当经历完这个doWhile循环后,loHead对应的链表为n1 -> n3,hiHead对应的链表为n2 -> n4,然后再执行loTail.next = null;
和hiTail.next = null;
,此时loHead对应的链表为n1 -> n3 -> null,hiHead对应的链表为n2 -> n4 -> null。
最后,执行newTab[j] = loHead;
和newTab[j + oldCap] = hiHead;
将loHead赋值给newTab[j],hiHead赋值给newTab[j + oldCap]。
至此,扩容结束。
哈希冲突(哈希碰撞)
什么是哈希冲突呢?
它的意思是在计算不同元素的key哈希值(hash)时,得到多个相同哈希值,相同的哈希值,就意味着元素所在的数组索引位置也相同,这种情况,就叫哈希冲突。
那么应该怎样解决哈希冲突的呢?
有两种常用的解法:链表法和开放地址法。而HashMap就是使用链表法来解决哈希冲突的,
链表法:当结点数组的索引上没有元素时,新添加的元素将会被添加到数组的指定索引位置上,而当结点数组的索引上已经有元素了,则元素将会以链表的形式存放在数组的这个索引位置上,其中链表的头结点的指针指向数组这个索引位置的地址。
线程不安全的问题
官方介绍文档上已经明确说过了,HashMap是线程不安全的,那么为啥会线程不安全?
首先是JDK1.7的HashMap上,在多线程环境下操作HashMap可能引起死循环。
原因是在HashMap扩容时,链表转移后,前后链表顺序倒置(尾插法导致),在转移过程中修改了原来链表中节点的引用关系,导致链表结点互相引用,即形成了环,这种情况下,当我们使用get曹操获取到环形链表处的数据,就会发生死循环。
在JDK1.8中,同样的前提下并不会引起这个死循环,原因是扩容转移后前后链表顺序不变,保持了之前节点的引用关系。
但是即使1.8不会出现死循环,但是由于put、get方法都没有加同步锁,多线程操作仍是不安全的。
例如,我们无法保证上一秒put的值,下一秒get的时候还是原值,这就是数据不一致的问题,所以线程安全是无法保证。
那么,死循环问题是如何产生的呢?虽然平时的开发场景我们几乎不能遇见,因为我们一般都不会在单线程环境下使用HashMap,但这并不影响我们理解其中的原理。
我之前写过一篇文章就对其产生过程进行了详细分析:
感兴趣的大佬可以去观摩观摩,并且如果发现本人有理解不对的地方,感谢帮我及时指出~