分享 HashMap 的精髓,它永远比你自己写 map 的效率高

HashMap是面试时几乎必问的数据结构,也是Java中非常常用的一个数据结构,所以了解它的原理是对面试有极大的帮助,也是自身非常好的提升。
(大佬就不要来吹毛求疵了。。)

为什么要使用hash表

hash结构是为了查询效率而诞生的,是使查询速度最快化的结构,时间复杂度为O(1),真正达到了瞬间查找的目的。
(这个道理可能人人都懂,但是吧,你写的 HashMap 可能真的没法跟 jdk 源码里的 HashMap 比)

hash表如何达到高效的存储及查找效率

散列表(Hash table,也叫哈希表),是根据关键码值(Key value)而直接进行访问的数据结构。也就是说,它通过把关键码值映射到表中一个位置来访问记录,以加快查找的速度。这个映射函数叫做散列函数,存放记录的数组叫做散列表。
给定表M,存在函数f(key),对任意给定的关键字值key,代入函数后若能得到包含该关键字的记录在表中的地址,则称表M为哈希(Hash)表,函数f(key)为哈希(Hash) 函数。

就像这样:
有一堆 key,value 成双成对在一张表里的各个位置

keykeykeykeykeykey
valuevaluevaluevaluevaluevalue

比如我们要找 Mike,和他的老婆。就要先找到他的位置
function(Mike.hashCode()) = 2
我们通过一个哈希函数计算出 Mike 的位置在2
然后到表中一看,就找到了 Mike 的老婆是 Therese

01234n
TonynullMikeXiaoMingnull
XiaoHongnullThereseDarianull

存放键值对,删除也是同样的道理
只需要一个函数进行计算,就可以知道键值对在表中的位置。

HashMap中采用何种哈希函数

乍一看 HashMap 的源码似乎很复杂,然而实际上,哈希函数只是
取余(取模)!!!
就比如 7%5=2,4%3=1,8%4=0
虽然看起来源码好像不是这么写的,但实际上就是这个作用。

  • 由于在计算机语言中,数字使用补码表示的,而对数值进行取模时,考虑到了效率,采用了位运算,没有将数字转换为原码再进行取模。
  • 正数的原码与补码相同,因此取模结果也相同。
  • 而负数补码与原码不同,取模是对补码进行的,所以会与传统取模结果不符。

(如果你还不会再日常代码中写位运算,那你可能就 out 了)

HashMap中对hashCode()值做了什么处理

  • 众所周知,一个对象的 hashCode() 值可能是一串很长很大的数字,就是 int 数字。
  • int 数字在二进制一共有32位,所以有2的32次方个取值。
  • 而我们平时使用的 HashMap 中的数组容量可能很小,可能只有几十,因此我们取模时,只需要用到 hashCode() 值的最后几位,而前面高位数的数字特征就都被浪费掉了,由于少了一部分数字特征,所以出现数据集中的可能性会变大。
  • 比如:
    10100010110100100101111111001000
    01010100101000100101111111001000
    101001011111010101101111111001000
    这些数字虽然整体差异大,但后很多位都没有差异,在平时只对小的数字取模,所以得到的位置都会相同,则会产生碰撞。
  • 因此,HashMap 中用了一个方法,对对象的 hashCode() 值做了前后16位的值做异或操作。这样,得到的结果的后16位中也保留了前16位的特征,因此能获得更好的离散型程度。
static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
  • 我们再对上面第一个数字操作一下
    10100010110100100101111111001000 这是原数字
    00000000000000001010001011010010 这是右移16位的数字
    10100010110100101111110100011010 异或操作后的数字
    这样融合了前后16位的数字可以拥有更好地离散程度
  • 这样在进行取模操作时,产生冲突碰撞的可能性就降低了。

(据说 HashMap 用这个方法将碰撞概率降低了0.2)
(同时也是防止程序猿写的 hashCode 太差劲了,于是再平均一下)

HashMap中是如何保证哈希函数计算地址高效的

  • 前面提到了哈希函数是采用了取模运算
  • 但是不是使用的 h%n 的方式
  • 在 jdk 源码中,是使用了 h & (n - 1) 来计算出桶位置
  • 有人可能要提出疑问了,这明显不是取模操作。
  • 这个问题先放一边,我们了解一下这个函数的计算过程,我们很快便能明白了。
  • 我们知道 & 运算,对于对应位数上的两个数字:
    0 & 0 = 0,0 & 1 = 0,1 & 1 = 1。
  • 当长度为16时,16 - 1 的二进制表示就是
    00000000000000000000000000001111
  • 和 hash 后的值进行 & 运算,则最后的结果一定是
    0000000000000000000000000000xxxx
    最后4位就是原 hash 值的后4位
  • 可万一 n - 1 的二进制数字不是最后几位都是1,比如:
    000000000000000000000000 11010100
    000000000000000000000000 10101111
    这样进行位运算之后的结果就不一定是原值的最后几位
    这就牵扯到 HashMap 中数组的容量大小
    (我就当给不知道位运算的补课了)

HashMap的默认初始容量,以及其他情况下的容量

我们知道,HashMap 的默认初始容量为16
可为什么是16呢
仔细地翻出 jdk 源码一看

static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
  • 哦,1 << 4 = 16
  • 我们发现,源码中用到了位运算,不管左移多少位,都是2的次方数。
    在扩容时也是如此,用了左移操作。
    ** 什么,还有扩容?我们等会再说。
  • 但是,默认是16,在乘2的情况下可以永远保证是2的次方数
    如果我们初始值给他赋值3,那这样,容量岂不是永远有个因数3?
  • 这就要引出我们下一个方法,对初始容量做一次计算,不使用原容量,而是使用计算过后的值作为容量。
  • 我们来慢慢品尝源码
static final int tableSizeFor(int cap) {
    int n = cap - 1;
    n |= n >>> 1;
    n |= n >>> 2;
    n |= n >>> 4;
    n |= n >>> 8;
    n |= n >>> 16;
    return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
  • 首先,先对原 cap - 1。(我们先不管 T_T)
  • 先看第二行,有两种运算符,>>> 和 | 。
  • 运算符 >>>:无符号位右移,指将二进制数值不管第一位符号位,所有的位上的数值向右移动。
  • 比如:16 和 -16 右移四位
  • 数 字 16: 00000000000000000000000000010000
    右移四位:00000000000000000000000000000001
    得到结果:1。
  • 数字 -16: 10000000000000000000000000010000
    右移四位:00001000000000000000000000000001
    得到结果:2的27次方+1
  • 由于不理会符号位,所以符号位1右移之后首位一定变为0,则负数会因此变为正数。
  • 接下来我们再看 | 运算符,它表示二进制数上对应位的两个数值,如果有一个为1,则为1,否则为0。
  • 1 | 1 = 1,0 | 1 = 1,1 | 0 = 1,0 | 0 = 0。
  • a |= b 是 a = a | b 的简写
  • 现在我们很容易理解代码的第二行
  • 首先有这么一个正数,不管它是多少,它的二进制第一位一定为1。
    00001xxxxxxxxxxxxxxxxxxxxxxxxxxx,对它右移1位,然后和原数异或。
    000001xxxxxxxxxxxxxxxxxxxxxxxxxx
    000011xxxxxxxxxxxxxxxxxxxxxxxxxx,我们可以发现,前两位一定是1。
  • 这样,在对它右移2位异或,则前4位一定为1
    然后4位,8位,16位,就可以保证首位以后的所有位一定都是1。
    最后加上1就是容量了。
  • 这样就保证了容量永远是2的此方数
  • 但是如果本身输入参数已经是2的此方数了呢
    这样在计算过后会变成本身的2倍
    所以在开头要 - 1,先减小,然后执行变大为2的次方数。
  • 这样,在满足了容量一直为2的次方数时,在进行 h & (n - 1) 时,才能得到我们想要的结果。

HashMap中是如何处理冲突的

只不过在用哈希函数计算位置时,有可能出现两个不同的键得出同一个数值的情况,这样两对人就要共同挤在一个小房间里。但一个房间肯定是不能装两对夫妻的,因此,还需要有方法来处理冲突。


  • 在每个桶有超过1个数据时,将其作为链表中的结点插入
  • 在 jdk1.8 往后,在链表长度大于等于8时,会转化为红黑树结构
    (可能有些人还没听说过红黑树。。。)
    在这里插入图片描述
  • 在 jdk1.7 及以前,每一个结点类叫 Entry,jdk1.8 以后将其改为 Node。
  • 在 jdk1.8 以前都是采用头插法,因为他们认为,后插入的数据更有可能查找的频率更高,因此插入在头部,可以提高查询效率。
  • 而在 jdk1.8 以后则改为了尾插法,结点都放置在链表的尾部。因此在1.8版本以后,HashMap 不易出现环形链表。

扩容操作

  • 当数组的容量大时,且插入的数据很少,则产生碰撞的概率很小
  • 但随着插入数据的不断增多,越来越多的键值对不停地拥挤在这一块狭小的数组之中,则会经常产生碰撞,导致每个桶中链表变长,这样在对 Map 进行操作时效率会受到很大的影响。
  • 所以我们需要对数组进行扩容,可以让数据分布更均匀,尽量减少碰撞。
  • 在 HashMap 中,对数组扩容的判定条件则是在 put 进入数据后,如果存放数据的数量大于了 threshold(也就是 length * loadFactory),就会执行扩容操作。
  • 扩容时会新建一个数组,容量为原数组的两倍,然后将原数组中的对象放入新数组中。
  • 在移动数据时,会查看数据的 hash 值,如果对于新的长度,多出的那一位要做 & 运算的数字为0,则在数组中的位置就是原位置,如果是1,那在数组中的位置就是原数组位置加上原数组长度。

初始化操作

  • 为了不占用资源,HashMap 中的数组不是在 HashMap 建立时变创建,只有等到真正存储数据时,才会创建数组。
  • 在 jdk1.7 中,初始化有一个专门的方法。
  • 不过到了 jdk1.8 以后,初始化就被整合进了 resize() 方法中。
  • 但原理至少是不变的,都是等到要存储数据时才初始化数组。

(懒加载?是不是不提醒你也想不到)

树化操作

  • jdk1.8 的很大的一个特性就是增加了红黑树结构。
  • 如果存在树结构,当树的节点数降低到6时,会重新转化为链表
  • 首先,要存在树结构,先要保证的是数组的容量达到64
    否则数组较小时,则会优先扩容,而不是选择转化为复杂的红黑树
  • 而链表转化为红黑树的第二条件则是一条链表上节点数达到8
  • jdk源码中的注释是这么写的

Because TreeNodes are about twice the size of regular nodes, we
use them only when bins contain enough nodes to warrant use
(see TREEIFY_THRESHOLD). And when they become too small (due to
removal or resizing) they are converted back to plain bins. In
usages with well-distributed user hashCodes, tree bins are
rarely used. Ideally, under random hashCodes, the frequency of
nodes in bins follows a Poisson distribution
(http://en.wikipedia.org/wiki/Poisson_distribution) with a
parameter of about 0.5 on average for the default resizing
threshold of 0.75, although with a large variance because of
resizing granularity. Ignoring variance, the expected
occurrences of list size k are (exp(-0.5) * pow(0.5, k) /
factorial(k)). The first values are:
0: 0.60653066
1: 0.30326533
2: 0.07581633
3: 0.01263606
4: 0.00157952
5: 0.00015795
6: 0.00001316
7: 0.00000094
8: 0.00000006
more: less than 1 in ten million

翻译过来就是

因为TreeNodes的大小大约是常规节点的两倍,只有当容器包含足够的节点以保证使用时才使用它们。
当它们变得太小时(由于移除或调整大小)它们被转换回普通的结点。
在使用分布良好的用户哈希码、树结点很少使用。
理想情况下,在随机哈希码下容器中的节点遵循泊松分布带有默认大小调整的参数平均约为0.5阈值为0.75,但由于调整粒度。忽略方差,期望的列表大小k的出现次数为(exp(-0.5)*pow(0.5,k)/阶乘(k)。第一个值是:
0: 0.60653066
1: 0.30326533
2: 0.07581633
3: 0.01263606
4: 0.00157952
5: 0.00015795
6: 0.00001316
7: 0.00000094
8: 0.00000006
更多:不到千万分之一

  • 所以实际上,jdk 设计者本身是不希望用到树节点的。
  • 在统计学原理下,进行精确计算得出:
    在 hashCode() 离散性良好的情况下,节点数达到8及以上的概率已经不足千万分之1,几乎是不可能的。
  • 但是由于不能保证每个程序员给出的 hashCode() 方法都具有良好的离散性。
    当遇到不够优秀的 hashCode() 方法时,可能会出现大量碰撞的情况,从而导致性能下降。
    而此时将链表转化为红黑树,则可以一定程度上提升性能。
    (说白了,就是避免不知名的程序猿写的 hashCode 太烂,用红黑树拉扯一下)

总结:
HashMap 毕竟也是 jdk 很基础的源码之一,里面涉及到了很多知识点可供学习。
比如位运算,重哈希,转红黑树,链表头插改尾插…
这些细小的点虽然编码不难,但是能想到这样的细微修改,就能使性能因此而提高,这是很高的一种修为。
我们不仅仅是去学习里面的编码,更是去学习这种思维,能在实际情况中敏捷思考,写出高效,高质量的代码。

文章到这里就结束啦,我还是大学生哦,喜欢学习的小伙伴可以评论交流,或者加关注,一起学习更轻松。
素质三连!

  • 24
    点赞
  • 89
    收藏
    觉得还不错? 一键收藏
  • 3
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值