如果刚好触发扩容,那么当前用户请求会被阻塞,因为 HashMap的底层是基于数组+链表(红黑树)来实现的,一旦它发生扩容,就需要新增一个比之前大2倍的数组,然后将元素copy到新的数组上
而 1G 的 HashMap 够大,所以扩容需要一定的时间,而扩容使用的又是当前的线程,所以用户此时会被阻塞,等待扩容完毕。
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
先计算key的hash值,再对其put
static final int hash(Object key) {
int h;
//为什么要右移16位? 默认长度为2^5=16,与hash值&操作,容易获得相同的值。
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
这里分为了三步:
第3步(n-1)&hash原理:
实际上(n-1) & hash等于 hash%n都可以得到元素在桶中的位置,但是(n-1)&hash操作更快。
取余操作如果除数是 2 的整数次幂可以优化为移位操作。这也是为什么扩容时必须是必须是2的n次方
位运算(&)效率要比代替取模运算(%)高很多,主要原因是位运算直接对内存数据进行操作,不需要转成十进制,因此处理速度非常快。
而计算hash是通过同时使用hashCode()的高16位异和低16位实现的(h >>> 16):这么做可以在数组比较小的时候,也能保证考虑到高低位都参与到Hash的计算中,可以减少冲突,同时不会有太大的开销。
hash值其实是一个int类型,二进制位为32位,而HashMap的table数组初始化size为16,取余操作为hashCode & 15 ==> hashCode & 1111 。这将会存在一个巨大的问题,1111只会与hashCode的低四位进行与操作,也就是hashCode的高位其实并没有参与运算,会导很多hash值不同而高位有区别的数,最后算出来的索引都是一样的。 举个例子,假设hashCode为1111110001,那么1111110001 & 1111 = 0001,如果有些key的hash值低位与前者相同,但高位发生了变化,如1011110001 & 1111 = 0001,1001110001 & 1111 = 0001,显然在高位发生变化后,最后算出来的索引还是一样,这样就会导致很多数据都被放到一个数组里面了,造成性能退化。 为了避免这种情况,HashMap将高16位与低16位进行异或,这样可以保证高位的数据也参与到与运算中来,以增大索引的散列程度,让数据分布得更为均匀(个人认为是为了分布均匀)
put流程如下:
// 第四个参数 onlyIfAbsent 如果是 true,那么只有在不存在该 key 时才会进行 put 操作
// 第五个参数 evict 我们这里不关心
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)//初始判断,初始数组为空时
// resize()初始数组,需要进行扩容操作
n = (tab = resize()).length;
//这里就是上面的第三步,根据key的hash值找到数据在table中的位置
if ((p = tab[i = (n - 1) & hash]) == null)
//通过hash找到的数组下标,里面没有内容就直接赋值
tab[i] = newNode(hash, key, value, null);
else {//如果里面已经有内容了
Node<K,V> e; K k;
if (p.hash == hash &&
//hash相同,key也相同,那就直接修改value值
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
else if (p instanceof TreeNode)
//key不相同,且节点为红黑树,那就把节点放到红黑树里
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;
}
}
//传入的K元素已经存在,直接覆盖value
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;
}
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
//检查是否满足转换成红黑树的条件,如果数组大小还小于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);
}
}
总结put方法流程:
如果table没有初始化就先进行初始化过程
使用hash算法计算key的索引判断索引处有没有存在元素,没有就直接插入
如果索引处存在元素,则遍历插入,有两种情况,
一种是链表形式就直接遍历到尾端插入,
一种是红黑树就按照红黑树结构
插入链表的数量大于阈值8,且数组大小已经大等于64,就要转换成红黑树的结构
添加成功后会检查是否需要扩容
//table数组的扩容操作
final Node<K,V>[] resize() {
//引用扩容前的node数组
Node<K,V>[] oldTab = table;
//旧的容量
int oldCap = (oldTab == null) ? 0 : oldTab.length;
//旧的阈值
int oldThr = threshold;
//新的容量、阈值初始化为0
int newCap, newThr = 0;
//计算新容量
if (oldCap > 0) {
//如果旧容量已经超过最大容量,让阈值也等于最大容量,以后不再扩容
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
//没超过最大值,就令newcap为原来容量的两倍
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
//如果旧容量翻倍没有超过最大值,且旧容量不小于初始化容量16,则翻倍
newThr = oldThr << 1; // double threshold
}
else if (oldThr > 0) // initial capacity was placed in threshold
//旧容量oldCap = 0时,但是旧的阈值大于0,令初始化容量设置为阈值
newCap = oldThr;
else { // zero initial threshold signifies using defaults
//两个值都为0的时候使用默认值初始化
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;
//如果e没有next节点,证明这个节点上没有hash冲突,则直接把e的引用给到新的数组位置上
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;
//从这条链表上第一个元素开始轮询,如果当前元素新增的bit是0,则放在当前这条链表上
//如果是1,则放在"j+oldcap"这个位置上,生成“低位”和“高位”两个链表
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);
//低位链表放到j这个索引的位置上
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
//高位链表放到(j+oldCap)这个索引的位置上
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
显然,HashMap的扩容机制,就是当达到扩容条件时会进行扩容。扩容条件就是当HashMap中的元素个数超过临界值时就会自动扩容(threshold = loadFactor * capacity)。如果是初始化扩容,只执行resize的前半部分代码,但如果是随着元素的增加而扩容,HashMap需要重新计算oldTab中每个值的位置,即重建hash表,随着元素的不断增加,HashMap会发生多次扩容,这样就会非常影响性能。所以一般建议创建HashMap的时候指定初始化容量
但是当使用HashMap(int initialCapacity)来初始化容量的时候,HashMap并不会使用传进来的initialCapacity直接作为初始容量。JDK会默认计算一个相对合理的值当做初始容量。所谓合理值,其实是找到第一个比用户传入的值大的2的幂。也就是说,当new HashMap(7)创建HashMap的时候,JDK会通过计算,创建一个容量为8的Map;当new HashMap(9)创建HashMap的时候,JDK会通过计算,创建一个容量为16的Map。当然了,当创建一个HashMap
时,表的大小并不会立即分配,而是在第一次put元素时进行分配,并且分配的大小会是大于或等于初始容量的最小的2的幂。
一般来说,initialCapacity = (需要存储的元素个数 / 负载因子) + 1。注意负载因子(即 loaderfactor)默认为 0.75,如果暂时无法确定初始值大小,请设置为 16(即默认值)。HashMap 需要放置 1024 个元素,由于没有设置容量初始大小,随着元素增加而被迫不断扩容,resize() 方法总共会调用 8 次,反复重建哈希表和数据迁移。当放置的集合元素个数达千万级时会影响程序性能。
//初始化容量
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
public HashMap(int initialCapacity, float loadFactor) {
//保证initialCapacity在合理范围内,大于0小于最大容量
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);
}
static final int tableSizeFor(int cap) {
int n = cap - 1;
//|=计算方式:两个二进制对应位都为0时,结果等于0,否则结果等于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;
}
/**
* 红黑树分裂方法
*/
final void split(HashMap<K,V> map, Node<K,V>[] tab, int index, int bit) {
//当前这个节点的引用,即这个索引上的树的根节点
TreeNode<K,V> b = this;
// Relink into lo and hi lists, preserving order
TreeNode<K,V> loHead = null, loTail = null;
TreeNode<K,V> hiHead = null, hiTail = null;
//高位低位的初始树节点个数都设成0
int lc = 0, hc = 0;
for (TreeNode<K,V> e = b, next; e != null; e = next) {
next = (TreeNode<K,V>)e.next;
e.next = null;
//bit=oldcap,这里判断新bit位是0还是1,如果是0就放在低位树上,如果是1就放在高位树上,这里先是一个双向链表
if ((e.hash & bit) == 0) {
if ((e.prev = loTail) == null)
loHead = e;
else
loTail.next = e;
loTail = e;
++lc;
}
else {
if ((e.prev = hiTail) == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
++hc;
}
}
if (loHead != null) {
if (lc <= UNTREEIFY_THRESHOLD)
//!!!如果低位的链表长度小于阈值6,则把树变成链表,并放到新数组中j索引位置
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);
}
}
}
/**
* 将树转变为单向链表
*/
final Node<K,V> untreeify(HashMap<K,V> map) {
Node<K,V> hd = null, tl = null;
for (Node<K,V> q = this; q != null; q = q.next) {
Node<K,V> p = map.replacementNode(q, null);
if (tl == null)
hd = p;
else
tl.next = p;
tl = p;
}
return hd;
}
/**
* 链表转换为红黑树,会根据红黑树特性进行颜色转换、左旋、右旋等
*/
final void treeify(Node<K,V>[] tab) {
TreeNode<K,V> root = null;
for (TreeNode<K,V> x = this, next; x != null; x = next) {
next = (TreeNode<K,V>)x.next;
x.left = x.right = null;
if (root == null) {
x.parent = null;
x.red = false;
root = x;
}
else {
K k = x.key;
int h = x.hash;
Class<?> kc = null;
for (TreeNode<K,V> p = root;;) {
int dir, ph;
K pk = p.key;
if ((ph = p.hash) > h)
dir = -1;
else if (ph < h)
dir = 1;
else if ((kc == null &&
(kc = comparableClassFor(k)) == null) ||
(dir = compareComparables(kc, k, pk)) == 0)
dir = tieBreakOrder(k, pk);
TreeNode<K,V> xp = p;
if ((p = (dir <= 0) ? p.left : p.right) == null) {
x.parent = xp;
if (dir <= 0)
xp.left = x;
else
xp.right = x;
//进行左旋、右旋调整
root = balanceInsertion(root, x);
break;
}
}
}
}
moveRootToFront(tab, root);
}
总结HashMap的实现:
总结HashMap的扩容和树形化:
即当数组的某一个索引位置上的元素以链表形式存在的数据个数>8且当前数组的长度>64时,此时此索引位置上的所有数据改为使用红黑树存储
1.8扩容机制:当元素个数大于threshold
时,会进行扩容,使用2倍容量的数组代替原有数组。采用尾插入的方式将原数组元素拷贝到新数组。1.8扩容之后链表元素相对位置没有变化,而1.7扩容之后链表元素会倒置。
1.7链表新节点采用的是头插法,这样在线程一扩容迁移元素时,会将元素顺序改变,导致两个线程中出现元素的相互指向而形成循环链表,1.8采用了尾插法,避免了这种情况的发生。
原数组的元素在重新计算hash之后,因为数组容量n变为2倍,那么n-1的mask范围在高位多1bit。在元素拷贝过程不需要重新计算元素在数组中的位置,只需要看看原来的hash值新增的那个bit是1还是0,是0的话索引没变,是1的话索引变成“原索引+oldCap”(根据e.hash & oldCap == 0
判断) 。这样可以省去重新计算hash值的时间,而且由于新增的1bit是0还是1可以认为是随机的,因此resize的过程会均匀的把之前的冲突的节点分散到新的bucket。
扩容触发条件:当 HashMap 中的元素数量超过 容量x负载因子
时,会触发扩容。扩容会将容量扩展为当前容量的两倍,并将所有键值对重新分配到新的桶(bucket)中。
性能影响:扩容是一个耗时的操作,因为它需要重新计算每个键的哈希值,并将键值对重新分配到新的桶中。因此,频繁的扩容会显著影响性能,特别是在存储大量数据时。
Hashmap 采用2的n次方倍作为容量,主要是为了提高哈希值的分布均匀性和哈希计算的效率
HasMap通过(n - 1)&hash来计算元素存储的索引位置,这种位运算只有在数组容量是2的n次方时才能确保案引均匀分布,位运算的效率高于取模运算(hash%n),提高了哈希计算的读度
且当 hashMap扩容时,通过容量为2的n次方,扩容时只需通过简单的位运算判断是否需要迁移,这减少了重新计算哈希值的开销,提升了 rehash 的效率。
HashMap有扩容机制,就是当达到扩容条件时会进行扩容。扩容条件就是当HashMap中的元素个数超过临界值时就会自动扩容(threshold = loadFactor * capacity)。
如果没有设置初始容量大小,随着元素的不断增加,HashMap会发生多次扩容。而HashMap每次扩容都需要重建hash表,非常影响性能。所以建议开发者在创建HashMap的时候指定初始化容量。
先看下HashMap的默认构造函数:
int threshold; // 容纳键值对的最大值
final float loadFactor; // 负载因子
int modCount;
int size;
Node[] table的初始化长度length为16,默认的loadFactor是0.7。
为什么默认负载因子是 0.75?官方答案如下:
As a general rule, the default load factor (.75) offers a good tradeoff between time and space costs. Higher values decrease the space overhead but increase the lookup cost (reflected in most of the operations of the HashMap class, including get and put). The expected number of entries in the map and its load factor should be taken into account when setting its initial capacity, so as to minimize the number of rehash operations. If the initial capacity is greater than the maximum number of entries divided by the load factor, no rehash operations will ever occur.
上面的意思,简单来说是默认负载因子为 0.75,是因为它提供了空间和时间复杂度之间的良好平衡。 负载因子太低会导致大量的空桶浪费空间,负载因子太高会导致大量的碰撞,降低性能。0.75 的负载因子在这两个因素之间取得了良好的平衡。也就是说官方并未对负载因子为 0.75 做过的的解释,只是大概的说了一下,0.75 是空间和时间复杂度的平衡,但更多的细节是未做说明的,Stack Overflow 进行了负载因子的科学推测,感兴趣的可以学习学习
也就是说所,0.75是对空间和时间效率的一个平衡选择,根据泊松分布,loadFactor 取0.75碰撞最小。一般不会修改,除非在时间和空间比较特殊的情况下 :
如果内存空间很多而又对时间效率要求很高,可以降低负载因子Load factor的值 。
如果内存空间紧张而对时间效率要求不高,可以增加负载因子loadFactor的值,这个值可以大于1。