Java集合答疑解惑之HashMap:常见问题 | 核心原理 | 源码分析

338 阅读4分钟

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

  1. 重写hashcode
  2. 重写equals
  3. 对象不可变,这个往往是容易被忽略的一点

比如Integer的实现类如下:

// 包装了 一个 int 且用final修饰 
private final int value;

String类的char数组同样有 final 修饰

也就是key必须是不可变的。为什么?

因为如果被修改了,那么equals,hashcode方法都会变,但你hashcode到的位置已经固定了

HashMap死锁问题,尾插法

多线程扩容的时候,头插法,会导致链表成环 于是while将会一直循环下去,形成死循环,不停增大JVM开销,最后内存溢出。

因此后来修改为:向链表插入数据时是尾插法

因为头插会引起死循环:Java7(头插)在多线程操作HashMap时可能引起死循环,原因是扩容转移后前后链表顺序倒置(头插入导致) ,在转移过程中修改了原来链表中节点的引用关系。

HashMap会缩容吗

HashMap没有缩容机制(redis的dict有) ;但是红黑树节点小于6也是会退化成链表的。