【HashMap 笔记】

1、成员变量 DEFAULT_INITIAL_CAPACITY 为什么是2的n次方?

// 默认容量 16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;  //16


1.1 为什么必须是2的n次幂?
因为只有是2n,才可以通过 hash & (leng-1) 计算出的索引尽可能保证数据分布均匀.
如果不是2的n次幂,计算出的索引特别容易相同,很容易发生hash碰撞,导致其余数组空间很大程度上没有存储数据,链表或者红黑树过长,效率较低.

1.2 进一步分析如下

2n的二进制是一个首位是1 后面为是0的数,如 23 二进制为00001000 23-1 二进制为00000111
hash & (2n-1) 表示 hash值对应的二进制与n个1 做与运算 都为1 则为1 否则为0
只有当 一个数的二进制有效位全是1的情况下 ,不同hash值计算的结果差异性才会更多。
如: 以下结果很明显全为1的情况计算的差异性会更大

  1111111     100000
&001001    &001001
———————————————
  001001    000000

2、最大容量为什么是2的30次方(1左移30)?

    /**
     * 最大容量2的30次方
     */
    static final int MAXIMUM_CAPACITY = 1 << 30;

首先是 << 这个操作符必须要理解,在一般情况下 1 << x 等于 2^x。这是左移操作符,对二进制进行左移。
来看1 << 30。它代表将1左移30位,也就是0010...0
来看这样一段代码:

首先:JAVA规定了该static final 类型的静态变量为int类型,至于为什么不是byte、long等类型,原因是由于考虑到HashMap的性能问题而作的折中处理!

由于int类型限制了该变量的长度为4个字节共32个二进制位,按理说可以向左移动31位即2的31次幂。但是事实上由于二进制数字中最高的一位也就是最左边的一位是符号位,用来表示正负之分(0为正,1为负),所以只能向左移动30位,而不能移动到处在最高位的符号位!
 

3、加载因子为什么是0.75?

    /**
     * 加载因子
     *
     */
static final float DEFAULT_LOAD_FACTOR = 0.75f;

    
加载因子需要在时间和空间成本上寻求一种折衷,如果初始容量大于最大条目数除以加载因子,则不会发生 rehash 操作。
     
3.1 加载因子过高:
例如为1,虽然减少了空间开销,提高了空间利用率,但同时也增加了查询时间成本。
     
3.2 加载因子过低:
例如0.5,虽然可以减少查询时间成本,但是空间利用率很低,同时提高了rehash操作的次数。
     
     

4、链表转红黑树的原因?为什么阈值为8?

  /**
   */
    static final int TREEIFY_THRESHOLD = 8;

*      * 0:    0.60653066
*      * 1:    0.30326533
*      * 2:    0.07581633
*      * 3:    0.01263606
*      * 4:    0.00157952
*      * 5:    0.00015795
*      * 6:    0.00001316
*      * 7:    0.00000094
*      * 8:    0.00000006
*      * 其它情况: 不到一千万分之一

4.1 事实上,链表长度超过 8 就转为红黑树的设计,更多的是为了防止用户自己实现了不好的哈希算法时导致链表过长,从而导致查询效率低,而此时转为红黑树更多的是一种保底策略,用来保证极端情况下查询的效率。

4.2 通常如果 hash 算法正常的话,那么链表的长度也不会很长,那么红黑树也不会带来明显的查询时间上的优势,反而会增加空间负担。所以通常情况下,并没有必要转为红黑树,所以就选择了概率非常小,小于千万分之一概率,也就是长度为 8 的概率,把长度 8 作为转化的默认阈值。

4.3 所以如果平时开发中发现 HashMap 或是 ConcurrentHashMap 内部出现了红黑树的结构,这个时候往往就说明我们的哈希算法出了问题,需要留意是不是我们实现了效果不好的 hashCode 方法,并对此进行改进,以便减少冲突。

5、红黑树转链表为什么是6?

    /**
     * 红黑树转链表
     */
    static final int UNTREEIFY_THRESHOLD = 6;
  • 5.1 必须小于TREEIFY_THRESHOLD,如果都是 8,则可能陷入(树化<=>树退化)的死循环中. 若是 7,则当极端情况下(频繁插入和删除的都是同一个哈希桶)对一个链表长度为 8 的的哈希桶进行频繁的删除和插入,同样也会导致频繁的树化<=>非树化.

  • 5.2 更低时,当链表长度很小的时候,即使遍历,速度也非常快。而TreeNodes占用空间是普通Nodes的两倍。

6、MIN_TREEIFY_CAPACITY 最小树形化阈值默认64

 /**
   * 容量小于64,出现转红黑树的情况,则直接扩容,不转红黑树
   */
static final int MIN_TREEIFY_CAPACITY = 64;

树化的阈值,只有在整个哈希表中的所有个数超过64时,才考虑将链表转为红黑树。

而红黑树初始默认大小是16,因此,在扩容至64之前,都还是采用连续数组+链表的方式来存储。

7、计算数组index的时候,为什么要用位运算&呢?

主要是效率问题,位运算(&)效率要比取模运算(%)高很多,主要原因是位运算直接对内存数据进行操作,不需要转成十进制,因此处理速度非常快。在jdk1.8之前的index计算就是用的取模运算%。

8、put方法

    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)//初始化判断
            n = (tab = resize()).length;
        if ((p = tab[i = (n - 1) & hash]) == null)// 头节点是null,得到的i的值永远是小于 n - 1 的值,保证了下标的合法性
            tab[i] = newNode(hash, key, value, null);
        else {
            Node<K,V> e; K k;
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;// 替换
            else if (p instanceof TreeNode)// 是红黑树
                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) //  变红黑树
                            treeifyBin(tab, hash);
                        break;
                    }
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }
            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;
    }

1.首先判断table成员是否初始化,如果没有,则调用resize
2.通过传入键值对的key的hashCode和容量,马上得到了该映射所在的table数组下标。并通过 数组的取下标操作,得到该哈希桶的头节点。
3.如果没有发生哈希碰撞(头节点为null),那么直接执行新增操作。
4.如果发生了哈希碰撞(头节点不为null),那么分为两种情况: 1. 如果与桶内某个元素==返回true,或者equals判断相同,执行替换操作。 2. 如果与桶内所有元素判断都不相等,执行新增操作 ,可能是链表也可能是红黑树的插入。
5.链表新增操作后 会有两个判断: 1. 如果哈希桶是单链表结构,且桶内节点数量超过了TREEIFY_THRESHOLD(8),且size大于等于了 MIN_TREEIFY_CAPACITY(64),那么将该哈希桶转换为红黑树结构。 2. 如果新增后size大于了threshold,那么调用resize。

9、get方法

    public V get(Object key) {
        Node<K,V> e;
        return (e = getNode(hash(key), key)) == null ? null : e.value;
    }

    /**
     * Implements Map.get and related methods.
     *
     * @param hash hash for key
     * @param key the key
     * @return the node, or null if none
     */
    final Node<K,V> getNode(int hash, Object key) {
        Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (first = tab[(n - 1) & hash]) != null) {
            if (first.hash == hash && // always check first node
                ((k = first.key) == key || (key != null && key.equals(k))))
                return first;
            if ((e = first.next) != null) {
                if (first instanceof TreeNode)
                    return ((TreeNode<K,V>)first).getTreeNode(hash, key);
                do {
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        return e;
                } while ((e = e.next) != null);
            }
        }
        return null;
    }

1.调用key的hashcode方法,根据返回值定位到map里数组对应的下标
2.判断这个数组下标对应的头节点是不是为null,如果是,返回null
3.如果头节点不是null ,判断这个引用对应对象的key值的equals方法,跟查询的key值对比, 判断是否为true,如果是则返回这个对象的value值,否则继续遍历下一个节点。
4.如果遍历完map中的所有节点都无法满足上面的判断 则返回null

10、扩容(resize)流程

11、 key 的 hash 值,是怎么设计的?

拿到 key 的 hashCode,并将 hashCode 的高16位和 hashCode 进行异或(XOR)运算,得到最终的 hash 值。

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

12、HashMap 的底层数据结构

我们现在用的都是 JDK 1.8,底层是由“数组+链表+红黑树”组成,如下图,而在 JDK 1.8 之前是由“数组+链表”组成。

 13、为什么要改成“数组+链表+红黑树”?

主要是为了提升在 hash 冲突严重时(链表过长)的查找性能,使用链表的查找性能是 O(n),而使用红黑树是 O(logn)。

14、HashMap 其它属性和作用

除了用来存储我们的节点 table 数组外,HashMap 还有以下几个重要属性:

1)size:HashMap 已经存储的节点个数;

2)threshold:扩容阈值,当 HashMap 的个数达到该值,触发扩容。

3)loadFactor:负载因子,扩容阈值 = 容量 * 负载因子。

15、hashmap第一次分配内存是构造还是put?

第一次插入节点时,才会对 table 进行初始化,避免不必要的空间浪费。

16、HashMap 的容量有什么限制吗?

HashMap 的容量必须是2的N次方,HashMap 会根据我们传入的容量计算一个大于等于该容量的最小的2的N次方,例如传 9,容量为16。

17、总结下 JDK 1.8 主要进行了哪些优化?

1)底层数据结构从“数组+链表”改成“数组+链表+红黑树”,主要是优化了 hash 冲突较严重时,链表过长的查找性能:O(n) -> O(logn)。

2)计算 table 初始容量的方式发生了改变,老的方式是从1开始不断向左进行移位运算,直到找到大于等于入参容量的值;新的方式则是通过“5个移位+或等于运算”来计算。
 

// JDK 1.7.0
public HashMap(int initialCapacity, float loadFactor) {
    // 省略
    // Find a power of 2 >= initialCapacity
    int capacity = 1;
    while (capacity < initialCapacity)
        capacity <<= 1;
    // ... 省略
}
// JDK 1.8.0_191
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;
}

3)优化了 hash 值的计算方式,新的只是简单的让高16位参与了运算。

// JDK 1.7.0
static int hash(int h) {
    h ^= (h >>> 20) ^ (h >>> 12);
    return h ^ (h >>> 7) ^ (h >>> 4);
}
// JDK 1.8.0_191
static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

4)扩容时插入方式从“头插法”改成“尾插法”,避免了并发下的死循环。

5)扩容时计算节点在新表的索引位置方式从“h & (length-1)”改成“hash & oldCap”,性能可能提升不大,但设计更巧妙、更优雅。

18、除了 HashMap,还用过哪些 Map,在使用时怎么选择?

本文部分转载如下:

————————————————
版权声明:本文为CSDN博主「程序员囧辉」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/v123411739/article/details/106324537

猜你喜欢

转载自blog.csdn.net/szdenny/article/details/125400220