八股会问你HashMap 的底层结构是什么扩容因子是多少反八股问三个问题为什么这么设计代价是什么什么时候会翻车一、八股怎么教 HashMap1. 数组 链表JDK 8 后链表长度 ≥ 8 转红黑树 2. 默认容量 16负载因子 0.75扩容阈值 12 3. 扩容时容量翻倍重新 hash 4. 线程不安全用 ConcurrentHashMap这是正确的但八股没有回答的问题是为什么负载因子是 0.75 而不是 0.5 或 0.8链表转红黑树的阈值为什么是 8HashMap 占堆 30% 就该上 Redis这个说法对吗初始容量设不设到底有多大影响二、为什么负载因子是 0.750.75 是时间和空间的折中点不是随便选的。负载因子越小如 0.5 → 数组更早扩容链表更短查询更快 → 但浪费大量内存一半的桶是空的 负载因子越大如 1.0 → 内存利用率高桶基本塞满才扩容 → 但链表很长查询退化到 O(n) 0.75 的数学依据 泊松分布下负载因子 0.75 时 桶中元素个数超过 8 的概率 0.00000006 → 红黑树几乎不会被触发极端碰撞场景除外代价0.75 意味着永远有约 25% 的内存浪费。如果你的场景是内存极度紧张但查询不频繁可以调到 0.9。但大多数时候不要动它。边界负载因子调到多少都没法解决 hash 碰撞攻击的问题——如果攻击者故意构造 hashCode 相同的 key所有元素挤在一个桶里查询直接退化到 O(n)。这是 DoS 攻击的经典手法。三、为什么链表转红黑树的阈值是 8这个数字背后是统计学推导不是经验值。理想 hash 函数下元素落入每个桶的数量服从泊松分布 桶中元素数量 概率 0 0.6065 1 0.3033 2 0.0758 3 0.0126 4 0.0016 5 0.0002 6 0.00002 7 0.000002 8 0.00000006 ← 阈值在这里 超过 8 个元素落入同一个桶的概率是千万分之一。 如果真出现了说明 hash 函数有问题或者遭到了碰撞攻击。 此时转红黑树 O(log n) 兜底防止性能雪崩。代价红黑树节点TreeNode占用的内存是普通节点Node的 2 倍多了 parent/left/right/prev/boolean 六个字段。如果正常场景下也转树内存会暴涨。边界退化阈值是 6不是 8中间留了 2 的缓冲区避免频繁在链表和红黑树之间切换。如果 hashCode 实现不好导致频繁树化应该修 hashCode而不是调阈值。四、HashMap 占堆多少该上 Redis网上常说占堆 30% 就该换 Redis。这个说法缺乏数据支撑。我们跑了 20 组实测数据来验证。实验设计变量 JVM 堆大小512m / 1g HashMap 占堆比例10% / 20% / 30% / 40% / 50% 操作模式纯读 / 混合67% 读 33% 写 关键修正启动后台线程持续分配临时对象模拟生产 GC 压力 环境Dragonwell 17 / G1GC / 每组 300 万次查询结果p99 延迟微秒512m 堆占比readmixed10%1.52.120%2.51.930%1.82.940%3.32.150%2.22.4Redis 同机房 p99 ≈ 1000 微秒。HashMap 占堆 50% 时 p99 只有 2~4 微秒差了 250 倍。30% 就该换为什么不对GC 停顿确实存在1~2ms/次但只影响正在执行的那一个操作。 300 万次操作中大约 100 次 GC 0.003% 受影响。 要影响到 p991% 阈值需要 30000 次 GC这不现实。什么时候真的该上 Redis判断标准不是延迟是数据要不要跨进程共享 单进程用 → HashMap占堆 50% 也没问题 多进程共享 → Redis别犹豫 数据比内存大 → Redis / 数据库 延迟不是换的理由——G1GC 下 HashMap 的 p99 比 Redis 快 250 倍换过去反而更慢。五、初始容量设不设有区别吗实测数据100 万条数据负载因子 0.75不设初始容量default 16 put 100 万条 → 扩容 17 次16→32→64→...→1048576 总耗时~120ms 每次 resize 要重新计算所有 key 的位置 复制数据 设初始容量为 13333341000000 / 0.75 1 put 100 万条 → 0 次扩容 总耗时~80ms 提速 33% 内存差异几乎一样最终都是同样的数组大小代价初始容量设太大同样有问题。100 条数据设new HashMap(1000000)一开始就占 4MB 数组空间。更常见的错误是不评估实际业务数据量直接套公式算出一个巨大的数字——比如业务实际只会有 500 条数据却按 20 万算出 262,144 的初始容量白白浪费内存。边界数据量小于 1000 时差别不大resize 只有一两次耗时 1ms。数据量超过 10 万时不设初始容量会明显感觉到 resize 的开销。实战建议公式不是万能药关键是先搞清楚你的业务到底有多少数据// 第一步评估业务数据量// - 这个 Map 的数据来源是什么数据库查出来的接口返回的配置文件// - 数据量上限是多少是 100 条还是 10 万条// - 数据量是固定的还是随时间增长的// 第二步确认数据量后按公式计算intexpectedSize500;// ← 这个数字来自业务评估不是拍脑袋MapString,StringmapnewHashMap((int)(expectedSize/0.75)1);// 常见误区// ❌ 不管业务多大一律 new HashMap(262144) — 浪费内存// ❌ 从配置文件读 10 条数据初始容量给 100000 — 浪费内存// ✅ 确认数据源最多返回 500 条初始容量给 668 — 合理// ✅ 不确定数据量用默认 16 — 也能用就是多几次 resize// 或者用 Guava更直观MapString,StringmapMaps.newHashMapWithExpectedSize(500);六、容量为什么是 2 的幂位运算与扰动函数位运算替代取模HashMap 计算元素桶下标时需要把 hash 值映射到数组索引index hash % length。取模是除法运算CPU 需要约 20~40 个时钟周期。而hash (length - 1)是一次位与运算只需 1 个周期——快 20~40 倍。但 (length - 1)等价于% length仅当 length 是 2 的幂length 16二进制 10000 length - 1 15二进制 01111 hash 15 → 结果范围 0~15等价于 hash % 16如果 length 不是 2 的幂呢length 15二进制 1111 length - 1 14二进制 1110 hash 14 → 结果只能是 0,2,4,6,8,10,12,148 个偶数 奇数位置永远为空 → 一半桶浪费 → 碰撞率翻倍代价2 的幂意味着容量只能是 2, 4, 8, 16, 32… 不能精细控制。new HashMap(100)实际容量被向上取到 128最近的 2 的幂浪费 28%。扩容翻倍16→32→64→…保持 2 的幂不变位运算始终有效——这就是扩容翻倍而不是加固定值的原因。为什么默认是 16 而不是 8 或 32容量太小如 2 或 4 length - 1 1 或 3二进制 01 或 011 只有 1~2 位参与路由 → hash 值高位全被忽略 → 碰撞极高 容量太大如 1024 默认只有 12 个元素16 × 0.75就占 8KB 数组 → 浪费内存 16 是折中4 位参与路由足够分散空数组占 128 字节可忽略扰动函数hash ^ (hash 16)即使容量是 2 的幂容量小时参与路由的位数太少容量 16 时length - 1 0b0000...01111 hash 0b0000...01111 → 只有低 4 位起作用高 28 位被掩盖 假设两个 key key1.hashCode() 0b10101010_10101010_10101010_1101 key2.hashCode() 0b01010101_01010101_01010101_1101 低 4 位完全相同 → 路由到同一个桶 → 碰撞 但它们的高位差异很大JDK 的解决方案——扰动函数// HashMap.hash() 源码JDK 8staticfinalinthash(Objectkey){inth;return(keynull)?0:(hkey.hashCode())^(h16);}原理把 hash 的高 16 位异或到低 16 位让高位特征参与路由 原始 hash: 10101010 10101010 10101010 11010000 右移 16 位: 00000000 00000000 10101010 10101010 异或结果: 10101010 10101010 00000000 01111010 ↑ 高位特征混入了低位 再 (length-1) 时高 16 位和低 16 位的特征都在了代价一次右移 一次异或几乎零成本。这不是完美的散列只混合了一次但用最低的代价显著降低了碰撞概率。边界容量超过 2^16 65536 时扰动函数的效果递减——低位本身已经足够参与路由。但 HashMap 很少超过这个大小超过 65536 个元素通常该考虑数据库或 Redis 了。七、线程不安全到底会怎样八股说HashMap 线程不安全但很少有人解释具体会出什么事。并发 put 的三个问题 1. 数据丢失 线程 A 和线程 B 同时 put 到同一个桶 A 写入 node1 → B 写入 node2 覆盖了 node1 → node1 的数据丢了 2. resize 死循环JDK 7 并发扩容时头插法导致链表成环 后续 get 遍历链表进入死循环 → CPU 100% JDK 8 改成尾插法解决了这个问题 3. size 不准确 size 不是原子操作两个线程同时 1 实际只加了 1JDK 8 没有死循环了但数据丢失和 size 不准确仍然存在。解决方案的取舍方案性能安全性适用场景Collections.synchronizedMap差全表锁安全低并发ConcurrentHashMap好分段锁/CAS安全高并发外部加锁看锁粒度安全已有锁体系ConcurrentHashMap 的代价每个 put/get 多了 CAS 操作单线程性能比 HashMap 低约 10-15%。只有在真正并发写入时才值得用。八、总结HashMap 的设计哲学HashMap 的每个参数背后都有推导依据负载因子 0.75 → 时间和空间的统计最优解 树化阈值 8 → 泊松分布下千万分之一概率的兜底 初始容量 16 → 2 的幂4 位参与路由折中分散与内存 扩容翻倍 → 保持 2 的幂hash (length-1) 始终有效 扰动函数 → hash ^ (hash 16)零成本混合高低位 尾插法JDK 8 → 解决并发 resize 死循环记住这三个问题框架为什么这么设计 → 统计学/性能/工程折中 代价是什么 → 内存浪费/单线程开销/并发风险 什么时候会翻车 → 碰撞攻击/内存不足/多线程写入本文数据来自 JVM 实验和源码分析完整测试代码见language-basics/模块。
【反八股 01】HashMap 的设计参数是怎么来的
发布时间:2026/6/9 17:41:52
八股会问你HashMap 的底层结构是什么扩容因子是多少反八股问三个问题为什么这么设计代价是什么什么时候会翻车一、八股怎么教 HashMap1. 数组 链表JDK 8 后链表长度 ≥ 8 转红黑树 2. 默认容量 16负载因子 0.75扩容阈值 12 3. 扩容时容量翻倍重新 hash 4. 线程不安全用 ConcurrentHashMap这是正确的但八股没有回答的问题是为什么负载因子是 0.75 而不是 0.5 或 0.8链表转红黑树的阈值为什么是 8HashMap 占堆 30% 就该上 Redis这个说法对吗初始容量设不设到底有多大影响二、为什么负载因子是 0.750.75 是时间和空间的折中点不是随便选的。负载因子越小如 0.5 → 数组更早扩容链表更短查询更快 → 但浪费大量内存一半的桶是空的 负载因子越大如 1.0 → 内存利用率高桶基本塞满才扩容 → 但链表很长查询退化到 O(n) 0.75 的数学依据 泊松分布下负载因子 0.75 时 桶中元素个数超过 8 的概率 0.00000006 → 红黑树几乎不会被触发极端碰撞场景除外代价0.75 意味着永远有约 25% 的内存浪费。如果你的场景是内存极度紧张但查询不频繁可以调到 0.9。但大多数时候不要动它。边界负载因子调到多少都没法解决 hash 碰撞攻击的问题——如果攻击者故意构造 hashCode 相同的 key所有元素挤在一个桶里查询直接退化到 O(n)。这是 DoS 攻击的经典手法。三、为什么链表转红黑树的阈值是 8这个数字背后是统计学推导不是经验值。理想 hash 函数下元素落入每个桶的数量服从泊松分布 桶中元素数量 概率 0 0.6065 1 0.3033 2 0.0758 3 0.0126 4 0.0016 5 0.0002 6 0.00002 7 0.000002 8 0.00000006 ← 阈值在这里 超过 8 个元素落入同一个桶的概率是千万分之一。 如果真出现了说明 hash 函数有问题或者遭到了碰撞攻击。 此时转红黑树 O(log n) 兜底防止性能雪崩。代价红黑树节点TreeNode占用的内存是普通节点Node的 2 倍多了 parent/left/right/prev/boolean 六个字段。如果正常场景下也转树内存会暴涨。边界退化阈值是 6不是 8中间留了 2 的缓冲区避免频繁在链表和红黑树之间切换。如果 hashCode 实现不好导致频繁树化应该修 hashCode而不是调阈值。四、HashMap 占堆多少该上 Redis网上常说占堆 30% 就该换 Redis。这个说法缺乏数据支撑。我们跑了 20 组实测数据来验证。实验设计变量 JVM 堆大小512m / 1g HashMap 占堆比例10% / 20% / 30% / 40% / 50% 操作模式纯读 / 混合67% 读 33% 写 关键修正启动后台线程持续分配临时对象模拟生产 GC 压力 环境Dragonwell 17 / G1GC / 每组 300 万次查询结果p99 延迟微秒512m 堆占比readmixed10%1.52.120%2.51.930%1.82.940%3.32.150%2.22.4Redis 同机房 p99 ≈ 1000 微秒。HashMap 占堆 50% 时 p99 只有 2~4 微秒差了 250 倍。30% 就该换为什么不对GC 停顿确实存在1~2ms/次但只影响正在执行的那一个操作。 300 万次操作中大约 100 次 GC 0.003% 受影响。 要影响到 p991% 阈值需要 30000 次 GC这不现实。什么时候真的该上 Redis判断标准不是延迟是数据要不要跨进程共享 单进程用 → HashMap占堆 50% 也没问题 多进程共享 → Redis别犹豫 数据比内存大 → Redis / 数据库 延迟不是换的理由——G1GC 下 HashMap 的 p99 比 Redis 快 250 倍换过去反而更慢。五、初始容量设不设有区别吗实测数据100 万条数据负载因子 0.75不设初始容量default 16 put 100 万条 → 扩容 17 次16→32→64→...→1048576 总耗时~120ms 每次 resize 要重新计算所有 key 的位置 复制数据 设初始容量为 13333341000000 / 0.75 1 put 100 万条 → 0 次扩容 总耗时~80ms 提速 33% 内存差异几乎一样最终都是同样的数组大小代价初始容量设太大同样有问题。100 条数据设new HashMap(1000000)一开始就占 4MB 数组空间。更常见的错误是不评估实际业务数据量直接套公式算出一个巨大的数字——比如业务实际只会有 500 条数据却按 20 万算出 262,144 的初始容量白白浪费内存。边界数据量小于 1000 时差别不大resize 只有一两次耗时 1ms。数据量超过 10 万时不设初始容量会明显感觉到 resize 的开销。实战建议公式不是万能药关键是先搞清楚你的业务到底有多少数据// 第一步评估业务数据量// - 这个 Map 的数据来源是什么数据库查出来的接口返回的配置文件// - 数据量上限是多少是 100 条还是 10 万条// - 数据量是固定的还是随时间增长的// 第二步确认数据量后按公式计算intexpectedSize500;// ← 这个数字来自业务评估不是拍脑袋MapString,StringmapnewHashMap((int)(expectedSize/0.75)1);// 常见误区// ❌ 不管业务多大一律 new HashMap(262144) — 浪费内存// ❌ 从配置文件读 10 条数据初始容量给 100000 — 浪费内存// ✅ 确认数据源最多返回 500 条初始容量给 668 — 合理// ✅ 不确定数据量用默认 16 — 也能用就是多几次 resize// 或者用 Guava更直观MapString,StringmapMaps.newHashMapWithExpectedSize(500);六、容量为什么是 2 的幂位运算与扰动函数位运算替代取模HashMap 计算元素桶下标时需要把 hash 值映射到数组索引index hash % length。取模是除法运算CPU 需要约 20~40 个时钟周期。而hash (length - 1)是一次位与运算只需 1 个周期——快 20~40 倍。但 (length - 1)等价于% length仅当 length 是 2 的幂length 16二进制 10000 length - 1 15二进制 01111 hash 15 → 结果范围 0~15等价于 hash % 16如果 length 不是 2 的幂呢length 15二进制 1111 length - 1 14二进制 1110 hash 14 → 结果只能是 0,2,4,6,8,10,12,148 个偶数 奇数位置永远为空 → 一半桶浪费 → 碰撞率翻倍代价2 的幂意味着容量只能是 2, 4, 8, 16, 32… 不能精细控制。new HashMap(100)实际容量被向上取到 128最近的 2 的幂浪费 28%。扩容翻倍16→32→64→…保持 2 的幂不变位运算始终有效——这就是扩容翻倍而不是加固定值的原因。为什么默认是 16 而不是 8 或 32容量太小如 2 或 4 length - 1 1 或 3二进制 01 或 011 只有 1~2 位参与路由 → hash 值高位全被忽略 → 碰撞极高 容量太大如 1024 默认只有 12 个元素16 × 0.75就占 8KB 数组 → 浪费内存 16 是折中4 位参与路由足够分散空数组占 128 字节可忽略扰动函数hash ^ (hash 16)即使容量是 2 的幂容量小时参与路由的位数太少容量 16 时length - 1 0b0000...01111 hash 0b0000...01111 → 只有低 4 位起作用高 28 位被掩盖 假设两个 key key1.hashCode() 0b10101010_10101010_10101010_1101 key2.hashCode() 0b01010101_01010101_01010101_1101 低 4 位完全相同 → 路由到同一个桶 → 碰撞 但它们的高位差异很大JDK 的解决方案——扰动函数// HashMap.hash() 源码JDK 8staticfinalinthash(Objectkey){inth;return(keynull)?0:(hkey.hashCode())^(h16);}原理把 hash 的高 16 位异或到低 16 位让高位特征参与路由 原始 hash: 10101010 10101010 10101010 11010000 右移 16 位: 00000000 00000000 10101010 10101010 异或结果: 10101010 10101010 00000000 01111010 ↑ 高位特征混入了低位 再 (length-1) 时高 16 位和低 16 位的特征都在了代价一次右移 一次异或几乎零成本。这不是完美的散列只混合了一次但用最低的代价显著降低了碰撞概率。边界容量超过 2^16 65536 时扰动函数的效果递减——低位本身已经足够参与路由。但 HashMap 很少超过这个大小超过 65536 个元素通常该考虑数据库或 Redis 了。七、线程不安全到底会怎样八股说HashMap 线程不安全但很少有人解释具体会出什么事。并发 put 的三个问题 1. 数据丢失 线程 A 和线程 B 同时 put 到同一个桶 A 写入 node1 → B 写入 node2 覆盖了 node1 → node1 的数据丢了 2. resize 死循环JDK 7 并发扩容时头插法导致链表成环 后续 get 遍历链表进入死循环 → CPU 100% JDK 8 改成尾插法解决了这个问题 3. size 不准确 size 不是原子操作两个线程同时 1 实际只加了 1JDK 8 没有死循环了但数据丢失和 size 不准确仍然存在。解决方案的取舍方案性能安全性适用场景Collections.synchronizedMap差全表锁安全低并发ConcurrentHashMap好分段锁/CAS安全高并发外部加锁看锁粒度安全已有锁体系ConcurrentHashMap 的代价每个 put/get 多了 CAS 操作单线程性能比 HashMap 低约 10-15%。只有在真正并发写入时才值得用。八、总结HashMap 的设计哲学HashMap 的每个参数背后都有推导依据负载因子 0.75 → 时间和空间的统计最优解 树化阈值 8 → 泊松分布下千万分之一概率的兜底 初始容量 16 → 2 的幂4 位参与路由折中分散与内存 扩容翻倍 → 保持 2 的幂hash (length-1) 始终有效 扰动函数 → hash ^ (hash 16)零成本混合高低位 尾插法JDK 8 → 解决并发 resize 死循环记住这三个问题框架为什么这么设计 → 统计学/性能/工程折中 代价是什么 → 内存浪费/单线程开销/并发风险 什么时候会翻车 → 碰撞攻击/内存不足/多线程写入本文数据来自 JVM 实验和源码分析完整测试代码见language-basics/模块。