int i = hash("zhangshan") 假如 i 等于 1,就会把 e0 构造成一个节点,放入数组下标为 1 的位置。数组存放的是一个节点,该节点有指向下一个节点的指针 next, 如下:
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node <K,V> next;
}
int i = hash("zhangshan") 把字符串映射成一个整型,不同的字符串可能映射成相同的位置,有下面这种可能:
hash("zhangshan") == hash("lisi")
这就是 hash 碰撞,出现碰撞后,会以链表的方式追加在后面,就形成了上图中的结构。
如何确定 key 在数组中的位置
先看 jdk 1.7 中的实现:
public V put(K key, V value) {
if (table == EMPTY_TABLE) {
inflateTable(threshold);
}
if (key == null)
return putForNullKey(value);
// 获取 key 对应的整型 hash值
int hash = hash(key);
// 再将这个hash值转换为小于这个数组的整型值 i,然后将节点插入数组i位置
int i = indexFor(hash, table.length);
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++;
addEntry(hash, key, value, i);
return null;}
其中 hash 方法如下:
final int hash(Object k) {
int h = hashSeed;
if (0 != h && k instanceof String) {
return sun.misc.Hashing.stringHash32((String) k);
}
h ^= k.hashCode();
// This function ensures that hashCodes that differ only by
// constant multiples at each bit position have a bounded // number of collisions (approximately 8 at default load factor).
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
/**
* Returns index for hash code h.
* */
static int indexFor(int h, int length) {
// assert Integer.bitCount(length) == 1 : "length must be a non-zero power of 2";
return h & (length-1);
}
这段代码中的注释说。length 必须是 2 的 N 次方,我们来看看这是为什么
& 运算的规则是,同时为 1,结果才是 1,否则是 0,即 1 & 1 == 11 & 0 ==00 & 0 == 0
而 2 的 N 次方减一,的二进制一定是全为 1,比如 3 , 7 , 15 的二进制是 111111111 。正因为是这种结构, r = h & ( 2 ^ n -1) 的结果 r 一定小于 n, 且 r 取决于 h 的值,由此可以代替取余运算,像这种二进制的 & | ! ^ 运算是最接近计算机底层的,运算速度远远高于 % 运算,我简单测试一下,大约相差 10 倍。
在 JDK 1.8 版本之前,HashMap 底层的数据结构是数组 + 链表,如下图:
在 1.8 及以后是数组 + 链表 + 红黑树
重要的几个变量
存放数据
会对 "zhangshan" 进行一次 hash 运算, 把 “zhangshan” 这个字符串映射成一个小于数组长度的整型值。就像下面这样:
int i = hash("zhangshan")
假如 i 等于 1,就会把 e0 构造成一个节点,放入数组下标为 1 的位置。数组存放的是一个节点,该节点有指向下一个节点的指针next
, 如下:int i = hash("zhangshan")
把字符串映射成一个整型,不同的字符串可能映射成相同的位置,有下面这种可能:这就是 hash 碰撞,出现碰撞后,会以链表的方式追加在后面,就形成了上图中的结构。
如何确定 key 在数组中的位置
先看 jdk 1.7 中的实现:
其中
hash
方法如下:我们无需关注实现细节,只需知道这个
hash
方法会返回一个尽量分散的整型值 K. 下面一个关键步骤是如何把 k 转换为一个小于数组长度的值呢? 我们想到最直接的方法是取余运算%
, 即:K % table.length
, 是的。这样结果完全没问题,但是性能有问题,在我们常见的+ - * / %
运算中,%
效率是最低的。而HashMap
作为一个 java 内置的数据结构,会有大量的场景使用。对性能的要求就比较高,自然这里的indexFor
方法用的不是取余运算,而是&
运算, 如下:这段代码中的注释说。length 必须是 2 的 N 次方,我们来看看这是为什么
&
运算的规则是,同时为 1,结果才是 1,否则是 0,即1 & 1 == 1
1 & 0 ==0
0 & 0 == 0
而 2 的 N 次方减一,的二进制一定是全为 1,比如 3 , 7 , 15 的二进制是11
111
1111
。正因为是这种结构, r = h & ( 2 ^ n -1) 的结果 r 一定小于 n, 且 r 取决于 h 的值,由此可以代替取余运算,像这种二进制的& | ! ^
运算是最接近计算机底层的,运算速度远远高于%
运算,我简单测试一下,大约相差 10 倍。HashMap 的容量
但是要保证上述运算的准确性和效率,其中数组的长度 length 必须是 2 的 N 次方。那么我们在项目中的这种代码:
new HashMap<>(13)
, 数组的长度是 13 吗? 当然不是,而是以第一个大于 13 且是 2 的 N 次方的数 16, 作为数组的长度。我们先看一下 JDK 1.7 代码:初始化方法:
关于这个运算原理的讲解参考: https://segmentfault.com/a/1190000039392972
在后续的数组扩容中,新的数组容量也要遵循这个规则,这一点, JDK 1.8 和 1.8 之前的核心实现差不多。
HashMap 的扩容
并不是等到节点数量达到容量后才进行的扩容,而是设置了一个阈值,阈值小于等于容量。当节点数量达到阈值后就开始扩容,容量变为原来的 2 倍,在 1.8 之前,阈值 = 容量 * 加载因子。而在 1.8 中,阈值也是原来的 2 倍;如下:
容量和阈值的增长
1.8
1.7
节点的移动方式
在底层数组的扩容方法上,1.8 版本和 1.8 之前的版本相差最大,其中 1.8 之前,HashMap 的扩容在多线程下会产生死循环的问题。
我们先看一下 1.7 版本的扩容 :
1.7版本 节点移动步骤
1.7 版本扩容的核心方法只有上面一段,理解起来也不难,主要有下几个步骤:
一图胜千言:
用头插法会导致链表的顺序发生变化。其中每一步不再详解。下面看一下这种扩容方法在多线程下的问题
并发导致的死循环问题
经过几次循环形成了环,Thread1 线程后面在进行 Put 数据时,如果某个key 落在了这个有环节点位置,就会发生死循环。如下:
下面我们对比看一下 1.8 版本是如何解决这个问题的。
1.8版本 节点移动步骤
在1.8版本中仍保留了 数组+链表的结构,只有当HashMap中的容量大于某个值时,才会把链表转换为红黑树,提高检索效率。现在我们只关注扩容部分。
扩容的关键代码:
这里定义了四个指针,将某个链表分为两部分,链表节点和数组长度相与的结果作为分隔,等于0的放在以loHead为头节点的链表中,等1的放在以hiHead为头节点的链表中。如下图:
如上所示。这种移动方式没有改变节点关系的方向,所以并发之下也没有问题
扩容因子为什么是0.75
1.8版本链表与红黑树的转换