一个工作的真实场景聊聊 HashMap 的实现
背景描述
业务同事和其他公司合作活动,对方公司筛选 2W 优质用户手机号提供给我方,出于安全考虑(其实具体原因未知),对方提供的手机号都是 MD5 加密内容;业务同事想对这 2W 用户做短信触达,搞促销活动,业务找到研发需要将现在的 2W MD5 手机号内容逆向转换成用户手机号,然后发短信,需求场景就是这样。
PS. 一句话需求,2W MD5 手机号内容,需要逆向碰撞出手机号明文,做短信促销。
实现过程
我们都知道 MD5 是不可逆的,所以通过密文反向出明文是基本不太可行的;想到的可行方式就是穷举所有号段手机号,将所有手机号 MD5 后看是否包含在对方提供的 2W 手机号里,如果包含在里面就找到了一个手机号。
方案一
程序穷举某个号段所有手机号内容,生成 13400000000 ~ 13499999999 所有手机号(可能某些手机号不存在,但是拿不到运营商真实手机号,只好穷举),每个号段 10的8次方
个号码;
1 | 13400000000 |
程序将 2W 手机号 MD5 内容装入 ArrayList
集合,对于每一个穷举的手机号码,首先生成 MD5 内容,然后判断生成的 MD5 内容在 ArrayList
集合中是否存在,存在则碰撞到一个手机号保存下来:
1 | // 2W MD5 手机号内容 |
执行结果,大概一天才能跑完一个号段数据。当时汇总了一下,运营商常用号段至少在 40 多个,整个都跑完大概需要 1 个多月时间,理论 & 实际都不可接受。
方案二
后来想到改用 HashMap
尝试一下,HashMap
提前将穷举的手机号和 MD5 内容一同写入某个文件,调整程序JVM堆内存大小,程序直接读取文件(单个文件大概 500M),将穷举的内容直接读入内存 HashMap
结构中,然后循环判断 2W MD5 内容在 HashMap
中是否存在,存在则碰撞到一个手机号保存下来
1 | // 文件内容 |
1 | // 2W MD5 手机号内容 |
执行结果,第一步生成所有 手机号MD5:手机号明文
内容,写入某个文件(几分钟搞定),然后通过程序进行碰撞真实手机号(几十秒完成),一通操作下来 10 分钟搞定一个号段,方案 OK。
这段时间分成两步操作,第一步找到所有运营商号段,每个号段生成一个文件内容;第二步单个文件开始进行处理,最终整个工作一天完成。
PS. 这期间先用一台普通 windows 机器(SSD硬盘)处理,生成 手机号MD5:手机号明文
内容写入文件中,写入速度不是很快,生成一个文件要十几分钟,后来改用自己的 MAC PRO 机器,写入文件速度明显加快,几分钟搞定一个文件,速度提升好几倍,所以说 工欲善其事,必先利其器
还是很有道理的。
源码分析
对于 ArrayList
的 boolean contains(Object o);
方法,内部是通过循环的方式逐一判断是否存在的,时间复杂度为 O(n):
1 | public boolean contains(Object o) { |
对于 HashMap
的 V get(Object key);
方法,内部是通过 hash 结构来存储和获取的,如果 hash 函数设计的非常优秀,理想情况下是可以达到 O(1)
的时间繁杂度的,当然这是理想情况,后面我们还会再分析实际时间复杂度:
1 | public V get(Object key) { |
这只是上面需求的一个实现过程,下面我们来具体分析一下 HashMap
中的一些关键内容的实现原理。
前面描述的是真实的工作场景,
HashMap
最终搞定了这个工作,如果有更好的实现方案,也欢迎大家留言讨论。
JDK版本
JDK 8
全局变量定义
1 | /** |
从全局变量的定义我们可以总结如下一下信息:
- JDK8 中 HashMap 初始容量和负载因子分别为 16 和 0.75;
- JDK8 中 HashMap 存储结构转换为
数组 + 链表 + 红黑树
,之前为数组 + 链表
; - 数组某个位置下链表长度 >= 8时,可能会执行树化操作,进化成
红黑树
存储结构; - 当 HashMap 执行扩容操作之后,数组某个位置下节点数量会变少,当 <= 6 个时,会从红黑树退化成链表;
TREEIFY_THRESHOLD
+MIN_TREEIFY_CAPACITY
两个值共同决定了链表是否执行树化操作。
构造函数初始化
1 | /** |
如果在初始化 HashMap
时指定了 initialCapacity
参数,由于 HashMap 要求 capacity
必须是 2的k次幂,因此构造函数调用 tableSizeFor
方法计算出 >= initialCapacity 的最小的 2的k次幂 数值(如果 initialCapacity 本身就是 2的k次幂,经过计算后还是原值)。
算法步骤:
int n = cap - 1;
首先执行减 1 操作是为了防止 cap 已经是 2的k次幂。如果 cap 已经是 2的k次幂, 又没有执行减 1 操作,则执行完后面的操作之后,返回的结果将是这个 cap 的 2
倍。
下面开始执行右移操作:
例如:n = 12时,二进制位:00001100
第一步,无符号右移 1 位:
n |= n >>> 1;
00001100 | (00001100 >>> 1) = 00001100 | 00000110 = 00001110
第二步,无符号右移 2 位:
n |= n >>> 2;
00001110 | (00001110 >>> 2) = 00001110 | 00000011 = 00001111
第三步,无符号右移 4 位:
n |= n >>> 4;
00001111 | (00001111 >>> 4) = 00001111 | 00000000 = 00001111
以此类推右移 8 位、16 位,在这里容量最大也就是 32bit 的正数,因此最后 n |= n >>> 16;
后,最多也就 31 个 1(第一位符号位,恒为 0),但是这时已经大于了 MAXIMUM_CAPACITY
,所以最终取值为 MAXIMUM_CAPACITY
。
之所以执行上面的一波无符号右移操作,就是为了让 initialCapacity
的值右移几次之后,二进制的值从某一位起的低位全部为连续 1,之前高位全部为 0,这样最后执行 n + 1
操作之后,结果一定是 2的k次幂 值,而且是 >=n 的最小的 2的k次幂 值。
hash 方法
1 | static final int hash(Object key) { |
在 hash 方法的实现中,首先计算 key 的 hashCode 值,然后将得到的 hashCode 值无符号右移 16 位,然后再进行 ^
操作,最终得到的结果为 key 的 hashCode。
我们知道对于 hash 这种数据结构来说,最重要的就是 散列函数(hash函数)
的设计,如果hash函数设计的比较好,可以减少元素之间的碰撞概率,使数据分布更加均匀,提高 get 和 put 方法的执行效率。
对于JDK中散列函数的设计基本思想是:让 hashCode 中高 16bit 位也参与到 hash 函数计算中
。
在 HashMap
中,容量值为 2的k次幂,而计算元素下标的时候,是这样实现的:
(n - 1) & hash (实际含义为 hash % n,后面会解释为什么)
如果容量值比较小的时候,不进行 移位异或
操作的话 hashCode 的高位是无法参与到 hash 函数运算中的,比如
当 n = 16 时,如下三个 key 的 hashCode:
1 | 0101 0101 0101 0010 1010 1010 1010 1001 |
这三个key的hashCode完全不同,但是如果计算三个 key 在数组中的位置的话,结果是相同的:
(n - 1) & hash
结果都是:9
,所以虽然是三个不同 key 却发生了碰撞,如果将高位 移位异或
之后再计算结果,分别是
11
7
9
因为当容量比较小的时候,hashCode 只有低位才会参与到运算中,所以容易发生碰撞,所以在 右移异或
之后让 hashCode 中高 16bit 也参与到 hash 运算中,降低碰撞出现的概率。
Computes key.hashCode() and spreads (XORs) higher bits of hash to lower. Because the table uses power-of-two masking, sets of hashes that vary only in bits above the current mask will always collide. (Among known examples are sets of Float keys holding consecutive whole numbers in small tables.) So we apply a transform that spreads the impact of higher bits downward. There is a tradeoff between speed, utility, and quality of bit-spreading. Because many common sets of hashes are already reasonably distributed (so don’t benefit from spreading), and because we use trees to handle large sets of collisions in bins, we just XOR some shifted bits in the cheapest possible way to reduce systematic lossage, as well as to incorporate impact of the highest bits that would otherwise never be used in index calculations because of table bounds.
在 hash 方法的注释中作者也解释了,这个实现是充分考虑了 speed(速度), utility(作用), and quality(质量)
之后的结果,而且 现在大部分的 hash 函数实现使数据分布已经很均匀了,而且在发生碰撞之后也优化成了树型结构,仅仅进行了一次异或操作,既没有引起系统很大的开销,也降低了因为高位没有参加运算而导致的碰撞情况出现。
index 方法
1 | (n - 1) & hash |
HashMap
中没有 index 方法,之所以这么称呼,是因为这一行代码,就是确定某个 key 在 table 数组中的位置,所以姑且称作 index 方法。
其实,这行代码的含义是 hash % n
的结果,之所以使用 &
操作而不是 %
的方式,是因为 相对于 &
操作,%
是一个比较耗时的操作,而 &
是位操作,速度非常快。
但是,(n - 1) & hash == hash % n
是如何成立的?
这里举个例子,例如:
9 % 4 = 1,9 的二进制是 1001,4 - 1 = 3,3 的二进制是 0011。 9 & 3 = 1001 & 0011 = 0001 = 1;
12 % 8 = 4,12 的二进制是 1100,8 - 1 = 7,7的二进制是 0111。12 & 7 = 1100 & 0111 = 0100 = 4;
上面两个例子 4 和 8 都是 2的k次幂,结论是成立的,当长度不为 2的k次幂 时:
比如:9 % 5 = 4,9的二进制是 1001,5 - 1 = 4,4的二进制是0100。9 & 4 = 1001 & 0100 = 0000 = 0,显然是不成立的。
结论:
结论:当 n = 2的k次幂 时,x % n = x & (n - 1)
具体证明过程可以参考:由 HashMap 哈希算法引出的求余 % 和与运算 & 转换问题
put 方法
1 | public V put(K key, V value) { |
通过阅读源代码,put 方法的实现思路主要是:
- 判断 table 是否为 null,如果为空调用 resize() 方法进行初始化;
- 计算新增元素在 table 中位置
tab[i = (n - 1) & hash]
,判断是否发生碰撞; - 如果没有碰撞直接放到 table 中该索引位置;
- 如果发生碰撞,判断 table 后链接的是什么数据类型;
- 如果是 TreeNode 类型,直接插入红黑树中;
- 如果是 LinkedList 类型,直接
头插法
插入链表头部; - 插入后判断链表长度是不是大于树化阈值(TREEIFY_THRESHOLD),如果 >= TREEIFY_THRESHOLD,链表进化成红黑树;
- 在插入过程都要判断元素是否已经存在
p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))
; - 如果元素存在根据
onlyIfAbsent
参数判断是否覆盖旧值(HashMap 直接覆盖); - 最后判断元素数量是否超过阈值,超过阈值需要进行 resize() 操作。
resize 方法
1 | final Node<K,V>[] resize() { |
当执行 put
操作时,如果目前的 table
数组的使用程度已经超过 loadFactor
的比例(默认 75%
),就会调用 resize()
方法执行扩容操作,HashMap
扩容操作是将 table
扩容为原来的 2
倍,之后重排列元素,这里面的重新排列元素是有技巧的,resize()
方法的注释大致意思是:首先要扩容为原来的 2
倍,扩容之后,由于是扩容为原来的 2
倍,元素的位置或者是在原来的位置,或者是在新 table
中偏移 2
次幂的位置。
Otherwise, because we are using power-of-two expansion, the elements from each bin must either stay at same index, or move with a power of two offset in the new table.
我们在扩容 HashMap 的时候,不需要重新计算 hash,只需要观察一下原来的 hash 值新增的那个 bit 是 1 还是 0 就好了,是 0 的话索引没变,是 1 的话索引变成 原索引 + oldCap
。以下图为例,如果 n = 16,扩容后 n = 32:
get 方法
1 |
|
get 方法的实现思路如下:
- 根据 hashCode 计算元素在 table 中位置
tab[(n - 1) & hash]
,如果第一个元素命中,直接返回; - 如果发生碰撞,则根据结点类型继续向下查找;
- 如果是
TreeNode
类型,在红黑树中查找,时间复杂度O(logn)
; - 如果是
LinkedList
类型,循环查找,时间复杂度O(n)
。
写在最后
通过分析 HashMap
内部的实现细节,我们再返回头来看开篇的案例,对于 ArrayList
类,contains
方法的时间复杂度是 0(n)
,而 HashMap
的时间复杂度是 O(1) + O(logn)
或者是 O(1) + O(n)
的,但是 HashMap
的执行效率要高出很多,这是因为在 HashMap
中的 O(logn)
或者是 O(n)
的 n 是发生碰撞的元素数量,并不是我们实际计算的元素数量,或者写作 O(1) + O(logk) 或者 O(1) + O(k) k 为碰撞元素个数
更合适,如果 hash 函数设计的非常合理,数据分布的非常均匀,发生碰撞的概率比较小,k 值相对于计算元素 n 应该是小很多的,所以执行的效率会高很多。
参考文章
- Java HashMap工作原理及实现
- HashMap源码注解 之 静态工具方法hash()、tableSizeFor()(四)
- 由HashMap哈希算法引出的求余%和与运算&转换问题
- 深入解读HashMap线程安全性问题
- 为什么 HashMap 的加载因子是0.75?