每日Java集合面试系列(4):基础篇(HashMap的初始容量、HashMap构造方法有哪些、为什么默认容量是16、如何根据业务场景调整负载因子、负载因子与阈值的关系、HashMap的扰动函数)

每日一句:每天都是一个新的机会,不要错过任何一次机会。不断努力,积极面对生活,你一定能够成功。

系列介绍

Java集合基础(1)
Java集合基础(2)
Java集合基础(3)

今天是Java集合基础的第四篇。

1. 如果初始化 HashMap 时指定容量为 10,实际容量是多少?

详细回答:
实际容量是 16

底层原理:
HashMap 的容量必须是 2 的幂次方(如 16, 32, 64),这是为了优化哈希计算和元素分布(原因详见第10个问题)。当你传入一个非 2 的幂次方的容量(如 10)时,HashMap 不会直接使用它,而是会通过 tableSizeFor(int cap) 这个静态方法将其规范化为大于等于 cap 的第一个 2 的幂次方数。

tableSizeFor 方法的实现非常精妙,它通过一系列无符号右移和位或运算,将 cap 最高位以下的所有位都变为 1,最后再加 1,从而得到 2 的幂次方。

static final int tableSizeFor(int cap) {
    int n = cap - 1; // 防止cap本身就是2的幂,导致结果翻倍
    n |= n >>> 1;
    n |= n >>> 2;
    n |= n >>> 4;
    n |= n >>> 8;
    n |= n >>> 16;
    return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}

计算过程示例(cap=10):

  1. n = 10 - 1 = 9 (二进制 0000 1001)
  2. n |= n >>> 1 -> 0000 1001 | 0000 0100 = 0000 1101
  3. n |= n >>> 2 -> 0000 1101 | 0000 0011 = 0000 1111
  4. … (后续右移16位以内,结果已是 0000 1111,不再变化)
  5. return 15 + 1 = 16

所以,指定容量为 10,实际创建的数组容量是 16。


2. HashMap 的构造方法有哪些?分别适用于什么场景?

HashMap 提供了 4 个公共构造方法:

  1. HashMap()

    • 作用:使用所有默认参数(初始容量 16,负载因子 0.75)创建一个空的 HashMap。
    • 场景最常用。在无法预知数据量大小,或数据量不大时使用。
  2. HashMap(int initialCapacity)

    • 作用:指定初始容量,使用默认负载因子 (0.75) 创建空的 HashMap。
    • 场景:当你能预估要存储的键值对数量(比如大约 1000 个)时使用。通过指定 initialCapacity = (int)(expectedSize / 0.75) + 1,可以避免或减少扩容操作,提升性能。
  3. HashMap(int initialCapacity, float loadFactor)

    • 作用:同时指定初始容量和负载因子。
    • 场景非常少见。仅在对空间利用率和时间效率有非常明确的、特殊的权衡需求时使用。例如,如果你明确知道这个 Map 一旦创建就永不扩容,但会频繁插入,可以设置一个较大的负载因子(如 0.9 或 1.0)来充分利用空间。一般不推荐随意修改负载因子
  4. HashMap(Map<? extends K, ? extends V> m)

    • 作用:构造一个与指定 Map 具有相同映射的新 HashMap。
    • 场景:用于复制一个已有的 Map 结构。新 HashMap 的容量会被设置为足够容纳 m 中的映射(即 m.size() / 0.75 + 1),同样遵循 2 的幂次方规则,并使用默认负载因子。

3. 为什么 HashMap 的默认初始容量是 16?

这是一个经验值,是空间和时间成本的一个折衷

  • 太小(如 4 或 8):会导致非常早地就触发扩容(比如插入第 4、5 个元素时),而扩容是一个相对昂贵的操作,需要重新哈希和复制数据。频繁扩容影响性能。
  • 太大(如 32 或 64):虽然减少了扩容次数,但会浪费内存空间。对于大多数小规模的使用场景(这是最常见的),一个大的空数组会占据不必要的内存。

16 这个值被选为默认值,是因为它在大多数中小规模的场景下,提供了一个良好的起点:既不会立即触发扩容,也不会过度浪费内存。它是一个在大量实践后得出的一个经验上的“甜点”(Sweet Spot)。


4. 负载因子为什么不能设置得太高(如 1.0)或太低(如 0.5)?

负载因子(Load Factor)决定了 HashMap 在空间和时间上的权衡。

  • 设置太高(如 1.0)

    • 优点:空间利用率高,直到数组完全满才会扩容。
    • 缺点极大增加哈希冲突的概率。随着元素增多,哈希碰撞会越来越严重,导致链表变得很长或树化增多,查询和插入性能会急剧下降。这违背了哈希表设计的初衷(O(1) 的平均时间复杂度)。
  • 设置太低(如 0.5)

    • 优点:哈希冲突的概率非常低,链表几乎不会变长,查询速度极快
    • 缺点空间浪费严重。数组有一半的空间是空的就会触发扩容。例如,容量为 16 的 Map,存放第 9 个元素时就要扩容(16 * 0.5 = 8),这会导致更频繁的扩容,插入性能变差

因此,默认的 0.75 是官方通过大量测试得出的一个在时间和空间上最优的平衡值


5. 如何根据业务场景调整负载因子?

调整负载因子是一种非常规的、针对特定场景的优化手段,需要谨慎使用。

  • 调高负载因子(> 0.75,如 0.8, 0.9, 1.0)

    • 场景极度追求空间利用率,且对查询性能不敏感,或者你非常确定哈希函数分布极好,几乎不会产生冲突。
    • 示例:一个生命周期很短、且键的哈希值分布已知非常均匀的缓存 Map。
  • 调低负载因子(< 0.75,如 0.5, 0.6)

    • 场景极致追求查询速度,且内存充足。愿意用空间换时间,确保在任何时候哈希冲突都尽可能少。
    • 示例:一个需要被高频、并发读取的配置信息 Map,且一旦初始化后很少修改。

核心建议:在没有明确的性能监控数据表明当前的负载因子是性能瓶颈时,强烈建议使用默认值 0.75


6. 负载因子和扩容阈值的关系是什么?

扩容阈值(threshold) = 容量(capacity) * 负载因子(loadFactor)

  • threshold 是 HashMap 内部的一个字段,它定义了 HashMap 所能容纳键值对数量的最大值(在下次扩容之前)。
  • 当 HashMap 的 size(当前已存储的键值对数量)超过 threshold 时,就会触发 resize() 扩容操作。
  • 扩容后,capacity 变为原来的 2 倍,threshold 也相应地重新计算为 newCapacity * loadFactor

这是一个简单的自动触发扩容的机制。


7. 开放寻址法和链地址法的区别?

特性链地址法 (HashMap 采用)开放寻址法 (ThreadLocalMap 采用)
核心思想将冲突的元素存储在同一个桶位的链表(或树) 中。在数组中寻找下一个空槽位来存放冲突元素。
数据结构数组 + 链表/红黑树仅一个数组
内存占用需要额外空间存储链表指针或树节点,内存开销更大所有数据都在一个数组里,内存利用率高,更紧凑
查询性能受链表长度影响,最差 O(n),优化后 O(log n)。受冲突连续性的影响,容易产生聚集,最差性能可能更差。
删除操作简单,直接从链表或树中移除节点即可。复杂,不能直接置空,需要特殊标记(如逻辑删除),否则会断掉后续的探测路径。
优点实现简单;对负载因子不敏感;适合存储大数据量。无指针开销,数据序列化效率高;对 CPU 缓存更友好。
缺点指针占用额外内存;节点在内存中不连续,缓存不友好。删除操作麻烦;负载因子不能太高(通常 <= 0.7),否则性能暴跌。

8. 为什么 JDK 1.8 改用红黑树优化链表?

为了解决在极端情况下,链表过长导致的查询性能从 O(1) 退化为 O(n) 的问题。

  • 链表的问题:如果开发者使用了劣质的哈希函数,或者遭到恶意攻击(哈希碰撞攻击),大量的 key 会映射到同一个桶中,形成一个非常长的链表。此时 get(key) 操作需要遍历整个链表,性能极差。
  • 红黑树的优势:红黑树是一种自平衡的二叉查找树,它能将最差情况下的查询、插入、删除的时间复杂度都控制在 O(log n)。虽然 O(log n) 比 O(1) 慢,但远比 O(n) 要好得多。
  • 为什么不直接用红黑树?因为红黑树的节点结构更复杂(需要存储颜色、父节点、左右孩子指针),其维护平衡的操作也比链表操作开销大。对于绝大多数只有少量元素的桶来说,链表的性能更好、空间开销更小。

因此,JDK 1.8 的设计是 “链表为主,树为辅” 的防御性策略:在正常情况下使用高效的链表,只在极端情况下才启用更复杂的数据结构来保住性能底线。


9. HashMap 的扰动函数(hash())的作用是什么?

扰动函数的目的是让 key 的 hashCode 的高位也能参与后续的哈希运算,从而降低哈希碰撞的概率

详细解释:

  1. 问题:计算桶下标的公式是 (n - 1) & hashn(数组长度)通常很小,这意味着只有 hashCode低位参与了运算,而高位完全没有起作用。如果多个 key 的 hashCode 低位相同而高位不同,依然会发生碰撞。
  2. 解决方案:在计算下标前,先对 hashCode 进行扰动。JDK 1.8 的扰动函数非常简单:(h = key.hashCode()) ^ (h >>> 16)
    • 它将 hashCode高 16 位低 16 位进行了一个异或操作。
    • 这样,hashCode 的高位特征就被融合到了低位中。在后续 (n-1) & hash 计算时,实际参与运算的低位值就同时包含了原高低位的信息。
  3. 效果:极大地增加了哈希值的随机性分散性,使得元素分布更加均匀,减少了哈希碰撞。

10. 如果两个 key 的 hashCode 相同,HashMap 如何区分它们?

hashCode 相同只是哈希冲突,不代表是同一个 key。HashMap 通过以下两步来精确区分:

  1. 比较哈希值:首先,即使 hashCode() 相同,经过扰动函数计算得到的 hash 值也相同。它们会被放到同一个桶里。
  2. 比较 key 本身:在同一个桶位的链表或树上,HashMap 会使用 equals() 方法来逐个比较 key 的内容
    • 如果 key1.equals(key2) 返回 true,则认为这是同一个 key,会用新的 value 覆盖旧的 value。
    • 如果 key1.equals(key2) 返回 false,则认为这是两个不同的 key,会将这个新的键值对添加到链表或树中。

结论:HashMap 区分 key 的唯一标准是 equals() 方法。这就是为什么重写 equals() 方法时必须重写 hashCode() 方法的原因:要保证两个 equals() 的 key 一定有相同的 hashCode,否则它们会被放到不同的桶里,永远无法被识别为同一个 key,导致 HashMap 行为异常。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值