深入底层理解HashMap——妙哉妙哉的结构!!

HashMap

引言

学Java的小伙伴们一定在学习的过程中都离不开集合框架吧~

临时提问:常见的集合框架有什么?

3

2

1

没错!你说的很对,常见的集合框架有 List ,Set,Map~

而今天我们要说的是Map当中的HashMap~


一、HashMap的概念

  • 什么是HashMap:HashMap是Java集合框架中极为重要的一员,它以 "键-值对" 的形式存储数据,支持快速的插入、查找和删除操作,平均时间复杂度为O(1)。它基于哈希表实现,允许使用null键和多个null值,是开发中高频使用的数据结构之一。
  • 应用场景:在实际编程中,HashMap广泛应用于缓存系统(如本地缓存)、配置项管理、统计频次(如词频统计)、去重处理以及数据库结果映射等场景,极大提升了程序的运行效率和可读性。

那么为什么HashMap的平均时间复杂度如此之低呢,竟然只要O(1),到底是怎么做到的,这也太匪夷所思了,别着急,我们从HashMap的基本结构说起

二、HashMap的基本结构

1."数组 + 链表/红黑树"的复合结构

  • 数组+链表/红黑树:HashMap底层采用"数组 + 链表/红黑树"的复合结构。数组是哈希表的主干,每个数组元素称为一个"桶"(Bucket)。当发生哈希冲突时,多个元素会以链表形式挂在同一个桶上;当链表长度超过8且数组长度达到64时,链表自动转为红黑树,以提升查找性能。

说到这里你可能还是有些懵逼,我来给你画个图你就懂了:

【小声叨叨:我猜有小伙伴不知道啥是红黑树,也不知道啥是平衡二叉树,这个你就当是一种很巧妙的构思,让人直呼妙哉妙哉,能够保证查找、插入和删除操作的最坏时间复杂度均为 O(logn),后续有时间再给大家讲】

其中,红色框框就相当于一个结点,里面有什么呢?

java 复制代码
static class Node<K,V> implements Map.Entry<K,V> {
    final int hash;       // 哈希值
    final K key;          // 键
    V value;              // 值
    Node<K,V> next;       // 指向下一个节点的指针(绿色箭头的实现原理)
    // 构造函数...
}

2.哈希值

那可能又会有小伙伴们看到上述代码里的 final int hash; 问:啥又是哈希值呢?

我们都知道 Java 的运行时环境(JVM)本身就是用 C++ 等语言编写的。在 Java 中,Object 类是所有类的祖宗,而 hashCode() 方法正是定义在 Object 类中。

这就好比:

每一个 Java 对象出生时,系统都会给它发一个"身份凭证"。虽然这个凭证在 Java 层面表现为一个方法,但在底层,它往往是由 JVM 用更高效的语言(如 C++)来实现的。这就像是由"户籍系统底层"直接生成的编号,我们直接拿过来用就行,不需要自己去编写生成逻辑。

在 HashMap 中的应用:

当你把一个字符串(比如 "name")作为 Key 存入 HashMap 时,HashMap 的第一反应就是:"我要检查你的指纹!"

它会立刻调用这个 Key 的 hashCode() 方法。这个"指纹"是后续一切操作的基础,HashMap 会拿着这个号码去进行扰动计算和取模运算,最终算出这个 Key 应该存放在数组的哪一个"桶"里。

PS:虽然所有对象都有这个方法,但默认情况下 (如果没有被重写),这个哈希码通常是根据对象的内存地址 通过某种算法映射得来的;而像 StringInteger 等包装类,它们都重写hashCode() 方法,确保内容相同的字符串,算出来的哈希码是一模一样的。

那么我们再来讲讲扰动计算和取模运算~

首先,调用 Key 的 hashCode() 方法,得到一个原始的整数(哈希码)。

1. 扰动函数(防止分布不均)

拿到原始哈希码后,HashMap 不会直接使用它。

  • 问题: 如果用户实现的 hashCode() 不够好(比如产生的哈希码低比特位变化少,高比特位全是0),直接取模会导致所有数据都集中在数组的前半部分,造成严重的哈希冲突。
  • 解决(扰动): Java 会对原始哈希码进行高地位异或运算h ^ (h >>> 16))。
  • 效果: 这样做是为了让哈希码的高位信息 也能参与到下标的计算中,让生成的哈希值更加"随机"和均匀分布,从而减少冲突。

2. 取模运算(定位桶)

最后,为了让哈希值能对应上数组的索引,会进行取模运算。

  • 公式: index = hash % 数组长度 或者在源码中更高效的位运算 index = (n - 1) & hash
  • 结果: 这一步决定了你的 Key-Value 对最终会落入哪一个**黑色的桶(数组槽位)**中。

所以:

哈希值就是通过算法算出的地址线索。它的质量直接决定了红黑树出现的频率,也决定了 HashMap 的读写性能。

哈希冲突

但是,这里有一个巨大的误区需要澄清:哈希值并不是绝对唯一的。

假设我们现在要往 HashMap 里存第一个数据:put("name", "Mic")

  1. 算哈希 :HashMap 调用 "name".hashCode(),得到一个数字。
  2. 定位置 :经过扰动和取模,算出这个数据应该放在数组的第 1 号桶
  3. 直接放:因为是第一个数据,1 号桶空空如也,直接放进去,大功告成!

这时候,HashMap 的内心独白是:"这工作太轻松了,我就是个简单的数组。"

但是,程序的世界哪有那么多"刚好"?

假设现在我们要存第二个数据:put("hobby", "swing")

  1. 算哈希 :调用 "hobby".hashCode()
  2. 定位置 :经过计算,尴尬的一幕发生了------它算出来的位置也是第 1 号桶

这就是哈希冲突(Hash Collision)。

并且哈希冲突在数学上来说是不可避免的,那么为啥躲不掉呢?

核心原因(鸽巢原理)

想象一下,你可以存入的数据(输入空间)是无限的(任意长的字符串、文件),但哈希值(输出空间)是一个固定的数字范围(比如 int 类型只有 2^32 种可能)。这就像是有无数只鸽子(数据),但只有 42 亿个鸽巢(哈希值)。根据鸽巢原理,当鸽子数量超过鸽巢数量时,必然会有两只不同的鸽子挤进同一个巢里。这就是哈希碰撞。

如果 HashMap 还是死板地只用数组,那 "hobby" 就会把 "name" 挤走(覆盖),或者被 "name" 挡在外面。这显然是不行的。

HashMap 的应对策略

正因为哈希值不保真(不绝对唯一),所以 HashMap 在拿到哈希值定位到"桶"之后,还得进行二次确认:

第一步(看哈希值):快速定位到大致位置(桶)。

第二步(看 equals):在桶里遍历链表或红黑树,调用 equals() 方法逐个比对,确认到底是不是真的同一个 Key。

总结一句话:

哈希值是用来快速筛选的,equals() 是用来最终判决的。

哈希值不同,对象一定不同;哈希值相同,对象不一定相同。

三、HashMap的put与get

1. 存储过程(put)

  • 首先调用键的hashCode()方法计算原始哈希值。
  • 经过扰动处理(高地位异或)后,通过(n - 1) & hash的方式确定数组下标。
  • 若该位置为空,则直接插入;否则遍历链表或红黑树,比较键的hash值和equals(),若已存在则更新值,否则添加新节点。

2.查找过程(get)

  • 同样计算键的哈希值并定位到数组索引。
  • 若该位置有元素,则逐个比较链表或红黑树中的节点,通过hash值和equals()判断是否匹配,找到则返回对应值,否则返回null。

四、HashMap的扩容机制

1.负载因子

负载因子(load factor)是衡量HashMap填充程度的指标,默认值为0.75。当元素数量 > 容量 × 负载因子时,触发扩容(一般都是翻一倍)。

2.扩容过程

扩容时创建一个原数组两倍大小的新数组,将所有元素重新计算索引并迁移过去。此过程较耗时,因此建议在初始化时预估数据量,设置合理的初始容量,避免频繁扩容。

五、HashMap的线程安全性

1. 致命缺陷:JDK 1.7 的"死循环" (CPU 100%)

这是 HashMap 线程不安全最恐怖的表现。在 JDK 1.7 中,多线程并发扩容(resize)时,极有可能导致链表形成环形结构 ,一旦形成,后续的 get() 操作就会陷入无限循环,导致 CPU 使用率瞬间飙升至 100%。

  • 根源:头插法 (Head Insertion)
    JDK 1.7 在扩容迁移数据时,使用的是头插法 。即:将原链表的节点拆下来,插入到新链表的头部
  • 场景复现(两个线程 A 和 B):
    1. 前提 :原数组某个桶里有一条链表 A -> B -> null
    2. 并发:线程 A 和 线程 B 同时触发扩容,都要迁移这条链表。
    3. 过程
      • 线程 A 执行到一半(假设刚拿到 A 节点,还没修改 A 的 next),被挂起。
      • 线程 B 醒来,它完整地执行了迁移:它把 B 插到新链表头,再把 A 插到新链表头。此时新链表变成 A -> B -> null(注意顺序变了)。
      • 线程 A 醒来,继续执行。此时线程 A 眼里的视图还是旧的(它认为 B 的 next 是 null)。
      • 线程 A 开始操作:它把 B 插到新链表头(此时新链表是 B -> A -> B...),由于线程 A 眼里的 B.next 是 null,它会错误地把 A 接在 B 后面,而此时 A 的 next 又指向 B。
    4. 结果 :链表变成了 A -> B -> A环形链表
    5. 后果 :只要程序后续调用 get() 去遍历这个桶,就会在这个环里无限转圈,直到栈溢出或程序卡死。

2. 顽固通病:数据覆盖 (JDK 1.7 & 1.8)

这个问题在所有版本的 HashMap 中都存在,它是由于 put 操作的非原子性导致的。

  • 根源:检查与插入的分离
    put 操作的逻辑通常是:先检查位置是否为空(tab[i] == null),如果是空的,就新建节点放入。
  • 场景复现(两个线程 A 和 B):
    1. 前提:线程 A 和 线程 B 准备往同一个桶(同一个索引)放入不同的键值对。
    2. 并发
      • 线程 A 先执行,它检查发现 tab[i] 是 null,准备执行插入。
      • 此时线程 A 被挂起。
      • 线程 B 开始执行,它也检查发现 tab[i] 是 null(因为 A 还没来得及插入),于是线程 B 把自己的数据插进去了。
      • 线程 A 恢复,它接着执行插入操作,由于它之前已经判断过位置为空,所以它会直接覆盖当前位置。
    3. 结果 :线程 B 的数据被线程 A 无情覆盖,导致数据丢失。

3. JDK 1.8 的改进与残留问题

JDK 1.8 对 HashMap 做了重大优化,虽然解决了"死循环"这个大毒瘤,但并没有让它变得线程安全。

  • 改进:尾插法 (Tail Insertion)
    JDK 1.8 将扩容时的插入方式改为了尾插法 。在迁移链表时,保持原来的顺序插入到新链表的尾部。这样即使多线程并发,也不会导致指针错乱形成环,彻底解决了死循环问题
  • 残留问题:数据错乱与结构破坏
    • 数据覆盖:上面提到的"检查-插入"分离导致的覆盖问题依然存在。
    • 结构破坏:虽然用了尾插法,但在多线程同时进行扩容和插入时,由于缺乏同步锁,可能会导致链表中的节点丢失,或者红黑树的结构被破坏(虽然概率较低,但依然存在)。

4. 替代方案:ConcurrentHashMap 的进化

既然 HashMap 不行,我们就得用专业的工具:ConcurrentHashMap

  • JDK 1.7 的策略:分段锁 (Segment)
    它把数据分成一段一段的存储,给每一段配一把锁(ReentrantLock)。当多个线程访问不同段的数据时,互不影响。这叫做锁分段技术,大大提高了并发度。
  • JDK 1.8 的策略:CAS + synchronized
    这一版的设计非常精妙:
    1. CAS (无锁化操作):在插入数据时,如果没有发生哈希冲突(即桶是空的),它会使用 CAS 操作直接插入。CAS 是一种硬件级别的原子指令,比加锁效率高得多。
    2. synchronized (细粒度锁) :只有当发生冲突,需要在链表或红黑树上插入时,才会使用 synchronized 锁住当前桶的头节点
    3. 优势 :锁的粒度非常细,只有当两个线程操作同一个桶时才会互斥。不同桶之间完全并发,性能极高。

总结对比表

特性 HashMap Hashtable ConcurrentHashMap (JDK 1.8)
线程安全 是 (全表锁) 是 (CAS + synchronized)
并发性能 单线程极高,多线程崩坏 极低 (synchronized 锁全表) 极高 (锁粒度极细)
死循环风险 JDK 1.7 有风险,1.8 无
推荐场景 单线程环境 已废弃,不推荐 高并发环境首选

一句话总结:

除非是单线程,否则永远不要在多线程环境下使用 HashMap。如果需要线程安全的 Map,ConcurrentHashMap 是唯一的正确选择。

六、总结与最佳实践

  • 总结:HashMap凭借其高效的存取性能和灵活的结构设计,成为Java中最常用的Map实现。理解其底层原理有助于写出更高效、更稳定的代码。
  • 最佳实践
    • 在已知数据量时,显式指定初始容量,避免多次扩容带来的性能损耗。
    • 自定义类作为键时,务必重写hashCode()和equals()方法,且保证其一致性与不变性。
    • 在多线程环境下,优先选择ConcurrentHashMap而非HashMap,确保程序的健壮性与安全性。
    • 避免使用可变对象作为键,防止其状态改变导致哈希值变化,从而无法正确访问值。
    • 理解树化与退化条件,合理设计键的分布,减少极端情况下的性能波动。

本文参考了网上的各种文章以及学习视频以及AI助手综合整理而来,由浅入深,层层递进的带大家深入了解HashMap,希望对大家有所帮助

不得不说,我在整理的时候,真的是直呼妙哉妙哉,其实大部分知识都是为了解决问题而想出的办法,只有我们知其然,知其所以然才能更好的运用知识,服务于业务~~~

大家加油鸭!!!

相关推荐
Serene_Dream2 小时前
Java 内存区域
java·jvm
爱吃山竹的大肚肚2 小时前
文件上传大小超过服务器限制
java·数据库·spring boot·mysql·spring
黄昏恋慕黎明2 小时前
测试模型讲解
java
瑞雪兆丰年兮2 小时前
[从0开始学Java|第十二天]学生管理系统升级
java·开发语言
弹简特2 小时前
【JavaSE-网络部分03】网络原理-泛泛介绍各个层次
java·开发语言·网络
周杰伦的稻香2 小时前
Hexo搭建教程
java·node.js
倔强的石头1062 小时前
飞算JavaAI如何提升重塑Java开发体验
java·飞算javaai·ai开发工具
努力d小白2 小时前
leetcode438.找到字符串中所有字母异位词
java·javascript·算法
短剑重铸之日2 小时前
《设计模式》第九篇:三大类型之结构型模式
java·后端·设计模式·组合模式·代理模式·结构性模式