HashMap常见问题

目录

HashMap底层数据结构

1.7采用数组、链表

1.8采用数组、链表、红黑树

为什么使用红黑树?

为什么不用二叉树?

为什么不用平衡二叉树

HashMap put的流程

减少hash冲突在设计上的小心思

hash & (n-1)

可能你还会想问为啥不直接用hashcode%table.length 而用hash & (table.length - 1)?

解决hash冲突的办法

如何判断key相等

HashMap扩容

为何扩容的负载因子为0.75?

为何树化的阈值为8?

HashMap是线程安全的么?

并发场景下HashMap的问题

HashMap底层数据结构

首先HashMap分为两个版本,一个是JDK1.7版本下和JDK1.8版本(部分图片来源于网络)

1.7采用数组、链表

1.8采用数组、链表、红黑树

由图可以看到主要的数据结构就是数组加链表,可以把数组叫做桶位,每一个桶位存放的是链表的头节点,每一个头节点是一个Node类型(1.7叫Entry,一个意思)的节点。1.8引入了红黑树,引入红黑树的原因是在hash冲突严重,链表较长时查询的时间复杂度为O(n),而红黑树的查询效率是O(logn),也就是可以在哈希冲突严重的情况下尽可能保证查询效率。触发树化的条件是链表元素个数大于8,并且数组长度大于64(小于64的形况下通过进行数组扩容也可以减轻单个桶位内的hash冲突,避免树化)。

//桶位数组

transient修饰的原因:为了避免数组中过多空值被序列化,浪费资源,跟ArrayList内部数组用transient修饰一个道理。

Node有四个属性,如图

为什么使用红黑树?

为什么不用二叉树?

二叉树是最基本的树结构,每个节点最多有两个子节点,但是二叉树容易出现极端情况,比如插入的数据是有序的,那么二叉树就会退化成链表,查询效率就会变成 O(n)。

为什么不用平衡二叉树

平衡二叉树比红黑树的要求更高,每个节点的左右子树的高度最多相差 1,这种高度的平衡保证了极佳的查找效率,但在进行插入和删除操作时,可能需要频繁地进行旋转来维持树的平衡,这在某些情况下可能导致更高的维护成本。

采用红黑树是一种折中的方案,采用一种近似平衡的机制避免极端情况的出现,同时也能保证调平的效率。另外红黑树的插入、删除、查找的效率都是O(logn)。

红黑树需满足的五条特性

  1. 节点是红色或黑色
  2. 根是黑色
  3. 叶子节点(外部节点,空节点)都是黑色,这里的叶子节点指的是最底层的空节点(外部节点),下图中的那些null节点才是叶子节点,null节点的父节点在红黑树里不将其看作叶子节点
  4. 红色节点的子节点都是黑色
    • 红色节点的父节点都是黑色
    • 从根节点到叶子节点的所有路径上不能有 2 个连续的红色节点
  1. 从任一节点到叶子节点的所有路径都包含相同数目的黑色节点

对于红黑树的详细内容可以参考https://blog.csdn.net/cy973071263/article/details/

HashMap put的流程

先看个流程图有个大致的思路:

再来看看源码(看源码不要钻牛角尖)

public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    // 初始化桶数组 table,table 被延迟到插入新数据时再进行初始化
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    // 如果桶中不包含键值对节点引用,则将新键值对节点的引用存入桶中即可
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    else {
        Node<K,V> e; K k;
        // 如果键的值以及节点 hash 等于链表中的第一个键值对节点时,则将 e 指向该键值对
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
            
        // 如果桶中的引用类型为 TreeNode,则调用红黑树的插入方法
        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) // -1 for 1st
                        treeifyBin(tab, hash);
                    break;
                }
                
                // 条件为 true,表示当前链表包含要插入的键值对,终止遍历
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }
        
        // 判断要插入的键值对是否存在 HashMap 中
        if (e != null) { // existing mapping for key
            V oldValue = e.value;
            // onlyIfAbsent 表示是否仅在 oldValue 为 null 的情况下更新键值对的值
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        }
    }
    ++modCount;
    // 键值对数量超过阈值时,则进行扩容
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}

减少hash冲突在设计上的小心思

(看完这部分你能理解为什么数组长度一定为2的n次幂,hash函数的作用,为什么用&而不用%)

  1. hash扰动函数
  2. 数组长度需要是2的整数次幂
  3. hash & (n-1)
static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
//拿到 key 的 hashcode,是一个 32 位的 int 类型的数值,
//然后让 hashcode 的高 16 位和低 16 位进行异或操作。

下文有详细解释

/**
 * 计算出大于等于参数的第一个2的幂次方
 * 例如:1返回1,3返回4,8返回8,9返回16,125返回128,
 * 如果参数大于默认最大值,则容量取默认最大值。
 */
static final int tableSizeFor(int cap) {
    int n = cap - 1;      //容量减1,为了防止初始化容量已经是2的幂的情况,最后有+1运算。如果cap已经是2的幂, 又没有执行这个减1操作,则执
                                                // 行完后面的几条无符号右移操作之后,返回的capacity将是这个cap的2倍。
    n |= n >>> 1;         //将n无符号右移一位再与n做或操作
    n |= n >>> 2;         //将n无符号右移两位再与n做或操作
    n |= n >>> 4;         //将n无符号右移四位再与n做或操作
    n |= n >>> 8;         //将n无符号右移八位再与n做或操作
    n |= n >>> 16;        //将n无符号右移十六位再与n做或操作
    //如果入参cap为小于或等于0的数,那么经过cap-1之后n为负数,n经过无符号右移和或操作后仍未负 
    //数,所以如果n<0,则返回1;如果n大于或等于最大容量,则返回最大容量;否则返回n+1
    return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
//注:
//a |= b  即为 a = a|b
//>>>  是无符号右移运算符  无论正负,右移后,高位填充0

hash & (n-1)

先从hash & (n-1)开始说。

其作用就相当于 hash % n,n 为数组的长度,比如说数组长度是 16,hash 值为 20,那么 20 % 16 = 4,也就是说 20 这个元素应该放在数组的第 4 个位置;hash 值为 23,那么 23 % 16 = 7,也就是说 23 这个元素应该放在数组的第 7 个位置。& 操作的结果就是哈希值的高位全部归零,只保留 n 个低位,用来做数组下标访问。

比如说( hash & 2^4) 的结果实际上是取 hash 的低 4 位,这四位能表示的取值范围刚好是 0000 到 1111,也就是 0 到 15,正好是数组长度为 16 的下标范围。以初始长度 16 为例,16-1=15。2 进制表示是0000 0000 0000 0000 0000 0000 0000 1111。和某个哈希值做 & 运算,结果就是截取了最低的四位。

可能你想问为什么一大串哈希值只取后四位?岂不是会加剧hash冲突?

(h = key.hashCode()) ^ (h >>> 16)就是为了解决这个问题

它的作用就是让hash的高16位参与低16位的运算,也就是对hash值进行扰动。

举个例子:

第一个数:h1 = 0001 0010 0011 0100 0101 0110 0111 1000

第二个数:h2 = 0001 0010 0011 0101 0101 0110 0111 1000

如果没有 hash 函数,直接取低 4 位,那么 h1 和 h2 的低 4 位都是 1000,也就是两个数都会放在数组的第 8 个位置。

来看一下 hash 函数的处理过程。

①、对于第一个数h1的计算:

原始: 0001 0010 0011 0100 0101 0110 0111 1000
右移: 0000 0000 0000 0000 0001 0010 0011 0100
异或: ---------------------------------------
结果: 0001 0010 0011 0100 0100 0100 0100 1100

②、对于第二个数h2的计算:

原始: 0001 0010 0011 0101 0101 0110 0111 1000
右移: 0000 0000 0000 0000 0001 0010 0011 0101
异或: ---------------------------------------
结果: 0001 0010 0011 0101 0100 0100 0100 1101

通过上述计算,我们可以看到h1和h2经过h ^ (h >>> 16)操作后得到了不同的结果。

现在,考虑数组长度为 16 时(需要最低 4 位来确定索引):

对于h1的最低 4 位是1100(十进制中为 12)

对于h2的最低 4 位是1101(十进制中为 13)

这样,h1和h2就会被分别放在数组的第 12 个位置和第 13 个位置上,避免了哈希冲突。

可能你还会想问为啥不直接用hashcode%table.length 而用hash & (table.length - 1)

其实这就是HashMap的一种优化策略,让我们来深入了解一下。

对于 HashMap 来说,它需要通过 hash % table.length 来确定元素在数组中的位置,这种做法可以在很大程度上让元素均匀的分布在数组中。

先给出结论:在数组长度为2的n次幂时,hash % table.length = hash & (length - 1)

比如说 9 % 4 = 1,9 的二进制是 1001,4 - 1 = 3,3 的二进制是 0011,9 & 3 = 1001 & 0011 = 0001 = 1。

再比如说 10 % 4 = 2,10 的二进制是 1010,4 - 1 = 3,3 的二进制是 0011,10 & 3 = 1010 & 0011 = 0010 = 2。

当数组的长度不是 2 的 n 次方时,hash % length 和 hash & (length - 1) 的结果就不一致了。

比如说 7 % 3 = 1,7 的二进制是 0111,3 - 1 = 2,2 的二进制是 0010,7 & 2 = 0111 & 0010 = 0010 = 2。

从二进制角度看hash/2^n=hash>>n

备注:<< 左移相当于hash*2^n(二的n次方),>> 右移相当于hash/2^n(2的n次方)。

比如15/4 = 3 余 1 二进制角度看 15/4=15>> 2^2 = 1110 右移2位 得出 0011(对应二进制的3),被移除掉1的就是余数。

0010 0101(37) >> 3(8=2^3) = 0000 0100 (4) 移除掉的为0101(5) 即:37/8(2的三次方) 商为4,余数为 5 与37%8的值相等。

hash%length的操作是求hash除以2^n,在二进制中的结果就是hash的二进制的最低n位。所以取模的时候高于n位的二进制位没有对取模做贡献。

hash&(length-1)实际就是保留hash二进制表示的低n位,其高位被置为0。

注:& 与运算:两个操作数中位都为 1,结果才为 1,否则结果为 0。

举个例子,hash 为 14,数组长度为 2^3,也就是 8,n=3。

  1110 (hash = 14)
& 0111 (length - 1 = 7)
  ----
  0110 (结果 = 6)
//保留了后三位,其高位被置为0,后三位的值为6(0+2+4)

结论:当在数组长度为2的n次幂时,hash % table.length = hash & (length - 1),没有直接使用取模运算的原因是位运算的速度远高于取模运算,因为计算机本质就是二进制计算。

小总结:看到这你就应该能理解hash()方法的作用,以及hashmap的数组大小为何需要为2的整数次幂,以及为什么取余不直接使用hash%table.length而使用hash&(length-1)

解决hash冲突的办法

主要有三种方法

①、再哈希法

同时构造多个不同的哈希函数,等发生哈希冲突时就使用第二个、第三个……等其他的哈希函数计算地址,直到不发生冲突为止。虽然不易发生聚集,但是增加了计算时间。

(这里我想到了布隆过滤器的原理,通过多个hash算法对新添加的值做预判断,对于新添加的值多重hash过后落入的节点不为空,说明该值可能在集合中存在,但是存在误判的概率。而如果多重hash判断结果为空,那集合中一定不存在该元素。布隆过滤器最经典的适用场景就是在Redis中解决缓存击穿的问题,但是布隆过滤器的缺点也很明显,占用内存、无法删除、误判。)

②、拉链法

也就是所谓的链地址法,当发生哈希冲突的时候,使用链表将冲突的元素串起来。HashMap 采用的正是拉链法。

③、开放地址法

遇到哈希冲突的时候,就去寻找下一个空的槽。有 3 种方法:

  • 线性探测:从冲突的位置开始,依次往后找,直到找到空槽。
  • 二次探测:从冲突的位置 x 开始,第一次增加1^2个位置,第二次增加 2^2个位置,直到找到空槽。
  • 双重哈希:和再哈希法类似,准备多个哈希函数,发生冲突的时候,使用另外一个哈希函数。

如何判断key相等

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

①、hashCode() :首先,使用key的hashCode()方法计算key的哈希码。由于不同的key可能有相同的哈希码,hashCode()只是第一步筛选。

②、equals() :当两个key的哈希码相同时,HashMap还会调用key的equals()方法进行精确比较。只有当equals()方法返回true时,两个key才被认为是完全相同的。

③、==:当然了,如果两个key的引用指向同一个对象,那么它们的hashCode()和equals()方法都会返回true,所以在 equals 判断之前会优先使用==运算符判断一次。

经典面试题:为什么重写equals()必须重写hashcode()?

自己之前总结的一段话直接copy过来:==是比较两个对象的地址是否相同,equals在不被重写的情况下就是使用==比较,但是在Spring重写的equals方法中,就是先通过==比较是否是一个对象,不是再通过遍历判断两个字符串的内容是否相等。hashcode是标识这个对象的码,相同的对象拥有相同的hashcode,不同的对象hashcode可能相等,也就是hash冲突,而在hash表中,如果我们通过equasl判断两个对象相同,那么必须有相同的hashcode否则就会出现equals认为相同的两个对象落入在两个不同的槽位中。

HashMap扩容

final Node<K,V>[] resize() {
    Node<K,V>[] oldTab = table;
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    int oldThr = threshold;
    int newCap, newThr = 0;
    // 如果 table 不为空,表明已经初始化过了
    if (oldCap > 0) {
        // 当 table 容量超过容量最大值,则不再扩容
        if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return oldTab;
        } 
        // 按旧容量和阈值的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
        /*
         * 初始化时,将 threshold 的值赋值给 newCap,
         * HashMap 使用 threshold 变量暂时保存 initialCapacity 参数的值
         */ 
        newCap = oldThr;
    else {               // zero initial threshold signifies using defaults
        /*
         * 调用无参构造方法时,桶数组容量为默认容量,
         * 阈值为默认容量与默认负载因子乘积
         */
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    
    // newThr 为 0 时,按阈值计算公式进行计算
    if (newThr == 0) {
        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                  (int)ft : Integer.MAX_VALUE);
    }
    threshold = newThr;
    // 创建新的桶数组,桶数组的初始化也是在这里完成的
    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 { // preserve order
                    Node<K,V> loHead = null, loTail = null;
                    Node<K,V> hiHead = null, hiTail = null;
                    Node<K,V> next;
                    // 遍历链表,并将链表节点按原顺序进行分组
                    do {
                        next = e.next;
                        if ((e.hash & oldCap) == 0) {
                            if (loTail == null)
                                loHead = e;
                            else
                                loTail.next = e;
                            loTail = e;
                        }
                        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;
}

注意到它是如何判断单个槽位内节点扩容后是高位还是低位了么?

下面我来说一下为什么可以这么判断。

//newCap = oldCap << 1新数组大小是原数组大小的两倍,
//比如16->32也就是2^4->2^5,原数组是通过hash二进制位的后四位判断桶位,新数组就是通过后五位判断桶位,
//所以对于原数组中的元素,只需要判断hash二进制位倒数第五位是1还是0,
//是1对应的桶位就是原桶位加原数组大小,0就是原桶位。
举例
 0001 0101 (假设hash对应二进制后八位)
&
 0000 1111 (假设数组大小为16 那么(tab-1)二进制)
-------------
 0000 0101 (那么对应桶位为1+0+4)=5

 0001 0101
&
 0001 1111(扩容后数组大小变为32对应的(tab-1)二进制)
--------------
 0001 0101(对应桶位为 16+0+4+0+1)=21=16+5(由此你应该不难看出实际落入哪个桶位取决于倒数第五位)
所以扩容算法中使用e.hash & oldCap 判断倒数第n位是0还是1
 0001 0101
&
 0001 0000
------------
 0001 0000 第五位为1所以槽位为原槽位加16 

为何扩容的负载因子为0.75?

HashMap 会在存储的键值对数量超过阈值(即容量 * 加载因子)时进行扩容。

结论是在空间成本和时间成本做了一个均衡。

假设负载因子较大为0.9,那么数组基本被占满才会触发扩容,我们知道hash元素的分布采用的是除留取余法,而且hash值也细心的处理过,节点落入桶位一般情况比较均匀,也就是说,当数组快被填满时,hash冲突也比较严重,而hash冲突严重就影响了单个槽位内的查找,删除,插入的效率。对时间不友好。

相反,如果负载因子比较小为0.5,那么就可能会频繁的触发扩容,对空间不友好

所以默认为0.75是一个相对平衡的选择。

为何树化的阈值为8?

那就顺便说一下这个问题,其实本质思路是一样的。

红黑树的节点需要保存左右子孩子节点和颜色,大小大概是普通节点的两倍,所以转红黑树就相当于空间换时间。

链表的查询,插入,查找效率都为O(logn),当节点过多,hash冲突严重,效率很低。

其实选择8是统计学给出的答案,理想情况下,使用随机哈希码,链表里的节点符合泊松分布,出现节点个数的概率是递减的,节点个数为 8 的情况,发生概率仅为0.00000006。

红黑树转回链表的阈值为什么是 6,而不是 8?是因为如果这个阈值也设置成 8,假如发生碰撞,节点增减刚好在 8 附近,会发生链表和红黑树的不断转换,导致资源浪费。

HashMap是线程安全的么?

答案当然是否定的,因为在hashmap中没有任何同步手段。HashTable是线程安全的HashMap,但他就是对HashMap的每个方法加了synchronized关键字保证并发场景的线程安全。但是你知道的,这样的同步效率很低,并发度为1。HashTable已经不再被推荐使用,通常使用大哥李的CurrentHashMap做并发场景下的HashMap。

并发场景下HashMap的问题

JDK1.7 中,由于多线程对HashMap进行扩容,调用了HashMap的transfer(){头插法},具体原因:某个线程执行过程中,被挂起,其他线程已经完成数据迁移,等CPU资源释放后被挂起的线程重新执行之前的逻辑,数据已经被改变,造成死循环、数据丢失。

JDK1.8 中,由于多线程对HashMap进行put操作,调用了HashMap的putVal(),具体原因:假设两个线程A、B都在进行put操作,并且hash函数计算出的插入下标是相同的,当线程A执行完第六行代码后由于时间片耗尽导致被挂起,而线程B得到时间片后在该下标处插入了元素,完成了正常的插入,然后线程A获得时间片,由于之前已经进行了hash碰撞的判断,所有此时不会再进行判断,而是直接进行插入,这就导致了线程B插入的数据被线程A覆盖了,从而线程不安全。

1.7死循环

1.7死循环的产生过程。

//jdk 1.7的transfer方法,HashMap的扩容操作
void transfer(Entry[] newTable, boolean rehash) {
    int newCapacity = newTable.length;
    for (Entry<K,V> e : table) {
        while(null != e) {
            Entry<K,V> next = e.next;
            if (rehash) {
                e.hash = null == e.key ? 0 : hash(e.key);
            }
            int i = indexFor(e.hash, newCapacity);
            e.next = newTable[i];
            newTable[i] = e;
            e = next;
        }
    }
}

(详细过程用图文方式不太好理解,这里简单描述下)

假设有两个线程T1和T2,定义好各自的e和next后T2CPU时间片用尽,T2挂起。但T2的e和next指向不会变。

T1执行完该桶位的链表从新分配。因为1.7采用头插法,所以链表会翻转。

当T2重新获取到时间片后继续执行操作,会将a接着头插,就形成了循环链表。

1.8数据覆盖

1.8中修复了这个问题采用尾插法,但是在并发put时也会产生数据覆盖问题,很好理解,就是T1,T2线程都判断完要执行插入时,T1线程CPU时间片耗尽,T2执行完插入,T1重新获取时间片后,就会把T2线程插入的数据覆盖,造成数据丢失。(比较简单,一笔带过)。

其实主要原因是HashMap是一个线程不安全的集合类,我们如果在多线程下使用难免会出现各种问题。对于线程安全的map可以使用Collections.synchronizedMap,本质也是使用synchronized锁来进行互斥。(HashTable就是synchronized来搞,但因为效率太低已经不被推荐使用)。通常我们使用JUC下的ConcurrentHashMap。他核心思想就是分段锁来提高并发度,还有CAS乐观锁加持提升性能....................(他能聊的太多,就不放在该篇文章,后续会出相关文章去聊)

  • 34
    点赞
  • 31
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值