面试被问 HashMap 连环问?从数组到红黑树,从扩容到死锁,你能撑到第几招?

164 阅读11分钟

面试被问 HashMap 连环问?从数组到红黑树,从扩容到死锁,你能撑到第几招?

(八年 Java 开发:从基础到源码,扒透 10 个让面试官点头的细节)

开篇:那个被问懵的三面现场

很久很久很久以前去面试,面试官从 HashMap 基础问到源码,我的回答现在想起来还觉得尴尬:

-   面试官:“HashMap 底层结构是什么?”
  • 我:“数组 + 链表,JDK1.8 加了红黑树。”(心里松了口气,这题会)

  • 面试官:“为什么链表转红黑树的阈值是 8?”

  • 我:“不知道,可能是随便定的吧。”(话音刚落就后悔了,明显感觉面试官皱眉了)

  • 面试官:“扩容时 rehash,JDK1.8 比 1.7 优化了什么?”

  • 我:“…… 好像是避免死循环?”(脑子一片空白,只能含糊其辞)

作为有八年 Java 开发经验的人,走出面试间我就明白了:HashMap 看似基础,实则藏着太多 “只知表面,不懂深层” 的坑。面试中的连环问,其实是在区分 “用过”“懂原理”“能结合业务优化” 三个层级 —— 这直接对应着 “执行层”“设计层”“架构层” 的能力差距。

今天就以这次 “翻车经历” 为起点,从业务场景→底层原理→源码细节→实战优化,复盘我后来吃透 HashMap 的过程,希望能帮你在类似面试中从容应对,让面试官觉得 “这人是真的懂”。

第一问:HashMap 最常用的业务场景有哪些?(先看 “用在哪”)

别小看这个问题,能答出具体场景,说明你不是 “背源码” 而是 “真用过”。八年开发中,我在这些场景高频用到 HashMap:

1. 临时缓存(电商购物车)

用户浏览商品时,用 HashMap 临时存储 “商品 ID→数量”,比如:

// 购物车缓存:key=商品ID,value=购买数量
Map<Long, Integer> cart = new HashMap<>(16); 
cart.put(1001L, 2); // 商品1001买2件
cart.put(1002L, 1); // 商品1002买1件

为什么用 HashMap?  插入、查询、删除都是 O (1),适合高频操作的临时数据(用户离开页面就失效)。

2. 数据映射(用户会话存储)

登录后,用 HashMap 存储用户信息的 “键值对”,比如:

// 用户会话:key=用户ID,value=用户详情
Map<Long, UserDTO> userSessions = new HashMap<>(1024); 
userSessions.put(10086L, new UserDTO(10086L, "张三", "VIP"));

为什么用 HashMap?  能快速通过用户 ID 获取详情,比遍历列表查快 10 倍以上(尤其用户数多的时候)。

3. 统计计数(订单状态统计)

统计不同状态的订单数量,比如:

// 订单状态统计:key=状态码,value=数量
Map<Integer, Integer> orderStatusCount = new HashMap<>(8);
for (Order order : orderList) {
    int status = order.getStatus();
    orderStatusCount.put(status, orderStatusCount.getOrDefault(status, 0) + 1);
}

为什么用 HashMap?  getOrDefault方法能一行搞定计数,比用 switch-case 简洁太多。

第二问:HashMap 底层结构是什么?(基础但要讲透)

大部分人会说 “数组 + 链表 + 红黑树”,但面试官想听的是 “为什么这么设计”。

JDK1.7:数组 + 链表

  • 数组(哈希桶) :默认初始容量 16,每个元素是链表的头节点,通过哈希值定位数组下标。
  • 链表:解决哈希冲突(不同 key 算出同一哈希值),冲突的元素挂在链表上。

JDK1.8:数组 + 链表 + 红黑树

  • 当链表长度超过8,会转成红黑树(查询时间从 O (n) 优化到 O (logn));

  • 当红黑树节点数少于6,转回链表(红黑树维护成本高,少量节点时不如链表高效)。

八年经验补充
在订单系统中,我曾遇到过极端场景 —— 某用户的订单哈希冲突特别多,链表长度达到 12,查询该用户的订单列表耗时从 1ms 涨到 30ms,换成红黑树后又降到 2ms。这就是结构优化的实际价值。

第三问:哈希函数怎么设计的?为什么这么做?(挖细节)

HashMap 的哈希函数是 “先算 key 的 hashCode,再扰动处理”,核心代码:

static final int hash(Object key) {
    int h;
    // key的hashCode高16位和低16位异或(扰动处理)
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); 
}

为什么要 “高 16 位异或低 16 位”?

  • 避免 “哈希值高位不同但低位相同” 导致的冲突(比如两个 key 的 hashCode 是 0x0001FFFF 和 0xFFFF0001,低 16 位相同);

  • 异或后,高位信息被 “混合” 到低位,让哈希值更分散,减少冲突。

举个🌰
key1 的 hashCode 是0b11000000 00000000 00000001 11111111
key2 的 hashCode 是0b00000000 11111111 00000001 11111111
两者低 16 位相同,直接用低位取模会冲突;但高 16 位异或低 16 位后,结果不同,冲突避免。

第四问:链表转红黑树的阈值为什么是 8?(关键考点)

很多人答 “因为源码里写的 8”,但正确回答要结合 “泊松分布”:

HashMap 的注释里明确说:在理想的随机哈希码下,链表长度遵循泊松分布,长度为 8 的概率仅 0.00000006(几乎不可能)。

  • 阈值设为 8,是因为 “正常情况下几乎不会触发转红黑树”,只有在哈希码分布极差(比如故意让所有 key 冲突)时才会转,此时红黑树的 O (logn) 优势才明显;

  • 如果设得太低(比如 5),会导致频繁转树,增加维护成本;设得太高(比如 10),则链表查询太慢。

八年踩坑
曾接过一个第三方接口,返回的商品 ID 哈希码分布极差,导致 HashMap 链表长度暴涨到 15,后来发现是对方生成 ID 时用了递增序列(hashCode 几乎相同),最后通过重写 hashCode 解决。

第五问:初始容量和负载因子有什么用?为什么负载因子是 0.75?

初始容量:数组的初始长度(默认 16)

  • 必须是 2 的幂(16=2⁴,32=2⁵),因为计算数组下标时用 “hash & (容量 - 1)”(等价于取模,但更快);
  • 如果指定初始容量不是 2 的幂,HashMap 会自动调整为最近的 2 的幂(比如指定 17,实际用 32)。

负载因子:扩容的阈值比例(默认 0.75)

  • 当元素数量 > 容量 × 负载因子时,触发扩容(比如 16×0.75=12,存第 13 个元素时扩容到 32)。

为什么负载因子是 0.75?

  • 平衡 “空间” 和 “时间”:

    • 太小(比如 0.5):扩容频繁,空间浪费多,但冲突少,查询快;
    • 太大(比如 1.0):空间利用率高,但冲突多,链表变长,查询慢;
    • 0.75 是统计得出的最优值,在两者间取平衡。

第六问:扩容时发生了什么?JDK1.8 比 1.7 优化了什么?(源码级考点)

扩容是 HashMap 的核心操作,也是面试高频问。

JDK1.7 扩容流程(有坑):

  1. 创建新数组(容量翻倍);

  2. 遍历旧数组,将每个链表的元素重新哈希到新数组(rehash);

  3. 迁移链表时,采用 “头插法”(新元素插在链表头部)。

问题:多线程下,头插法会导致链表成环,触发死循环(扩容时两个线程同时迁移同一条链表,互相修改引用)。

JDK1.8 扩容优化(重点):

  1. 尾插法:迁移链表时保持原有顺序,避免死循环;

  2. 无需重新计算哈希:因为容量翻倍(2ⁿ→2ⁿ⁺¹),新下标要么是 “旧下标”,要么是 “旧下标 + 旧容量”(通过哈希值的第 n 位判断,0 则不变,1 则 + 旧容量);

  3. 红黑树拆分:如果是红黑树,会拆成两个链表(或红黑树),分别放入新数组的两个位置。

代码示例(JDK1.8 扩容核心逻辑)

// 扩容时迁移链表
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
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 { 
            // 链表迁移,分高低位链表
            Node<K,V> loHead = null, loTail = null; // 低位链表(下标不变)
            Node<K,V> hiHead = null, hiTail = null; // 高位链表(下标+旧容量)
            Node<K,V> next;
            do {
                next = e.next;
                // 通过哈希值的第n位判断(旧容量是2ⁿ,第n位是1则高位)
                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;
            }
        }
    }
}

八年经验
在秒杀系统中,我曾把 HashMap 初始容量设为 1024(预计峰值 1000 个商品),负载因子保持 0.75,避免了扩容(扩容会阻塞主线程),让接口响应时间稳定在 50ms 以内。

第七问:HashMap 为什么线程不安全?有什么问题?(区分能力)

这是区分 “知道用” 和 “懂原理” 的关键问题。

JDK1.7 的死循环(扩容时)

多线程同时扩容,头插法导致链表成环,get 元素时陷入无限循环,CPU 飙升到 100%。

JDK1.8 的数据覆盖(put 时)

虽然修复了死循环,但仍有线程安全问题:

  • 线程 A 判断位置为空,准备插入;
  • 线程 B 先插入了元素;
  • 线程 A 继续插入,覆盖线程 B 的数据,导致数据丢失。

业务影响

在支付系统中,曾因多线程操作 HashMap 统计订单金额,导致金额少算(数据覆盖),最后换成 ConcurrentHashMap 才解决。

第八问:怎么解决 HashMap 的线程安全问题?(延伸考点)

  • 用 ConcurrentHashMap:JDK1.8 采用 “CAS+ synchronized” 实现,支持高并发;
  • 用 Collections.synchronizedMap () :对所有方法加锁,性能差(不推荐);
  • 自己加锁:比如用 ReentrantLock 包裹 HashMap 操作(灵活但代码繁琐)。

第九问:HashMap 和 HashTable、TreeMap 的区别?(横向对比)

特性HashMapHashTableTreeMap
线程安全不安全安全(方法加 synchronized)不安全
底层结构数组 + 链表 + 红黑树数组 + 链表红黑树(key 有序)
key 是否可为 null可为 null(仅一个)不可为 null不可为 null
有序性无序无序按 key 自然排序 / 自定义排序

业务选型

  • 一般场景用 HashMap(性能好);
  • 并发场景用 ConcurrentHashMap;
  • 需要 key 有序用 TreeMap(如排行榜按分数排序)。

第十问:实际开发中如何优化 HashMap?(结合业务)

这是面试官最想听到的 “实战经验”,八年开发总结 3 个技巧:

1. 提前预估容量,避免频繁扩容

比如知道要存 1000 个元素,初始容量设为(int)(1000 / 0.75) + 1 = 1334(避免扩容)。

// 优化前:默认容量16,会扩容4次(16→32→64→128→256→512→1024)
Map<String, Object> map = new HashMap<>(); 

// 优化后:初始容量1334,一次都不扩容
Map<String, Object> map = new HashMap<>(1334); 

2. 重写 hashCode 和 equals(自定义对象当 key 时)

  • 确保 “相等的对象必须有相等的 hashCode”;

  • 避免用可变对象当 key(修改后哈希值变了,get 不到值)。

// 反例:用自定义对象当key,没重写hashCode/equals
class User {
    private String name;
    // 没重写hashCode和equals
}

Map<User, String> map = new HashMap<>();
User u1 = new User("张三");
User u2 = new User("张三");
map.put(u1, "1");
map.get(u2); // 结果为null(u1和u2的hashCode不同)

3. 高频删除场景用 LinkedHashMap

HashMap 的 remove 操作要遍历链表,LinkedHashMap 通过双向链表记录顺序,删除更快(O (1) 定位前驱节点)。

面试回答技巧:从 “基础” 到 “深入” 的递进公式

面对连环问,按这个逻辑回答,能体现你的深度:

  1. 基础定义:先答清是什么(比如 “底层结构是数组 + 链表 + 红黑树”);
  2. 设计原因:再讲为什么这么设计(比如 “红黑树是为了优化长链表的查询”);
  3. 源码细节:结合关键代码(比如 “哈希函数用了高 16 位异或低 16 位”);
  4. 业务影响:举项目中的例子(比如 “扩容导致接口延迟,优化容量后解决”)。

总结:HashMap 的本质是 “用空间换时间”

八年开发越久越觉得,HashMap 的设计处处体现 “权衡”:

  • 用数组快速定位(空间换时间);

  • 用链表和红黑树平衡冲突(时间换空间);

  • 负载因子 0.75 平衡扩容频率和冲突率。

面试中,能把这些 “权衡” 讲透,结合业务场景说明白优化思路,比单纯背源码更能打动面试官 —— 毕竟,技术的价值永远是解决业务问题。