【疑惑整理】【Java】深入浅出HashMap+面试整理

一、数据结构

1、内部存储:数组+链表/红黑树

  • 1)数组:查询 O ( 1 ) O(1) O(1)
  • 2)链表:查找 O ( n ) O(n) O(n),插入 O ( 1 ) O(1) O(1)

1

二、实现逻辑

1、基础知识

1.1、Hash映射

根据键的Hash值映射到内存地址,决定存储位置。这种索引方式使得获取速度很快。

1.2、碰撞处理

当两个键值对的hash(key)相同,就被称为哈希冲突。

键值对:(x,“aa”)、 (y,“bb”)
哈希函数:hash(x) = hash(y) ,两个对象存储地址冲突。

解决冲突方式:开放定址法、再哈希函数法和链地址法

  • 1、开放定址法:如果哈希表未被装满,把 key 放到冲突位置后面空位。
    缺点:查找、扩容等麻烦,
  • 2、再哈希法:冲突时再计算另一个哈希函数地址,直到冲突不再发生,这种方法不易产生“聚集”,但却增加了计算时间。
    场景:若不考虑添加元素的时间,且对查询元素的要求极高,可以考虑。
  • 3、链地址法:HashMap采用了数组(哈希表)+ 链表的数据结构,当发生哈希冲突时,就用一个链表结构存储相同 Hash 值的数据。

2、重要属性

1、数组存储单元:Node

// 关键属性:key,value,next
static class Node<K,V> implements Map.Entry<K,V> {
    
    
        final int hash;
        final K key;
        V value;
        Node<K,V> next;

        Node(int hash, K key, V value, Node<K,V> next) {
    
    
            this.hash = hash;
            this.key = key;
            this.value = value;
            this.next = next;
        }
}

2、关键初始化参数。

int threshold;  // 边界值。不设置参数,默认12--容量默认16
               // 1. 扩容阈值=容量*负载因子。threshold=capacity*loadFactor。
               // 2. Node数量>threshold,会调用resize()
	final float loadFactor;  // 加载因子,默认0.75

2
1、初始化完成,HashMap 就可使用 put() 方法添加键值对。
2、添加一个 key-value 对,
【1】return code1 = key.hashCode()
【2】hash(code1) 计算出 hash 值
【3】putVal 方法中 (n - 1) & hash 决定 Node 存储位置。

// 源码分析键值对添加
public V put(K key, V value) {
    
    
	return putVal(hash(key), key, value, false, true);
}


static final int hash(Object key) {
    
    
	int h;
	return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);  //h>>>16:无符号右移16位
}


if ((tab = table) == null || (n = tab.length) == 0)
	n = (tab = resize()).length;
	//通过putVal方法中的(n - 1) & hash决定该Node的存储位置
	if ((p = tab[i = (n - 1) & hash]) == null)
		tab[i] = newNode(hash, key, value, null);
// 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)
//1、判断当table为null或者tab的长度为0时,即table尚未初始化,此时通过resize()方法得到初始化的table
            n = (tab = resize()).length;
        if ((p = tab[i = (n - 1) & hash]) == null)
//1.1、此处通过(n - 1) & hash 计算出的值作为tab的下标i,并另p表示tab[i],也就是该链表第一个节点的位置。并判断p是否为null
            tab[i] = newNode(hash, key, value, null);
//1.1.1、当p为null时,表明tab[i]上没有任何元素,那么接下来就new第一个Node节点,调用newNode方法返回新节点赋值给tab[i]
        else {
    
    
//2.1下面进入p不为null的情况,有三种情况:p为链表节点;p为红黑树节点;p是链表节点但长度为临界长度TREEIFY_THRESHOLD,再插入任何元素就要变成红黑树了。
            Node<K,V> e; K k;
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
//2.1.1HashMap中判断key相同的条件是key的hash相同,并且符合equals方法。这里判断了p.key是否和插入的key相等,如果相等,则将p的引用赋给e

                e = p;
            else if (p instanceof TreeNode)
//2.1.2现在开始了第一种情况,p是红黑树节点,那么肯定插入后仍然是红黑树节点,所以我们直接强制转型p后调用TreeNode.putTreeVal方法,返回的引用赋给e
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            else {
    
    
//2.1.3接下里就是p为链表节点的情形,也就是上述说的另外两类情况:插入后还是链表/插入后转红黑树。另外,上行转型代码也说明了TreeNode是Node的一个子类
                for (int binCount = 0; ; ++binCount) {
    
    
//我们需要一个计数器来计算当前链表的元素个数,并遍历链表,binCount就是这个计数器

                    if ((e = p.next) == null) {
    
    
                        p.next = newNode(hash, key, value, null);
                        if (binCount >= TREEIFY_THRESHOLD - 1) 
// 插入成功后,要判断是否需要转换为红黑树,因为插入后链表长度加1,而binCount并不包含新节点,所以判断时要将临界阈值减1
                            treeifyBin(tab, hash);
//当新长度满足转换条件时,调用treeifyBin方法,将该链表转换为红黑树
                        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;
    }

3、HashMap 获取元素优化

HashMap发生大量冲突,就会有很长Node链表,会明显降低,红黑树很好解决了这个问题,将时间复杂度从O(n) 降到 O(log(n))。编码中可以通过重写Key值的hashCode()来降低哈希冲突,进而减少链表提高性能。

4、HashMap 扩容优化

在 JDK1.7 中,HashMap 整个扩容过程就是分别取出数组元素,一般该元素是最后一个放入链表中的元素,然后遍历以该元素为头的单向链表元素,依据每个被遍历元素的 hash 值计算其在新数组中的下标,然后进行交换。这样的扩容方式会将原来哈希冲突的单向链表尾部变成扩容后单向链表的头部。

而在 JDK 1.8 中,HashMap 对扩容操作做了优化。由于扩容数组的长度是 2 倍关系,所以对于假设初始 tableSize = 4 要扩容到 8 来说就是 0100 到 1000 的变化(左移一位就是 2 倍),在扩容中只用判断原来的 hash 值和左移动的一位(newtable 的值)按位与操作是 0 或 1 就行,0 的话索引不变,1 的话索引变成原索引加上扩容前数组。

之所以能通过这种“与运算“来重新分配索引,是因为 hash 值本来就是随机的,而 hash 按位与上 newTable 得到的 0(扩容前的索引位置)和 1(扩容前索引位置加上扩容前数组长度的数值索引处)就是随机的,所以扩容的过程就能把之前哈希冲突的元素再随机分布到不同的索引中去。

我们还可以在预知存储数据量的情况下,提前设置初始容量(初始容量 = 预知数据量 / 加载因子)。这样做的好处是可以减少 resize() 操作,提高 HashMap 的效率。

三、面试小结

3.1、问题列表

1、为什么是 0.75 这个值呢?

2、什么办法来解决因链表过长导致查询时间复杂度高的问题呢?

3、影响 HashMap 性能的因素?

4、HashMap的哈希函数怎么设计的?
 - 追问1:初始容量为什么设置为 2 的整数次幂?【(n - 1) & hash 】
 - 追问2:如果没使用 hash() 方法计算 hashCode,直接使用对象的 hashCode 值,会出现什么问题呢?
 - 追问3:为什么获取下标时用按位与 &,而不是取模 %5、JDK1.8后对HashMap的改进(*3- 追问1:为什么要做这几点优化?
 - 追问21.8 中的 HashMap 是否线程安全?
 - 追问3:什么时机执行 resize()- 追问4resize() 如何实现的?

6、HashMap get和put源码,

7、HashMap 的 key 需要满足什么条件?
 - 追问1:HashMap 允许 key/value 为 null, 但最多只有一个。 为什么?
 - 追问2:如果重写了equals(),不重写hashCode()会发生什么?

8、你平常怎么解决这个线程不安全的问题?

9、那你知道ConcurrentHashMap的分段锁的实现原理吗?

3.2、具体回答

1、为什么是 0.75 这个值呢?

链表法查找时间是 O ( 1 + n ) O(1+n) O(1+n) [n - 链表长度],0.75是对时间和空间的平衡。
1)加载因子越大,空间利用就越充分,但链表长度越长,查找效率也就越低。
2)加载因子太小,数据过于稀疏,空间严重浪费,当然,时间查找会更高。

追问1、加载因子什么时候适合减少,什么时候适合增加?【优化】

查询操作频繁可适当地减少加载因子;内存利用率要求高可适当增加加载因子。

  • 补充:若能预知存储数据量,设置初始容量 = 预知数据量 / 加载因子。可减少resize()操作,提高效率。

2、什么办法来解决因链表过长导致查询时间复杂度高的问题呢?

引入红黑树数据查询效率,当链表长度超过threshold(默认8)会将链表转换为红黑树,值得注意的是新增由于存在左旋、右旋效率会降低。

追问1:为什么红黑而非平衡树?

不需要因为新增节点频繁调整二叉树。……

追问2:红黑树具体结构及实现,红黑树与查找树的区别体现

……

3、影响 HashMap 性能的因素?

  • 1)负载因子与边界值。前者涉及时间与空间的平衡,后者可能会涉及resize()影响性能。
  • 2)哈希值。理想情况是均匀散列。一般 HashMap 使用 String 类型作为 key,而 String 类重写了 hashCode 函数。

4、HashMap的哈希函数怎么设计的?

先拿到 key 的hashCode(32位int值),然后让高16位和低16位进行异或操作,然后 (n-1) & hash找到桶位置。

// 补充:源码分析键值对添加
public V put(K key, V value) {
    
    
	return putVal(hash(key), key, value, false, true);
}


static final int hash(Object key) {
    
    
	int h;
	// 【1】return code1 = key.hashCode() 
	// 【2】hash(code1) 计算出 hash 值
	return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);  //h>>>16:无符号右移16位
}


if ((tab = table) == null || (n = tab.length) == 0)
	n = (tab = resize()).length;
	// 【3】putVal 方法中 (n - 1) & hash 决定 Node 存储位置。
	if ((p = tab[i = (n - 1) & hash]) == null)
		tab[i] = newNode(hash, key, value, null);
追问1:初始容量为什么设置为 2 的整数次幂?【(n - 1) & hash 】

为了服务hash到桶位置的算法 — (n - 1) & hash2的幂次方减1后每一位都是1,让数组每一个位置都能添加到元素。如果不是,那计算结果总有一位总0,对应下标位置总是空的

如果初始化设置不是2的幂次方,HashMap也会调整到比初始化值大 & 最近的2的幂作为capacity。

追问2:如果没使用 hash() 方法计算 hashCode,直接使用对象的 hashCode 值,会出现什么问题呢?

碰撞很严重,不是好的哈希算法。但若将 hashCode 二进制值对半切开,并且使用位异或运算,就能避免大量碰撞。简而言之,尽量打乱 hashCode 真正参与运算的低 16 位。

补充:没采用hash()的情况,直接用hashCode计算

假设添加两个对象 a 和 b,数组长度是16,公式(n - 1) & hash。
-> (16-1)&a.hashCode 和 (16-1)&b.hashCode,
   0000000000000000000000000001111 [15的二进制]
   1000010001110001000001111000000 [假设A]
   0111011100111000101000010100000 [假设B]
   = 0。
哈希结果太让人失望了。
追问3:为什么获取下标时用按位与 &,而不是取模 %?

& 效率更高。

如果 l e n g t h = 2 n length = 2^n length=2n
那么 x x x % l e n g t h = length = length= x x x & ( l e n g t h − 1 ) (length-1) (length1)
即:当长度为 2 n 2^n 2n 时,模运算% 可变换为按位与 & 运算。

5、JDK1.8后对HashMap的改进(*3)

  • 1)数据结构。从 数组+链表 改成 数组+链表或红黑树
  • 2)链表插入。从 头插法 改成 尾插法
  • 3)扩容优化
  • [1]-索引定位:1.7需要对原数组中的元素进行重新hash定位在新数组的位置,1.8采用更简单的判断逻辑,位置不变或索引+旧容量大小
  • [2]-判断顺序1.7先判断是否需要扩容,再插入;1.8先插入,再判断是否需要扩容。
追问1:为什么要做这几点优化?
  • 1)数据结构。降低链表访问时间复杂度,从 O ( n ) O(n) O(n)降到 O ( l o g n ) O(logn) O(logn)

  • 2)链表插入。头插改变原本元素顺序,并发场景会导致链表成环,而尾插不会。
    7

  • 3)扩容优化

[1]索引定位:新扩容算法效率更高。高1位为0,索引没变;是1,索引变成“原位置+旧容量”。

8
[2]判断顺序:JDK1.7 中先进行扩容后进行插入,而在 JDK1.8 中是先进行插入后进行扩容。

  • JDK1.7 中:先扩容后插入
    当你发现你插入的桶是不是为空,如果不为空说明存在值就发生了hash冲突,那么就必须得扩容,但是如果不发生Hash冲突的话,说明当前桶是空的(后面并没有挂有链表),那就等到下一次发生Hash冲突的时候在进行扩容,但是当如果以后都没有发生hash冲突产生,那么就不会进行扩容了,减少了一次无用扩容,也减少了内存的使用

  • JDK1.8 中:先插入后扩容
    主要是因为对链表转为红黑树进行的优化,因为你插入这个节点的时候有可能是普通链表节点,也有可能是红黑树节点
    如果是链表节点,是否达到了 链表转化为红黑树的阈值是8,如果没有那么就还可以继续插入。
    如果是红黑树节点,需要看插入红黑树节点是否还能满足当前是红黑树的特性,如果还能继续满足即还没有达到扩容的临界条件。

追问2:1.8 中的 HashMap 是否线程安全?

1.8虽然解决了链表成环,但还有其他并发问题,比如:上秒 put 的值,下秒 get 的时候却不是刚 put 的值;因为操作都没有加锁,不是线程安全的。

追问3:什么时机执行 resize()?

答:桶里Node 数量超过边界值,HashMap 会调用 resize() 方法重新分配 table 数组。

补充:
1、扩容2倍。
2、边界值[threshold] = 桶数量*负载因子[loadFactor]。不设置参数情况下,默认值为123resize()会导致HashMap数组复制,迁移到另一块内存,从而影响 HashMap 效率。
追问4:resize() 如何实现的?

实现整体逻辑:若Node数量>threshold,就会扩容;扩容后判断高位是否为1,是1,则newPos=oldPos+oldCapacity;否则不变。

源码:……

6、HashMap get和put源码,

……

7、HashMap 的 key 需要满足什么条件?

答:必须重写 hashCode 和 equals 方法, 常用的 String 类实现了这两个方法。

追问1:HashMap 允许 key/value 为 null, 但最多只有一个。 为什么?

答: 如果 key 为 null 会放在第一个桶(即下标 0)位置, 而且是在链表最前面(即第一个位置)。

追问2:如果重写了equals(),不重写hashCode()会发生什么?

默认的equals()hashCode()比较的是内存地址,不重写会认为hashCode()不一样,就认为是不同对象,不需要再进行equals()比较,效率和正确性都会有问题。

HashMap()比较顺序:
1hashCode()比较结果不相同,则说明是不同对象;
2hashCode()比较结果相同,则需要再进行equals()比较,相等则说明相同,否则不同。

8、你平常怎么解决这个线程不安全的问题?

HashTableCollections.synchronizedMapConcurrentHashMap都是实现线程安全的Map。

  • 1)HashTable:直接在方法上加synchronized来锁住整个数组,粒度比较大。
  • 2)Collections.synchronizedMap:Collections集合工具的内部类,通过传入Map封装出一个SynchronizedMap对象,内部定义了一个对象锁,方法内通过对象锁实现。
  • 3)ConcurrentHashMap:使用分段锁,降低锁粒度,让并发度大大提高。

9、那你知道ConcurrentHashMap的分段锁的实现原理吗?

ConcurrentHashMap成员变量使用volatile修饰,免除指令重排序,同时保证内存可见性,另外使用CAS操作和synchronized结合实现赋值操作,多线程操作只会锁住当前操作索引的节点。

如下图,线程A锁住A节点所在链表,线程B锁住B节点所在链表,互不干涉。
9

四、参考

1、07 | 深入浅出HashMap的设计与优化
2、一个 HashMap 能跟面试官扯上半个小时
3、到底什么是 HashMap?
4、jdk 源码系列之 HashMap
5、HashMap 源码分析(JDK1.8)
6、由HashMap哈希算法引出的求余%和与运算&转换问题
7、【1】JDK8 HashMap扩容优化
8、HashMap 链表插入方式 → 头插为何改成尾插 ?
9、HashMap1.7 vs 1.8

猜你喜欢

转载自blog.csdn.net/HeavenDan/article/details/112789200
今日推荐