每日一句:每天都是一个新的机会,不要错过任何一次机会。不断努力,积极面对生活,你一定能够成功。
系列介绍
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):
n = 10 - 1 = 9(二进制0000 1001)n |= n >>> 1->0000 1001 | 0000 0100 = 0000 1101n |= n >>> 2->0000 1101 | 0000 0011 = 0000 1111- … (后续右移16位以内,结果已是
0000 1111,不再变化) return 15 + 1 = 16
所以,指定容量为 10,实际创建的数组容量是 16。
2. HashMap 的构造方法有哪些?分别适用于什么场景?
HashMap 提供了 4 个公共构造方法:
-
HashMap()- 作用:使用所有默认参数(初始容量 16,负载因子 0.75)创建一个空的 HashMap。
- 场景:最常用。在无法预知数据量大小,或数据量不大时使用。
-
HashMap(int initialCapacity)- 作用:指定初始容量,使用默认负载因子 (0.75) 创建空的 HashMap。
- 场景:当你能预估要存储的键值对数量(比如大约 1000 个)时使用。通过指定
initialCapacity = (int)(expectedSize / 0.75) + 1,可以避免或减少扩容操作,提升性能。
-
HashMap(int initialCapacity, float loadFactor)- 作用:同时指定初始容量和负载因子。
- 场景:非常少见。仅在对空间利用率和时间效率有非常明确的、特殊的权衡需求时使用。例如,如果你明确知道这个 Map 一旦创建就永不扩容,但会频繁插入,可以设置一个较大的负载因子(如 0.9 或 1.0)来充分利用空间。一般不推荐随意修改负载因子。
-
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 的高位也能参与后续的哈希运算,从而降低哈希碰撞的概率。
详细解释:
- 问题:计算桶下标的公式是
(n - 1) & hash。n(数组长度)通常很小,这意味着只有hashCode的低位参与了运算,而高位完全没有起作用。如果多个 key 的hashCode低位相同而高位不同,依然会发生碰撞。 - 解决方案:在计算下标前,先对
hashCode进行扰动。JDK 1.8 的扰动函数非常简单:(h = key.hashCode()) ^ (h >>> 16)。- 它将
hashCode的高 16 位与低 16 位进行了一个异或操作。 - 这样,
hashCode的高位特征就被融合到了低位中。在后续(n-1) & hash计算时,实际参与运算的低位值就同时包含了原高低位的信息。
- 它将
- 效果:极大地增加了哈希值的随机性和分散性,使得元素分布更加均匀,减少了哈希碰撞。
10. 如果两个 key 的 hashCode 相同,HashMap 如何区分它们?
hashCode 相同只是哈希冲突,不代表是同一个 key。HashMap 通过以下两步来精确区分:
- 比较哈希值:首先,即使
hashCode()相同,经过扰动函数计算得到的hash值也相同。它们会被放到同一个桶里。 - 比较 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 行为异常。
8424

被折叠的 条评论
为什么被折叠?



