一句话结论:HashMap = 数组 + 哈希扰动 + 链表/红黑树 + 2 倍扩容 rehash,用 O(1) 的定位换 O(log n) 的兜底。
put(k, v) 的完整链路:key → hashCode → 扰动函数 → 取模定位 → 落入桶(链表或红黑树)。
这是一个严格按 JDK 8 规则模拟的 HashMap(初始容量 16、负载因子 0.75、树化阈值 8、最小树化容量 64)。输入 key 观察它落入哪个桶。
输入任意 key,实时观察 hashCode → h^(h>>>16) → (n-1)&h 的每一比特变化。注意:最终索引只由低位决定,扰动把高位信息混入低位,大幅降低冲突率。
h ^ (h >>> 16) 用一次异或把高位特征"搅"进低位——开销极小,收益极大。
扩容时容量 ×2,每个节点只需判断 hash 的一个新增比特位:原位置不动,或平移 oldCap——无需重新取模,这就是 2 的幂设计的精妙之处。
旧:index = hash & 0 1111 (n=16)
新:index = hash & 1 1111 (n=32)
差异仅在第 5 位:
hash & 16 == 0 → 留在 原桶 j
hash & 16 == 1 → 搬到 j + 16
① (n-1) & hash 替代取模运算,位运算快一个数量级
② 扩容 rehash 只看 1 个比特,O(n) 搬家成本减半
③ 配合扰动函数,分布均匀性不依赖 key 的低位质量
JDK 8 的改动全部指向一个目标:在最坏情况下止血。
新节点插到链表头部,无需遍历,插入 O(1)。但多线程扩容时头插会反转链表顺序,可能形成环形链表 → get() 死循环,CPU 100%。这是 HashMap 最著名的并发事故。
遍历到尾部插入,保持原有顺序,扩容拆分后相对顺序不变,消除了成环的可能。代价:插入要遍历到链尾(树化机制恰好又兜住了遍历成本)。
⚠️ 注意:尾插只解决成环,HashMap 依然线程不安全,并发场景请用 ConcurrentHashMap。
每个数字背后都有工程权衡。
| 常量 | 值 | 为什么是这个值 |
|---|---|---|
| DEFAULT_INITIAL_CAPACITY | 16 | 2 的幂;兼顾小 Map 的内存占用与扩容频率 |
| MAXIMUM_CAPACITY | 1 << 30 | int 正数范围内最大的 2 的幂 |
| DEFAULT_LOAD_FACTOR | 0.75 | 空间与时间的折中:0.5 浪费内存,1.0 冲突激增;0.75 是泊松分布与经验验证的甜点 |
| TREEIFY_THRESHOLD | 8 | 理想哈希下链长 ≥8 的概率约 6×10⁻⁸(泊松分布),出现即视为哈希失效 |
| UNTREEIFY_THRESHOLD | 6 | 与 8 之间留出缓冲带,防止链长在边界抖动导致频繁树化/退化 |
| MIN_TREEIFY_CAPACITY | 64 | 容量小时冲突多应先扩容(更便宜),而非急着树化 |
| null key | hash = 0 | 固定放在 table[0],只允许一个 null key |
equals() 必须重写 hashCode()?
—— 因为定位桶靠 hashCode,桶内比价靠 equals。契约断裂则 equals 相等的对象落入不同桶,get 永远 miss。