Java HashMap精讲版

版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
本文链接: https://blog.csdn.net/L__ear/article/details/88313913

基于 JDK1.8

1、是否允许 key 为 null?

允许 key 为 null。

// 进一步处理的 key 原本哈希值的哈希函数
static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

如果 key 为 null,则哈希值返回 0。最终 key 为 null 对应的键值对存储在 HashMap 数组下标为 0 的位置。

2、是否线程安全,和 1.7 比在线程安全上做了什么改进?

因为没有加同步锁,所以是非线程安全的。1.7 的时候,HashMap 在扩容时,采用的是头插的方式,可能发生链表逆序,在多线程环境下,链表可能会形成环,当 HashMap 遍历这个环时,就会发生死循环(老生常谈,HashMap的死循环)。1.8 在扩容时,按原链表的顺序插入,不会构成环。

3、HashMap 的底层存储结构是什么?

HashMap 的底层存储结构为:数组 + 链表 + 红黑树,使用链地址法(拉链法)来解决哈希冲突。由于链表的查找时间复杂度为 O ( n ) O(n) ,比较慢,所以在 1.8 引入了红黑树,红黑树的查找时间复杂度为 O ( log n ) O(\log n) ,提高了发生哈希冲突时的查找效率。
HashMap 源码中,关于红黑树的三个阈值:

// 当链表长度大于 8 时,转换为红黑树
static final int TREEIFY_THRESHOLD = 8;

// 在扩容时,当红黑树的结点个数小于等于 6 时,转换为链表
static final int UNTREEIFY_THRESHOLD = 6;

// 当链表长度大于 8,但 HashMap 数组长度小于 64 时
// 并不将链表转换为红黑树,而是对 HashMap 数组进行扩容。
static final int MIN_TREEIFY_CAPACITY = 64;
4、HashMap 数组的初始化发生在什么时候?

HashMap 数组的初始化发生在用户第一次向 HashMap 添加键值对时,即调用 put() 函数后进行的。

// HashMap 数组的类型,是 HashMap 的内部类
static class Node<K,V> implements Map.Entry<K,V> {
        // key 对象的哈希值,是由 key 对象 hashCode() 方法的返回值进一步加工得到的值。
        final int hash;
        final K key;
        V value;
        Node<K,V> next;

        Node(int hash, K key, V value, Node<K,V> next) { ... }
        public final K getKey(){ ... }
        public final V getValue() { ... }
        public final String toString() { ... }
        public final int hashCode() { ... }
        public final V setValue(V newValue) { ... }
        public final boolean equals(Object o) { ... }
}
/********************************************************************************/
// HashMap 数组,在 HashMap 的构造函数中,并没有初始化这个数组
transient Node<K,V>[] table;
/********************************************************************************/
// HashMap 的 put() 函数,实际上是对 putVal() 函数的调用
public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}

// putVal() 函数首先就会判断数组是否为空或者长度是否为 0
// 如果是就调用 resize() 函数完成数组的初始化。
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
		boolean evict) {
	Node<K,V>[] tab; Node<K,V> p; int n, i;
    // 判断数组是否为空或长度为 0
	if ((tab = table) == null || (n = tab.length) == 0)
		n = (tab = resize()).length;  // 调用扩容函数 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) {  // 判断旧数组长度是否大于 0
        ...
    }
    else if (oldThr > 0) // 初始化为用户指定的长度
        newCap = oldThr;
    else {               // 否则,初始化为默认长度 16
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    ...
    @SuppressWarnings({"rawtypes","unchecked"})
        Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap]; // 数组的初始化
    table = newTab;
    ...
}
5、HashMap 数组长度有什么规律或特点?

HashMap 的数组长度恒为 2 的幂,好处是可以使用位运算代替取余运算。

// 无参构造函数
public HashMap() {
    this.loadFactor = DEFAULT_LOAD_FACTOR; // 初始化加载因子为默认值
}
// 指定初始容量,实际上调用的是双参数构造函数
public HashMap(int initialCapacity) {
    this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
// 指定初始容量及加载因子
public HashMap(int initialCapacity, float loadFactor) {
    // 前面是对参数的合法性判断
    ...
    this.loadFactor = loadFactor;
    // 如果用户指定的初始容量不为 2 的幂,则调用 tableSizeFor() 函数将初始容量向上调整为最近的 2 的幂。
    this.threshold = tableSizeFor(initialCapacity);
}
// 返回刚好大于等于参数 cap,且为 2 的幂的值
static final int tableSizeFor(int cap) {
    int n = cap - 1;
    // 五次无符号右移并与自身异或,可以将 n 的二进制表示中,最高位及其所有低位全部置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;
}
/********************************************************************************/
// resize() 函数负责数组初始化和数组扩容
// 数组初始化时长度为 2 的幂,以后扩容时每次变为原来的 2 倍,所以 HashMap 数组的长度恒为 2 的幂。
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; // 扩容后的数组长度为原数组长度的 2 倍
    }
    else if (oldThr > 0) // 初始化数组长度为用户指定的值,这个值会被构造函数转换为 2 的幂
        newCap = oldThr;
    else {               // 否则,初始化为默认长度 16,也为 2 的幂
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    ...
}
6、HashMap 是如何由 key 对象的 hashCode() 方法得到 key 对应键值对在数组中的存储位置的?
  1. 首先调用 key 对象的 hashCode() 方法,获取 key 对象的原始哈希值。
  2. 将原始哈希值无符号右移 16 位与其自身做异或运算,得到 key 对象最终的哈希值。
  3. 对 key 的哈希值关于数组长度取余,得到存储 key 对应键值对的数组下标。(实际中,使用了更高效的位运算等价替代了取余运算。只有当数组长度为 2 的幂时,才能替代,这也是 Java HashMap 数组长度保持为 2 的幂的原因)
/**
* 哈希值的计算分为两步(key 非空,如果为空直接返回 0):
* 1. h = key.hashCode()
* 2. hash = h ^ (h >>> 16)
*/
static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
/**
* 1.7 的时候计算数组下标还单独为一个函数,不过由于只有一句代码,且使用频
* 繁,1.8 省去函数调用的开销,直接将计算数组下标的代码放在了需要计算数组下
* 标时。
*/
// 下面是计算数组下标时的代码
// 计算方式:(n - 1) & hash,当 n 为 2 的幂时,等价于 hash % n,n 为数组长度
tab[i = (n - 1) & hash]

可以结合下图理解整个计算过程(n 为数组长度,假设为 16):
得到key对应结点所在的数组下标的计算过程
为什么不直接拿原始哈希值计算数组下标?
因为关于数组长度对原始哈希值取余,用到的仅仅是原始哈希值的低位信息。尤其是当数组长度较短时,会有更多的高位信息没有用到,参照上图所示,当数组长度为 16 时,取余运算用到的仅仅是哈希值的低 4 位信息。所以直接拿原始哈希值做取余运算的话,就算原始哈希值高位相差很大,而低位相似,也很容易会发生哈希冲突。
而将原始哈希值无符号右移 16 位,把高位信息移到低位,再与原始哈希值做异或,得到的最终哈希值,其低位部分混合了原始哈希值的高位和低位的信息,可以很充分的利用原始哈希值,使得键值对哈希的更均匀,碰撞的概率更小。

7、HashMap 在数组扩容时,链表上的键值对是如何重哈希的?

HashMap 的扩容过程为,先申请一个长度为旧数组二倍的新数组,然后遍历旧数组,将所有键值对链入新数组。在这个过程中,由于数组长度发生了变化,哈希值关于新的数组长度取余也可能发生变化,所以需要重新计算键值对对应的数组下标,即重哈希。
1.7 的做法是对每个键值对都重新进行等价的取余运算,然后直接将键值对以头插的方式插入新数组的对应位置。1.8 做了改变,源码的设计者发现键值对的重哈希,只会出现两种情况,要么结果不变,要么变为原索引值加旧数组长度。之所以这样,还是因为 HashMap 数组长度的特殊性(恒为 2 的幂),所以关于新数组长度取余,仅仅就是比关于旧数组取余多用了一个哈希值的二进制位(哈希值与旧数组长度做与运算就可以取出这个二进制位)。既然只有两种情况,那么在遍历旧数组链表时可以按这两种情况分类,把旧链表链成两个新链表,旧链表遍历结束后就能直接把两个新链表链到新数组的对应位置。由于新链表中键值对结点的顺序和旧链表一致,从而避免了 1.7 中多线程环境下插入时新链表可能形成环的情况。

两种情况如下所示(a 为计算旧索引的情况,b 为计算新索引的情况):
重哈希后数组下标变化的两种情况
从图中可以看出,新数组取余后得到的新索引的最高位如果为 0,那么新索引和旧索引就是一样的;如果为 1,那么新索引就比旧索引大一个旧数组的数组长度。
新索引的计算方式
1.8 数组长度 16 扩容到 32 的例子(键值对结点的相对顺序不会改变):
扩容的示例
扩容的源码主体:

final Node<K,V>[] resize() {
    Node<K,V>[] oldTab = table;
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    int oldThr = threshold;
    int newCap, newThr = 0;
    ...
    @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 { // 重哈希,并将当前链表上的所有键值对移到新数组
                    Node<K,V> loHead = null, loTail = null; // loHead 链表用于链接索引不变的结点
                    Node<K,V> hiHead = null, hiTail = null; // hiHead 链表用于链接索引改变的结点
                    Node<K,V> next;
                    do {
                        next = e.next;
                        if ((e.hash & oldCap) == 0) { // 判断更高一位的哈希值是否为 0
                            // 将索引不变的所有结点,链到 loHead 链表中
                            if (loTail == null)
                                loHead = e;
                            else
                                loTail.next = e;
                            loTail = e;
                        }
                        else {
                            //将索引改变的所有结点,链到 hiHead 链表中
                            if (hiTail == null)
                                hiHead = e;
                            else
                                hiTail.next = e;
                            hiTail = e;
                        }
                    } while ((e = next) != null);
                    if (loTail != null) { // 将 loHead 链表,链入新数组中
                        loTail.next = null;
                        newTab[j] = loHead;
                    }
                    if (hiTail != null) { // 将 hiHead 链表,链入新数组中
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}

猜你喜欢

转载自blog.csdn.net/L__ear/article/details/88313913
今日推荐