HashMap:哈希表🚩
JAVA8的HashMap利用了红黑树,所以其由 数组+链表+红黑树 组成。
核心属性:
// 数组table 也有人叫它桶
transient Node<K,V>[] table;
// 所有键值对的集合
transient Set<Map.Entry<K,V>> entrySet;
transient int size;
int threshold;
// 负载因子
final float loadFactor;
// 继承自父类的:
transient Set<K> keySet;
transient Collection<V> values;
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
}
threshold是什么,怎么来的
threshold是触发扩容的临界值,threshold = 75,当存入第76个数,++size > threshold,触发resize()函数扩容
threshold的值由两个来源:
- resize()根据负载因子和Cap计算得到,甚至可以说这也是唯一的途径
- 初始化指定Capacity,会找到离Cap最近的2^n赋值给threshold
这里是个误区,我们初始化指定的实际上就是Cap而不是threshold,threshold只是暂存一下Cap的值方便后续resize()时初始化而已,详细见:
< 扩容处理Capacity 与 threshold >章节
为什么初始化指定的明明是Cap,却被赋值给了threshold?🚩
因为数组的长度length就是Cap,我们没有必要在浪费4个字节去存储Cap。同时由于懒加载的思想,数组会等到第一次put时通过resize()扩容方法,做初始化。因此我们在初始化的时候,指定了Cap,却没办法赋值给Cap变量,因为没有;也没办法new数组,因为懒加载。
因此,既然threshold都是要依赖Cap计算而来的,那当然在Cap没有时,暂存Cap的值。节省4字节的空间。
负载因子loadFactor
负载因子从客观上来说它代表每个 bucket 桶存储的最大平均元素个数。但它存在的意义主观上来说是 帮助Cap计算threshold,让HashMap知道扩容的阈值。在初始化会修改Cap,Cap * loadFactor可得threshold
new一个HashMap时,是我们指定loadFactor的唯一机会。除了初始化的时候指定,没有别的操作会修改loadFactor了,并且new了之后不可修改。
loadFactor默认为0.75,一般不建议修改。
扩容时处理Capacity 与 threshold
注释写的很清楚了:
Node<K,V>[] oldTab = table;
// 旧容量
int oldCap = (oldTab == null) ? 0 : oldTab.length;
// 旧threshold
int oldThr = threshold;
int newCap, newThr = 0;
// oldCap > 0 已经不是第一次了
if (oldCap > 0) {
// 去掉了特殊情况的判断 理解为cap和thr一起翻倍即可
newCap = oldCap << 1;
newThr = oldThr << 1; // double threshold
}
// oldThr > 0 但oldCap = 0 这说明是第一次,且new时指定了初始容量
// new时指定的初始容量会被暂存在threshold变量里,因为cap是数组的length,懒加载思想,当时数组仍未初始化
else if (oldThr > 0)
newCap = oldThr;
else {
// 说明是第一次,且new时 没有 指定初始容量
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
// 根据loadFactor计算 newThr
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
后续:
- 创建一个新的Entry空数组,长度是原数组的2倍。
- ReHash:遍历原Entry数组,把所有的Entry重新Hash到新数组。 因为容量是2的幂,因此rehash的时候,只需要看新增的一位是0还是1即可,是0位置不变。这样避免了计算hash值,提高了效率。
为什么初始化时数组大小为16
更应该问:为什么大小必须是2的幂
为了位运算的方便,比起取模,性能更高。
hash函数:index = HashCode(Key) & (Length- 1)
那么 如果二进制 length-1 = 1111 做与运算相当于对16取模,因此实现了与运算和取模效果一样,而性能更高。
(n - 1) & hash = n % hash
16是一个经验值,不太大,也不会太小。
题外话:如何找到离capacity最近的2^n
tableSizeFor方法写的很巧妙,利用位运算最小化时间开销。
// 思路是:找到cap最高位的1 让后面所有的二进制位都变为1,然后+1即可
// -1 是防止出现如:0000 1000 这样的数,本身就是2^n
// 为什么是右移5次?
// 假设最高位为x,第一次|,使 x ~ x + 1 为 1 1
// 第2次|,使 x + 2 ~ x + 3 为1 2
// 第3次|,使 x + 4 ~ x + 7 为1 4
// 第4次|,使 x + 8 ~ x + 15 为1 8
// 第5次|,使 x + 16 ~ x + 31 为1 16
// 因此最多可以处理 1+2+4+8+16 加上自己本身的1,32位,刚好等于一个int的位
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;
}
为什么采用红黑树不用AVL树
AVL树:
严格平衡的二叉搜索树,必须满足所有节点的左右子树高度差不超过1。
红黑树:
红黑树确保没有一条路径会比其它路径长出两倍。
因此红黑树的插入节点效率更高,而AVL查找效率更高。
因为concurrenthashmap的put操作会加锁,阻塞后续的put/get,因此我们希望插入的效率要高一些。
红黑树节点小于6也是会退化成链表的。
什么对象可以作为key
- 重写hashcode
- 重写equals
- 对象不可变,这个往往是容易被忽略的一点
比如Integer的实现类如下:
// 包装了 一个 int 且用final修饰
private final int value;
String类的char数组同样有 final 修饰
也就是key必须是不可变的。为什么?
因为如果被修改了,那么equals,hashcode方法都会变,但你hashcode到的位置已经固定了
HashMap死锁问题,尾插法
多线程扩容的时候,头插法,会导致链表成环 于是while将会一直循环下去,形成死循环,不停增大JVM开销,最后内存溢出。
因此后来修改为:向链表插入数据时是尾插法
因为头插会引起死循环:Java7(头插)在多线程操作HashMap时可能引起死循环,原因是扩容转移后前后链表顺序倒置(头插入导致) ,在转移过程中修改了原来链表中节点的引用关系。
HashMap会缩容吗
HashMap没有缩容机制(redis的dict有) ;但是红黑树节点小于6也是会退化成链表的。