08、Map源码面试题

1、 Map 数据结构类问题

1、 说一说 HashMap 底层数据结构

HashMap的底层数据结构是数组+链表+红黑树,之所以采用这种结构,是因为数组通过下标查询的速度很快,方便快速查找,时间复杂度是O(1),数组的默认大小是16;当向HashMap中put()元素时,会先根据key计算数组的下标index(计算方式是(n-1) & hash,n是数组的长度,hash是key的hashcode),通过index获取数组的值,如果值不为null就说明hash冲突了,会接着寻找以该数组元素为头节点的链表,链表查询的时间复杂度是O(n),当链表长度等于8并且数组长度大于64时,链表会转换成红黑树,红黑树的时间复杂度是O(log(n)),当红黑树的节点数量等于6时,红黑树又会转换成链表。

2、HashMap、TreeMap、LinkedHashMap的相同点与不同点

相同点:

  1. 三者存放的都是k-v键值对;
  2. 都可以使用迭代器的方式遍历,当使用迭代器遍历时,如果集合被改变,都会快速失败;
  3. 都是线程不安全的,当作为共享变量时,存在线程安全问题;

不同点:

  1. 底层数据结构不同,HashMap的底层数据结构是数组+链表+红黑树;TreeMap底层数据结构是红黑树;LinkedHashMap继承了HashMap,所以底层数据结构也是数组+链表+红黑树;

  2. HashMap中的节点是无序的;TreeMap中的节点是根据key自动排序(可以自定义排序规则);LinkedHashMap中的节点也是有序的,可以按照元素存放顺序和访问最少删除进行访问;

  3. 使用场景不同,HashMap由于使用了数组,查询效率很快,但是里面的节点是无序的;TreeMap的底层数据结构是红黑树,元素在加进来的时候就会进行排序,查询速度也很快,但是新增和删除元素时需要不断的自旋来维护节点的顺序,效率相对较低;LinkedHashMap主要用于需要根据存放顺序进行访问的场景;

3、说一下Map的hash算法

HashMap 的hash 算法源码如下:

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
key 在数组中的位置公式:tab[(n - 1) & hash]

首先计算出 key 的 hashcode,因为 key 是 Object,所以会根据 key 的不同类型进行 hashcode 的计算,接着计算 h ^ (h >>> 16) ,这么做的好处是使大多数场景下,算出来的 hash 值比较分散。

一般来说,hash 值算出来之后,要计算当前 key 在数组中的索引下标位置时,可以采用取模的方式,就是索引下标位置 = hash 值 % 数组大小,这样做的好处,就是可以保证计算出来的索引下标值可以均匀的分布在数组的各个索引位置上,但取模操作对于处理器的计算是比较慢的,数学上有个公式,当 b 是 2 的幂次方时,a % b = (b-1)& a,所以此处索引位置的计算公式我们可以更换为: (n-1) & hash。

int hash = "hello".hashCode();
System.out.println("hash: " + hash);
System.out.println("hash % 16 : " +  (hash % 16));
System.out.println("15 & hash : " + (15 & hash));

/**
* 运行结果:
* hash: 99162322
* hash % 16 : 2
* 15 & hash : 2
*/

此问题可以延伸出三个小问题:

  1. 为什么不用 key % 数组大小,而是需要用 key 的 hash 值 % 数组大小?

    如果 key 是数字,直接用 key % 数组大小是完全没有问题的,但我们的 key 还有可能是字符串,是复杂对象,这时候用 字符串或复杂对象 % 数组大小是不行的,所以需要先计算出 key 的 hash 值。

  2. 计算 hash 值时,为什么需要右移 16 位?

    hash 算法是 h ^ (h >>> 16),为了使计算出的 hash 值更分散,所以选择先将 h 无符号右移 16 位,然后再于 h 异或时,就能达到 h 的高 16 位和低 16 位都能参与计算,减少了碰撞的可能性。

  3. 为什么把取模操作换成了 & 操作?

    key.hashCode() 算出来的 hash 值还不是数组的索引下标,为了随机的计算出索引的下表位置,我们还会用 hash 值和数组大小进行取模,这样子计算出来的索引下标比较均匀分布。取模操作处理器计算比较慢,处理器对 & 操作就比较擅长,换成了 & 操作,是有数学上证明的支撑,为了提高了处理器处理的速度。

  4. 为什么提倡HashMap的数组大小是 2 的幂次方?

    主要原因有两点:

    1. 因为只有大小是 2 的幂次方时,才能使 hash 值 % n(数组大小) == (n-1) & hash 公式成立;

    2. 使数组扩容是成本更低,当数组扩容时,会重新计算原来的HashMap中元素在数组中的下标,进行数据迁移,当数组扩容后的大小等于原来的数组大小的两倍时,链表中只会有一半的元素需要进行迁移,原理参考博文:

4、HashMap是如何解决hash冲突的

对于hash冲突,HashMap的解决方案如下:

  1. 设计好的hash算法,(h = key.hashCode()) ^ (h >>> 16)让hashcode的高16位和低16位都参与运算,使计算出来的hash更加分散,减小碰撞概率;
  2. 数组自动扩容,当存在链表长度大于等于8且数组长度小于64,或HashMap中的元素数量大于阈值threshold时,数组会自动扩容;
  3. 采用链表结构,当hash冲突时,使用链表来处理;
  4. 当hash冲突严重时,将链表转化成红黑树,保证查询性能(只有极端情况下或hash算法有问题时,才会出现红黑树)

2、 HashMap 源码细节类问题

1、HashMap是如何扩容的

扩容的时机:

  1. put 时,发现数组为空,进行初始化扩容,默认扩容大小为 16;
  2. put 成功后,发现HashMap元素总数量大于扩容的阀值时,进行扩容,扩容为老数组大小的 2 倍;
  3. hash冲突,put成功后,发现链表的长度大于等于8,且数组长度小于64,进行扩容,扩容为老数组大小的 2 倍;

扩容的阈值是 threshold,每次扩容时 threshold 都会被重新计算,阈值等于数组的大小 * 影响因子(0.75)。新数组初始化或扩容之后,需要将老数组的值拷贝到新数组上,链表和红黑树都有自己拷贝的方法

2、当链表长度大于等于8时,为什么要先判断数组容量是否小于64,再决定是否将链表转成红黑树

之所以要判断数组容量,主要是因为红黑树占用的空间比链表大很多,转化也比较耗时,所以数组容量小的情况下冲突严重,可以先尝试扩容,看看能否通过扩容来解决冲突的问题。

3、为什么链表长度大于等于 8 时,链表要转化成红黑树

链表查询的平均时间复杂度是O(n)/2,红黑树的时间复杂度是O(logn),当链表长度大于等于8时,链表的查询性能开始明显小于红黑树,为了提高查询性能,所以将链表转化成红黑树;同样的,当红黑树的节点数量小于等于6时,红黑树的查询性能并不大于链表,所以又将红黑树转化成了链表,之所以不使用7,是为了避免链表与红黑树之间的频繁转换(例如,重复的删除或新增同一个元素,如果阈值是7,会导致链表于红黑树之间频繁转换)。

4、为什么不直接使用数组+红黑树,而要使用数组+链表+红黑树

红黑树占用的空间比链表大很多,并且当新增或删除元素时,红黑树会不断的通过自旋来维护节点key的顺序,这个过程时很耗费时间的,事实上,基本不会出现链表转化成红黑树的情况。通过泊松分布公式计算,正常情况下,链表个数出现 8 的概率不到千万分之一(数组容量为8时,概率为1/8^8),所以说正常情况下,链表都不会转化成红黑树,这样设计的目的,是为了防止非正常情况下,比如 hash 算法出了问题时,导致链表个数轻易大于等于 8 时,仍然能够快速遍历。

5、Map 在 put 时,如果数组中已经有了这个 key,不想把 value 覆盖怎么办?取值时,如果得到的 value 是空时,想返回默认值怎么办?

如果数组有了 key,但不想覆盖 value ,可以选择 putIfAbsent 方法;取值时,如果为空,想返回默认值,可以使用 getOrDefault 方法。

这两个方法是在Map接口中定于的,HashMap、TreeMap、LinkedHashMap都有这两个方法。

3、其它 Map 面试题

1、DTO 作为 Map 的 key 时,有无需要注意的点?

看是什么类型的 Map,如果是 HashMap和LinkedHashMap,一定需要覆写 equals 和 hashCode 方法,因为在 get 和 put 的时候,需要通过 equals 方法进行相等的判断;如果是 TreeMap,DTO 需要实现 Comparable 接口,因为 TreeMap 会使用 Comparable 接口进行判断 key 的大小,从而保证红黑树中的元素左小右大。

2、LinkedHashMap 中的 LRU 是什么意思,是如何实现的

LRU ,英文全称:Least recently used,中文叫做最近最少访问,在 LinkedHashMap 中,也叫做最少访问删除策略,我们可以通过 removeEldestEntry 方法设定一定的策略,使最少被访问的元素,在适当的时机被删除,原理是在 put 方法执行的最后,LinkedHashMap 会去检查这种策略,如果满足策略,就删除头节点。保证头节点就是最少访问的元素的原理是:LinkedHashMap 在 get 的时候,都会把当前访问的节点,移动到链表的尾部,慢慢的,就会使头部的节点都是最少被访问的元素。

3、为什么推荐 TreeMap 的元素最好都实现 Comparable 接口?但 key 是 String 的时候,却没有额外的工作呢?

因为 TreeMap 的底层就是通过排序来比较两个 key 的大小的,所以推荐 key 实现 Comparable 接口,是为了往你希望的排序顺序上发展, 而 String 本身已经实现了 Comparable 接口,所以使用 String 时,我们不需要额外的工作,不仅仅是 String ,其他包装类型也都实现了 Comparable 接口,如 Long、Double、Short 等等。

<think>嗯,用户现在问的是关于Map集合的面试题。之前他们问过Spring注解,现在转向Java集合,可能是在准备Java相关的面试,需要复习常见的问题。首先,我需要回忆常见的Map相关的面试题,比如HashMap的工作原理、线程安全问题、ConcurrentHashMap的实现,还有红黑树和链表的结构转换等。 用户可能希望得到全面的问题列表和详细的解答,但需要结构清晰,分点说明。可能他们需要对比不同Map实现类的区别,比如HashMap、Hashtable、ConcurrentHashMap。还要注意源码层面的问题,比如hash计算、扩容机制。 另外,考虑到之前的回答用了Markdown,并且分模块,这次也需要保持类似的格式,让内容易读。可能需要包括基本概念、常见问题、源码解析、使用场景和注意事项等部分。 还要注意用户可能的盲点,比如JDK8之后HashMap引入红黑树的优化,ConcurrentHashMap的分段锁和CAS操作。此外,线程安全的问题和如何解决也是常见考点。 需要确保回答准确,比如HashMap的负载因子默认值,扩容倍数,以及为什么长度是2的幂次。同时,解答要尽量详细,但不过于冗长,适合面试准备时快速回顾。 可能还需要加入一些代码示例,比如如何正确使用HashMap,或者ConcurrentHashMap的线程安全示例。另外,强调实践中的最佳做法,比如初始化容量,选择合适的Map实现类。 最后,检查是否有遗漏的重要问题,比如哈希冲突解决、遍历方式、fail-fast机制等。确保覆盖所有常见考点,帮助用户全面准备。</think>以下是关于 Java `Map` 集合的常见面试题及详解,内容覆盖底层原理、高频考点和实际应用场景,帮助系统性准备面试: --- ### **一、基础概念** 1. **Map 的核心特点** - **键值对存储**:通过 `Key` 快速查找 `Value`,`Key` 不可重复,`Value` 可重复。 - **常用实现类**: - `HashMap`(无序,线程不安全) - `LinkedHashMap`(有序,按插入或访问顺序) - `TreeMap`(有序,按 `Key` 自然排序或自定义比较器) - `Hashtable`(线程安全,已逐渐被 `ConcurrentHashMap` 替代) - `ConcurrentHashMap`(高并发场景下的线程安全实现) 2. **HashMap 与 Hashtable 的区别** | **特性** | **HashMap** | **Hashtable** | |----------------|-------------------|-------------------| | **线程安全** | 不安全 | 安全(方法用 `synchronized` 修饰) | | **Null 支持** | 允许 `Key` 和 `Value` 为 `null` | `Key` 和 `Value` 均不可为 `null` | | **性能** | 更高(无锁竞争) | 较低(锁粒度大) | | **初始容量** | 16 | 11 | --- ### **二、高频考点(HashMap 底层原理)** #### **1. 数据结构与哈希冲突解决** - **JDK 1.7 及之前**:数组 + 链表(哈希冲突时,新元素插入链表头部)。 - **JDK 1.8 及之后**:数组 + 链表/红黑树(链表长度 ≥ 8 且数组长度 ≥ 64 时,链表转为红黑树)。 ```java // JDK 1.8 的节点定义(链表或红黑树) static class Node<K,V> implements Map.Entry<K,V> { final int hash; final K key; V value; Node<K,V> next; // 链表结构 } ``` #### **2. 哈希函数设计** - **核心逻辑**:`Key` 的 `hashCode()` 经过扰动计算(减少哈希冲突)。 ```java // JDK 1.8 的扰动函数 static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); } ``` - **数组长度为什么是 2 的幂次?** 方便通过 `(n - 1) & hash` 快速定位索引,等同于取模运算(`hash % n`),但位运算效率更高。 #### **3. 扩容机制(Resize)** - **触发条件**:当元素数量超过 `容量 × 负载因子`(默认负载因子 `0.75`)。 - **扩容操作**: 1. 创建新数组(大小为原数组的 2 倍)。 2. 重新计算每个元素的哈希位置(利用高位判断,避免全量重新哈希)。 - **链表拆分优化(JDK 1.8)**: 根据 `hash & oldCap` 是否为 0,将链表拆分为低位链表(原索引)和高位链表(原索引 + 原容量)。 --- ### **三、线程安全问题** #### **1. HashMap 为什么线程不安全?** - **场景 1:多线程扩容导致死循环(JDK 1.7)** 链表头插法在并发扩容时可能形成环形链表,导致 `get()` 死循环。 - **场景 2:数据覆盖(JDK 1.8)** 多线程同时执行 `put()` 时,可能覆盖已写入的键值对。 #### **2. ConcurrentHashMap 如何保证线程安全?** - **JDK 1.7**:分段锁(Segment),每个段独立加锁,降低锁粒度。 - **JDK 1.8**: - 取消分段锁,采用 `Node` 数组 + `CAS`(无锁化操作) + `synchronized`(仅锁住链表头或红黑树根节点)。 - **关键代码片段**: ```java final V putVal(K key, V value, boolean onlyIfAbsent) { // 使用 CAS 尝试无锁插入 if ((f = tabAt(tab, i = (n - 1) & hash)) == null) { if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value))) break; } // 若存在哈希冲突,则对头节点加 synchronized 锁 else { synchronized (f) { // 插入或更新节点 } } } ``` --- ### **四、进阶问题** #### **1. LinkedHashMap 如何实现有序性?** - 继承 `HashMap`,通过双向链表维护插入顺序或访问顺序(LRU 缓存的基础)。 - **访问顺序模式**:重写 `removeEldestEntry()` 可实现固定大小的 LRU 缓存。 #### **2. TreeMap 的排序规则** - 基于红黑树实现,支持: - **自然排序**:`Key` 实现 `Comparable` 接口。 - **定制排序**:通过 `Comparator` 传入比较器。 #### **3. 如何设计一个线程安全的 Map?** - **方案 1**:使用 `Collections.synchronizedMap(new HashMap<>())`(锁住整个 Map)。 - **方案 2**:使用 `ConcurrentHashMap`(推荐,锁粒度更小)。 - **方案 3**:自定义锁分段(参考 JDK 1.7 的 `ConcurrentHashMap` 实现)。 --- ### **五、实战技巧** 1. **初始化容量优化** - 预估数据量,避免频繁扩容(如数据量 1000,负载因子 0.75,则初始容量设为 `(1000 / 0.75) + 1 ≈ 1334`)。 2. **Key 的选择** - 使用不可变对象(如 `String`、`Integer`),避免修改 `Key` 导致哈希值变化。 3. **遍历方式** - 使用 `entrySet()` 遍历(直接获取键值对,效率高于 `keySet()` + `get()`)。 --- ### **六、常见面试题示例** 1. **HashMap 的负载因子为什么默认是 0.75?** - 权衡时间与空间:负载因子过小(如 0.5)会导致频繁扩容,过大(如 1.0)会增加哈希冲突概率。 2. **ConcurrentHashMap 的 size() 方法如何实现?** - JDK 1.8 中基于 `baseCount` 和 `CounterCell[]` 的分段统计,减少并发竞争。 3. **如何让 HashMap 支持插入顺序遍历?** - 改用 `LinkedHashMap`,并在构造方法中设置 `accessOrder=false`(默认按插入顺序)。 --- 通过理解这些核心问题,可以系统性地掌握 `Map` 的设计思想和应用场景,应对面试中的深度追问。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值