前言
HashMap
在Java
和Android
开发中非常常见- 今天,我将带来
HashMap
的全部源码分析,希望你们会喜欢。
- 本文基于版本
JDK 1.7
,即Java 7
- 关于版本
JDK 1.8
,即Java 8
,具体请看文章Java源码分析:关于 HashMap 1.8 的重大更新
目录
- 类定义
1 | 复制代码public class HashMap<K,V> |
- 主要介绍
HashMap
的实现在JDK 1.7
和JDK 1.8
差别较大- 今天,我将主要讲解
JDK 1.7
中HashMap
的源码解析
关于
JDK 1.8
中HashMap
的源码解析请看文章:Java源码分析:关于 HashMap 1.8 的重大更新
2.1 具体描述
HashMap
采用的数据结构 = 数组(主) + 单链表(副),具体描述如下
该数据结构方式也称:拉链法
2.2 示意图
2.3 存储流程
注:为了让大家有个感性的认识,只是简单的画出存储流程,更加详细 & 具体的存储流程会在下面源码分析中给出
2.4 数组元素 & 链表节点的 实现类
HashMap
中的数组元素 & 链表节点 采用Entry
类 实现,如下图所示
- 即
HashMap
的本质 = 1个存储Entry
类对象的数组 + 多个单链表Entry
对象本质 = 1个映射(键 - 值对),属性包括:键(key
)、值(value
) & 下1节点(next
) = 单链表的指针 = 也是一个Entry
对象,用于解决hash
冲突
- 该类的源码分析如下
具体分析请看注释
1 | 复制代码/** |
3.1 主要使用API(方法、函数)
1 | 复制代码V get(Object key); // 获得指定键的值 |
3.2 使用流程
- 在具体使用时,主要流程是:
- 声明1个
HashMap
的对象 - 向
HashMap
添加数据(成对 放入 键 - 值对) - 获取
HashMap
的某个数据 - 获取
HashMap
的全部数据:遍历HashMap
- 示例代码
1 | 复制代码import java.util.Collection; |
- 运行结果
1 | 复制代码方法1 |
下面,我们按照上述的使用过程,对一个个步骤进行源码解析
- 在进行真正的源码分析前,先讲解
HashMap
中的重要参数(变量) HashMap
中的主要参数 = 容量、加载因子、扩容阈值- 具体介绍如下
1 | 复制代码// 1. 容量(capacity): HashMap中数组的长度 |
- 参数示意图
- 此处 详细说明 加载因子
- 本次的源码分析主要是根据 使用步骤 进行相关函数的详细分析
- 主要分析内容如下:
- 下面,我将对每个步骤内容的主要方法进行详细分析
步骤1:声明1个 HashMap的对象
1 | 复制代码/** |
- 注:
- 此处仅用于接收初始容量大小(
capacity
)、加载因子(Load factor
),但仍无真正初始化哈希表,即初始化存储数组table
- 此处先给出结论:真正初始化哈希表(初始化存储数组
table
)是在第1次添加键值对时,即第1次调用put()
时。下面会详细说明
- 此处仅用于接收初始容量大小(
至此,关于HashMap
的构造函数讲解完毕。
步骤2:向HashMap添加数据(成对 放入 键 - 值对)
- 添加数据的流程如下
注:为了让大家有个感性的认识,只是简单的画出存储流程,更加详细 & 具体的存储流程会在下面源码分析中给出
- 源码分析
1 | 复制代码 /** |
- 根据源码分析所作出的流程图
- 下面,我将根据上述流程的5个分析点进行详细讲解
分析1:初始化哈希表
即 初始化数组(table
)、扩容阈值(threshold
)
1 | 复制代码 /** |
- 再次强调:真正初始化哈希表(初始化存储数组
table
)是在第1次添加键值对时,即第1次调用put()
时
分析2:当 key ==null时,将该 key-value 的存储位置规定为数组table 中的第1个位置,即table [0]
1 | 复制代码 /** |
从此处可以看出:
HashMap
的键key
可为null
(区别于HashTable
的key
不可为null
)HashMap
的键key
可为null
且只能为1个,但值value
可为null且为多个
分析3:计算存放数组 table 中的位置(即 数组下标 or 索引)
1 | 复制代码 /** |
- 总结 计算存放在数组 table 中的位置(即数组下标、索引)的过程
在了解 如何计算存放数组table
中的位置 后,所谓 知其然 而 需知其所以然,下面我将讲解为什么要这样计算,即主要解答以下3个问题:
- 为什么不直接采用经过
hashCode()
处理的哈希码 作为 存储数组table
的下标位置? - 为什么采用 哈希码 与运算(&) (数组长度-1) 计算数组下标?
- 为什么在计算数组下标前,需对哈希码进行二次处理:扰动处理?
在回答这3个问题前,请大家记住一个核心思想:
所有处理的根本目的,都是为了提高 存储
key-value
的数组下标位置 的随机性 & 分布均匀性,尽量避免出现hash值冲突。即:对于不同key
,存储的数组下标位置要尽可能不一样
问题1:为什么不直接采用经过hashCode()处理的哈希码 作为 存储数组table的下标位置?
- 结论:容易出现 哈希码 与 数组大小范围不匹配的情况,即 计算出来的哈希码可能 不在数组大小范围内,从而导致无法匹配存储位置
- 原因描述
- 为了解决 “哈希码与数组大小范围不匹配” 的问题,
HashMap
给出了解决方案:哈希码 与运算(&) (数组长度-1);请继续问题2
问题2:为什么采用 哈希码 与运算(&) (数组长度-1) 计算数组下标?
- 结论:根据HashMap的容量大小(数组长度),按需取 哈希码一定数量的低位 作为存储的数组下标位置,从而 解决 “哈希码与数组大小范围不匹配” 的问题
- 具体解决方案描述
问题3:为什么在计算数组下标前,需对哈希码进行二次处理:扰动处理?
- 结论:加大哈希码低位的随机性,使得分布更均匀,从而提高对应数组存储下标位置的随机性 & 均匀性,最终减少Hash冲突
- 具体描述
至此,关于怎么计算 key-value
值存储在HashMap
数组位置 & 为什么要这么计算,讲解完毕。
分析4:若对应的key已存在,则 使用 新value 替换 旧value
注:当发生
Hash
冲突时,为了保证 键key
的唯一性哈希表并不会马上在链表中插入新数据,而是先查找该key
是否已存在,若已存在,则替换即可
1 | 复制代码 /** |
- 此处无复杂的源码分析,但此处的分析点主要有2个:替换流程 &
key
是否存在(即key
值的对比)
分析1:替换流程
具体如下图:
分析2:key
值的比较
采用 equals()
或 “==” 进行比较,下面给出其介绍 & 与 “==”
使用的对比
分析5:若对应的key不存在,则将该“key-value”添加到数组table的对应位置中
- 函数源码分析如下
1 | 复制代码 /** |
此处有2点需特别注意:键值对的添加方式 & 扩容机制
1. 键值对的添加方式:单链表的头插法
- 即 将该位置(数组上)原来的数据放在该位置的(链表)下1个节点中(next)、在该位置(数组上)放入需插入的数据-> 从而形成链表
- 如下示意图
2. 扩容机制
- 具体流程如下:
- 扩容过程中的转移数据示意图如下
在扩容resize()
过程中,在将旧数组上的数据 转移到 新数组上时,转移操作 = 按旧链表的正序遍历链表、在新链表的头部依次插入,即在转移数据、扩容后,容易出现链表逆序的情况
设重新计算存储位置后不变,即扩容前 = 1->2->3,扩容后 = 3->2->1
- 此时若(多线程)并发执行 put()操作,一旦出现扩容情况,则 容易出现 环形链表,从而在获取数据、遍历链表时 形成死循环(Infinite Loop),即 死锁的状态 = 线程不安全
下面最后1节会对上述情况详细说明
总结
- 向
HashMap
添加数据(成对 放入 键 - 值对)的全流程
- 示意图
至此,关于 “向 HashMap
添加数据(成对 放入 键 - 值对)“讲解完毕
步骤3:从HashMap中获取数据
- 假如理解了上述
put()
函数的原理,那么get()
函数非常好理解,因为二者的过程原理几乎相同 get()
函数的流程如下:
- 具体源码分析如下
1 | 复制代码/** |
至此,关于 “向 HashMap
获取数据 “讲解完毕
步骤4:对HashMap的其他操作
即 对其余使用
API
(函数、方法)的源码分析
HashMap
除了核心的put()
、get()
函数,还有以下主要使用的函数方法
1 | 复制代码void clear(); // 清除哈希表中的所有键值对 |
- 下面将简单介绍上面几个函数的源码分析
1 | 复制代码 /** |
至此,关于HashMap
的底层原理 & 主要使用API
(函数、方法)讲解完毕。
下面,用3个图总结整个源码内容:
总结内容 = 数据结构、主要参数、添加 & 查询数据流程、扩容机制
- 数据结构 & 主要参数
- 添加 & 查询数据流程
- 扩容机制
HashMap
的实现在 JDK 1.7
和 JDK 1.8
差别较大,具体区别如下
JDK 1.8
的优化目的主要是:减少Hash
冲突 & 提高哈希表的存、取效率;关于JDK 1.8
中HashMap
的源码解析请看文章:Java源码分析:关于 HashMap 1.8 的重大更新
7.1 数据结构
7.2 存入数据时(获取数据 类似)
7.3 扩容机制
- 有几个小问题需要在此补充
- 具体如下
8.1 哈希表如何解决Hash冲突
8.2 为什么HashMap具备下述特点:键-值(key-value)都允许为空、线程不安全、不保证有序、存储位置随时间变化
- 具体解答如下
- 下面主要讲解
HashMap
线程不安全的其中一个重要原因:多线程下容易出现resize()
死循环 - 本质 = 并发 执行
put()
操作导致触发 扩容行为,从而导致 环形链表,使得在获取数据遍历链表时形成死循环,即Infinite Loop
* - 先看扩容的源码分析
resize()
关于resize()的源码分析已在上文详细分析,此处仅作重点分析:transfer()
1 | 复制代码/** |
从上面可看出:在扩容resize()
过程中,在将旧数组上的数据 转移到 新数组上时,转移数据操作 = 按旧链表的正序遍历链表、在新链表的头部依次插入,即在转移数据、扩容后,容易出现链表逆序的情况
设重新计算存储位置后不变,即扩容前 = 1->2->3,扩容后 = 3->2->1
- 此时若(多线程)并发执行
put()
操作,一旦出现扩容情况,则 容易出现 环形链表,从而在获取数据、遍历链表时 形成死循环(Infinite Loop
),即 死锁的状态,具体请看下图:
初始状态、步骤1、步骤2
注:由于 JDK 1.8
转移数据操作 = 按旧链表的正序遍历链表、在新链表的尾部依次插入,所以不会出现链表 逆序、倒置的情况,故不容易出现环形链表的情况。
但
JDK 1.8
还是线程不安全,因为 无加同步锁保护
8.3 为什么 HashMap 中 String、Integer 这样的包装类适合作为 key 键
8.4 HashMap 中的 key
若 Object
类型, 则需实现哪些方法?
至此,关于HashMap
的所有知识讲解完毕。
- 本文主要讲解
Java
的HashMap
源码 & 相关知识 - 下面我将继续对
Java
、Android
中的其他知识 深入讲解 ,有兴趣可以继续关注Carson_Ho的安卓开发笔记
请点赞!因为你的鼓励是我写作的最大动力!
欢迎关注carson_ho的微信公众号
本文转载自: 掘金