HashMap源码分析案例

wen java案例 2

HashMap源码深度剖析:从JDK1.7到1.8的演进与实战案例


目录导读

  1. 开场:HashMap为什么值得你花时间读源码?
  2. 底层数据结构演变:数组+链表 → 数组+链表+红黑树
  3. 核心方法源码实战案例(put/get/resize)
  4. 高频面试问答:容量、阈值、哈希碰撞深度解析
  5. 性能陷阱与最佳实践(避坑指南)
  6. 读源码带给我们的设计思维

开场:HashMap为什么值得你花时间读源码?

在Java日常开发中,HashMap是使用频率最高的集合类之一,但如果你仅仅停留在“会用”层面,当面对高并发扩容死循环大量哈希碰撞导致的性能骤降自定义对象作为key时的equals/hashCode规范问题时,你会毫无头绪,本文将通过源码级别的案例分析,带你穿透HashMap的“黑盒”,真正理解它的设计哲学。

HashMap源码分析案例


底层数据结构演变:数组+链表 → 数组+链表+红黑树

JDK1.7痛点:

  • 数组+链表结构,当哈希碰撞严重时,链表过长,查询效率退化为O(n)。
  • 头插法在并发扩容时容易形成环形链表,导致CPU 100%。

JDK1.8优化(源码证据):

// 来自JDK1.8 HashMap.putVal() 片段
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
    treeifyBin(tab, hash);
  • 当链表长度≥8且数组容量≥64时,链表转为红黑树,查询复杂度降为O(log n)。
  • 采用尾插法,避免并发死循环(但依然非线程安全)。

实战案例: 模拟100个哈希值相同但内容不同的对象(例如重写hashCode返回固定值),观察JDK1.7与1.8在插入和查询性能上的差异,在JDK1.8中,当树化后,性能提升明显,而JDK1.7则直线下降。


核心方法源码实战案例(put/get/resize)

1 put方法的核心逻辑(JDK1.8简化版)

final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length; // 懒加载,首次put才初始化数组
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null); // 无碰撞直接放
    else {
        // 处理碰撞:检查key是否存在 -> 链表 -> 树
    }
}

案例分析:

  • 索引计算 (n-1) & hash 替代取模运算,性能更高,但要求数组长度为2的幂次方。
  • 为什么HashMap容量必须是2的幂?因为这样 (n-1) & hash 能均匀分布且减少碰撞。

2 resize扩容机制(重点难点)

final Node<K,V>[] resize() {
    // ... 计算新容量newCap和阈值newThr
    if (oldCap > 0) {
        if (oldCap >= MAXIMUM_CAPACITY) {...}
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1; // 阈值翻倍
    }
    // 关键:数据迁移时,要么在原来索引,要么在 原索引+oldCap
}

实战案例: 扩容后,元素位置判定:if ((e.hash & oldCap) == 0) 则留在原位置,否则移动到 原位置+oldCap,此设计避免JDK1.7的rehash操作,提升性能。


高频面试问答:容量、阈值、哈希碰撞深度解析

问1:为什么HashMap的默认初始容量是16,而不是10或32? 答:16是2的4次幂,既满足2的幂要求(保证位运算高效),又不会因为初始容量太大而浪费内存,如果预估数据量,应使用 new HashMap<>(expectedSize) 指定容量,且最好为2的次幂。

问2:加载因子为什么是0.75? 答:这是时间与空间的权衡,0.75时,HashMap的泊松分布计算下,桶中链表长度达到8的概率极低(约千万分之六),从而匹配红黑树的引入阈值,如果太大会增加碰撞,太小则浪费空间。

问3:两个不相等的对象hashCode一定不同吗? 答:不一定,哈希碰撞就是两个不同对象返回相同hashCode,HashMap通过equals()来区分,所以在自定义key时,必须同时重写hashCode()和equals(),且保证 “equals相等,hashCode一定相同”。


性能陷阱与最佳实践(避坑指南)

  • 陷阱1:在并发环境下使用HashMap。 尽管JDK1.8解决了死循环,但数据丢失、size不准仍会发生,建议使用 ConcurrentHashMap
  • 陷阱2:容量设太大或太小。 太小导致频繁扩容(拷贝数组+重哈希消耗大),太大导致内存浪费,预估值 = 实际容量 / 0.75。
  • 陷阱3:重写key的hashCode导致分布极差。
    // 错误示范
    @Override public int hashCode() { return 1; }
  • 最佳实践: 使用 Map<String, Object> 时,String已重写优秀hash算法,若自定义,模仿31倍哈希法(如 result = 31 * result + ...)。

读源码带给我们的设计思维

从HashMap的源码分析中,我们可以提炼出三大设计准则:

  1. 空间换时间:数组+链表+红黑树的组合结构,本质是根据碰撞概率动态选择最优数据结构。
  2. 位运算优化:利用 & 代替 ,利用 << 代替 *2,在底层追求极致效率。
  3. 惰性初始化与动态扩容:不到万不得已不分配内存,扩容时通过位运算避免rehash。

最后问答互动: 如果让你在JDK1.8中的HashMap里加入一个 getOrDefault 方法(其实已存在),你如何设计?答案很简单:先判断 get(key) 是否为null,再返回默认值,但源码中实现是直接调 getNode 逻辑,避免了二次哈希,这也是细节优化的体现。


(全文完)

注: 本文基于JDK1.8源码撰写,结合了JDK1.7的对比案例,掌握HashMap源码,不仅是为了面试,更是为了在设计高并发、大数据量系统时做出正确决策,建议读者亲手运行案例,观察扩容日志与树化日志(可设置JVM参数 -Djdk.map.althashing.threshold=4 辅助观察)。

抱歉,评论功能暂时关闭!