[Interview系列 知识储备回顾] 集合篇 - Map[0]

为什么负载因子是0.75

官方权威解释, 负载因子为什么默认是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).

作为一个通用规则,默认的负载因子(0.75)提供了一个良好的性能, 这个值权衡考虑时间和空间成本在内. 对应的负载因子值越高, 空间开销越小, 但查找的成本增加了(对应HashMap的大多数api而言, 包括getput)

笔者本人的理解是这样的, 负载因子这个变量, 是用来计算扩容的阈值, 如果这个阈值越大, 那么对其扩容的标准就高了, 因此对应的扩容次数也会相应地减少, 但是对应的遍历查找, 性能会有所降低, 比如数组中大多数都是链表, 而且链表长度过长, 或者是红黑树深度过深等等.因为在对时间效率和空间效率的权衡下, 官方给出的默认值是0.75.

为什么容量是2的幂次方

默认容量是16, 官方文档里有声明容量 must be a power of two.
并且在构造函数中, JDK1.8的tableSizeFor方法中, 会对传入的容量值处理, 处理为2的幂次方.

/**
 * Returns a power of two size for the given target capacity.
 * 对于传入的容量, 返回对应的2的幂次方值
 * 比如传入10, 返回16
 * 这里以传入10为例子
 */
static final int tableSizeFor(int cap) {
    // n = 9
    int n = cap - 1;
    // 巧妙的移位和位或运算实现
    // 1001 <= 9
    // 0100 <= 1001 >>> 1
    // 1101 => 13
    // ----------
    // 1101 <= 13
    // 0011 <= 1101 >>> 2
    // 1111 => 15
    // ----------
    // 1111 <= 15
    // 0000 <= 1111 >>> 4
    // 1111 => 15
    // ----------
    // ... 总共右移31位, 不考虑符号位.
    n |= n >>> 1;
    n |= n >>> 2;
    n |= n >>> 4;
    n |= n >>> 8;
    n |= n >>> 16;
    // 如果n为负数, 返回1
    // 如果n大于容量最大值, 返回容量最大值
    // 反之, 返回n+1
    return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}

对于容量为什么是2的幂次方, 需要从put这个API开始讲.

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;
   // 这里通过位与运算来替换取模运算.
   // 这里的n是tab数组的长度, 根据(n - 1) & hash来获取数组的落地下标
   // 这里举个例子, 如果数组的长度不为2的幂次方, 以15为例子.
   // 15 - 1 = 14 => 1110
   //              	1110
   // hash这里分别取 	0000 => 0000 下标为0
   // hash这里分别取 	0001 => 0000 下标为0
   // hash这里分别取 	0010 => 0010 下标为2
   // hash这里分别取 	0011 => 0010 下标为2
   // hash这里分别取 	0100 => 0100 下标为4
   // hash这里分别取 	0101 => 0100 下标为4
   // hash这里分别取 	0110 => 0110 下标为6
   // hash这里分别取 	0111 => 0110 下标为6
   // 因此你会发现, 0, 1, 3, 5 这几个下标都没有数据分配进去, 数组的资源造成了极大的浪费
   // ---------------------------------
   // 如果是16, 2的4次方
   // 16 - 1 = 15 => 1111
   //              	1111
   // hash这里分别取 	0000 => 0000 下标为0
   // hash这里分别取 	0001 => 0001 下标为1
   // hash这里分别取 	0010 => 0010 下标为2
   // hash这里分别取 	0011 => 0011 下标为3
   // hash这里分别取 	0100 => 0100 下标为4
   // hash这里分别取 	0101 => 0101 下标为5
   // hash这里分别取 	0110 => 0110 下标为6
   // hash这里分别取 	0111 => 0111 下标为7
   // 因此你会发现, 结果均匀分布在数组的每一个下标中.
   // ---------------------------------
   // 当然你这里用取模运算来替代位与运算, 但是从效率上来讲, 位与运算的时间效率更高
   if ((p = tab[i = (n - 1) & hash]) == null)
       tab[i] = newNode(hash, key, value, null);
   else {
   	   // 省略...
   }

为什么1.8版本需要引入红黑树

在JDK1.7版本, HashMap底层实现是由数组+链表实现的, 会出现链表长度过长, 导致查找性能低下.
在JDK1.8版本引入红黑树, 主要是为了解决链表长度过长的问题, 指定链表长度阈值, 转换为红黑树, 利用二分查找的原理改善查找性能.

为什么1.8版本链表插入方式改为尾插

在JDK1.7版本, 对于数组下标位存在值的情况, 会考虑头插法, 插入链表中. 在多线程的业务场景中, 假设出现多个线程对map进行put调用, 在触发resize扩容的情况下, 可能会出现环形链表的情况, 导致下一个线程调用get方法时, 会出现cpu死循环空询.
这里出现环形链表的原因, 网上流程图很多, 简单的来讲, 就是因为头插, 需要确认head节点, 并将next指向该节点, 但是在触发resize的情况, 该节点的next又指向了需要插入的节点, 因此形成了环形链表.

为什么复写Equals方法还需要复写hashCode方法

在hash索引到数组下标时, 如果对应下标存在值, 那么判断该节点是否与待插入节点是否相同, 需要通过hashequals进行判断.
如果你只复写了equals方法, 那么两个相同的对象(内存地址不同)在equals判定通过的情况下, 结果hashCode判定这两个对象是不同的对象, 这样的话hashMap中存入了这两个相同的对象. 与期望所负,

if (p.hash == hash &&
    ((k = p.key) == key || (key != null && key.equals(k))))
    e = p;

为什么内部还需要进行二次hash

reHash

对于内部hash算法, 官方给出的解释是这样的, 计算hashCode并对其高16位进行异或运算.
是为了充分利用高位的影响, 来打散低位的连续性, 从而减小hash冲突.

扩容机制是什么样的

resize
可以通过resize方法的usage, 来判定扩容实现的时机.
典型的像是putVal

// 数组未初始化
if ((tab = table) == null || (n = tab.length) == 0)
   n = (tab = resize()).length;
// putVal 节点插入后, 计算当前的size是否超出阈值threshold
++modCount;
if (++size > threshold)
    resize();

这里再康康resize这个API的内部源码实现

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;
        }
        // 如果旧容量 * 2 < 最大容量 
        // 并且旧容量 >= 默认容量16
        // 新阈值 = 旧阈值 * 2 
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1; // double threshold
    }
    else if (oldThr > 0) // initial capacity was placed in threshold
        newCap = oldThr;
    else {               // zero initial threshold signifies using defaults
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    // 如果新阈值为0, 说明旧数组未初始化.
    if (newThr == 0) {
        // 根据负载因子和新容量计算ft变量
        float ft = (float)newCap * loadFactor;
        // 如果新容量 < 最大容量限制 
        // 并且上述计算所得ft < 最大容量限制
        // 新阈值赋值为上述计算的ft, 反之为Integer.MAX_VALUE
        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;
    // 如果旧数组不为空, 还需要对内部的数据做重新hash操作获取对应的下标, 并落地下标
    if (oldTab != null) {
        for (int j = 0; j < oldCap; ++j) {
            Node<K,V> e;
            // 如果对应下标值不为空
            if ((e = oldTab[j]) != null) {
                // 为了gc
                oldTab[j] = null;
                // 如果不是链表, 只需hash再落地新的下标即可
                if (e.next == null)
                    newTab[e.hash & (newCap - 1)] = e;
                // 如果是红黑树, 需要split
                else if (e instanceof TreeNode)
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                // 这里对链表所有节点进行高低位处理
                else { // preserve order
                    // 分为low头节点和尾节点
                    Node<K,V> loHead = null, loTail = null;
                    // 分为high头节点和尾节点
                    Node<K,V> hiHead = null, hiTail = null;
                    Node<K,V> next;
                    do {
                        next = e.next;
                        // 如果节点的hash与旧容量的位与运算结果为0
                        if ((e.hash & oldCap) == 0) {
                            if (loTail == null)
                                loHead = e;
                            else
                                loTail.next = e;
                            loTail = e;
                        }
                        // 如果节点的hash与旧容量的位与运算结果不为0
                        else {
                            if (hiTail == null)
                                hiHead = e;
                            else
                                hiTail.next = e;
                            hiTail = e;
                        }
                    } while ((e = next) != null);
                    // 低位节点还是在老位置
                    if (loTail != null) {
                        loTail.next = null;
                        newTab[j] = loHead;
                    }
                    // 高位节点迁移到[老位置下标+旧容量]下标位
                    if (hiTail != null) {
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}
发布了7 篇原创文章 · 获赞 0 · 访问量 37

猜你喜欢

转载自blog.csdn.net/BOFA_ll/article/details/105479181
今日推荐