深挖 HashMap

1.1 前言

做过 java 开发的朋友们相信都很熟悉 HashMap 这个类,它是一个基于 hashing 原理用于存储 Key-Value 键值对的集合,其中的每一个键也叫做 Entry,这些键分别存储在一个数组当中,系统会根据 hash 方法来计算出 Key-Value 的存储位置,可以通过 key 快速存取 value。
HashMap 基于 hashing 原理,当我们将一个键值对(Key-Value) 传入 put 方法时,它将调用这个 key 的 hashcode 方法计算出 key 的 hashcode 值,然后根据这个 hashcode 值来定位其存放数组的位置来存储对象(HashMap 使用链表来解决碰撞问题,当其发生碰撞了,对象将会存储在链表的下一个节点中,在链表的每个节点中存储 Entry 对象,在 JDK 1.8+ 中,当链表的节点个数超过一定值时会转为红黑树来进行存储),当通过 get 方法传入一个 key 来获取其对应的值时,也是先通过 key 的 hashcode 方法来定位其存储在数组的位置,然后通过键对象的 eqauls 方法找到对应的 value 值。接下来让我们看看其内部的一些实现细节。(PS:以下代码分析都是基于 JDK 1.8

1.2 为什么容量始终是 2 的整数次幂

因为获取 key 在数组中对应的下标是通过 key 的哈希值与数组的长度减一进行与运算来确定的(tab[(n - 1) & hash])。当数组的长度 n 为 2 的整数次幂,这样进行 n - 1 运算后,之前为 1 的位后面全是 1 ,这样就能保证 (n - 1) & hash 后相应位的值既可能是 1 又可能是 0 ,这完全取决于 key 的哈希值,这样就能保证散列的均匀,同时与运算(位运算)效率高。如果数组的长度 n 不是 2 的整数次幂,会造成更多的 hash 冲突。HashMap 提供了如下四个重载的构造方法来满足不同的使用场景:

  1. 无参构造:HashMap(),使用该方法表示全部使用 HashMap 的默认配置参数
  2. 指定容量初始值构造:HashMap(int initialCapacity),在初始化 HashMap 时指定其容量大小
  3. 指定容量初始值和扩容因子构造:HashMap(int initialCapacity, float loadFactor),使用自定义初始化容量和扩容因子
  4. 通过 Map 来构造 HashMap:HashMap(Map<? extends K, ? extends V> m),使用默认的扩容因子,其容量大小有传入的 Map 大小来决定

前三个构造方法最终都是调用第三个即自定义容量初始值和扩容因子构造 HashMap(int initialCapacity, float loadFactor),其源码实现如下

public HashMap(int initialCapacity, float loadFactor) {
    if (initialCapacity < 0)
        throw new IllegalArgumentException("Illegal initial capacity: " +
                                            initialCapacity);
    if (initialCapacity > MAXIMUM_CAPACITY)
        initialCapacity = MAXIMUM_CAPACITY;
    if (loadFactor <= 0 || Float.isNaN(loadFactor))
        throw new IllegalArgumentException("Illegal load factor: " +
                                            loadFactor);
    this.loadFactor = loadFactor;
    this.threshold = tableSizeFor(initialCapacity);
}

从源码实现可以看出,如果我们传入的初始容量值大于 MAXIMUM_CAPACITY 时,就设置容量为 MAXIMUM_CAPACITY,其值如下:

/**
  * The maximum capacity, used if a higher value is implicitly specified
  * by either of the constructors with arguments.
  * MUST be a power of two <= 1<<30.
  */
static final int MAXIMUM_CAPACITY = 1 << 30;

也就是容量的最大值为 2 的 30 次方(1 << 30)。我们知道,HashMap 的容量始终是 2 的整数次幂,不管我们传入的初始容量是什么,它都会使用最接近这个值并且是 2 的整数次幂作为 HashMap 的初始容量,这一步处理是通过 tableSizeFor 方法来实现的,我们看看它的源码:

通过方法的注释我们也可以知道(英语对于从事技术开发的人太重要了~~~),此方法的返回值始终是 2 的整数次幂,它是如何做到的呢?接下来我们通过一个例子一步一步来看,假设我们传入的初始容量大小 cap 的值 cap 为 15。

第 ① 步:将 cap - 1 后,n 的值为 14(15 - 1)。

第 ② 步:将 n 的值先右移 1 位后与 n 进行 或运算(两者都为 0 结果为 0,其它情况都为 1),下面是具体的计算过程:

第 ③ 步:将 n 的值先右移 2 位后与 n 进行 或运算(两者都为 0 结果为 0,其它情况都为 1),下面是具体的计算过程:

第 ④ 步:将 n 的值先右移 4 位后与 n 进行 或运算(两者都为 0 结果为 0,其它情况都为 1),下面是具体的计算过程:

第 ⑤ 步:将 n 的值先右移 8 位后与 n 进行 或运算(两者都为 0 结果为 0,其它情况都为 1),下面是具体的计算过程:

第 ⑥ 步:将 n 的值先右移 16 位后与 n 进行 或运算(两者都为 0 结果为 0,其它情况都为 1),下面是具体的计算过程:

最后如果 n 的值小于 0,则返回 1,如果大于最大值 MAXIMUM_CAPACITY 则返回 MAXIMUM_CAPACITY,否则返回 n + 1。 现在 n 为 15,所以返回 n + 1(16),而 16 正好是 2 的 4 次幂。有的朋友可能会问,刚刚上文假设的初始容量大小 cap 是 15,本来就不是 2 的整数次幂,如果我传入初始容量的就是 2 的整数次幂那会怎么样呢?现在假设传的初始容量大小为 32(2 的 5 次方)看看结果是什么。

第 ① 步:将 cap - 1 后,n 的值为 31(32 - 1)。

第 ② 步:将 n 的值先右移 1 位后与 n 进行 或运算(两者都为 0 结果为 0,其它情况都为 1),下面是具体的计算过程:

第 ③ 步:将 n 的值先右移 2 位后与 n 进行 或运算(两者都为 0 结果为 0,其它情况都为 1),下面是具体的计算过程:

第 ④ 步:将 n 的值先右移 4 位后与 n 进行 或运算(两者都为 0 结果为 0,其它情况都为 1),下面是具体的计算过程:

第 ⑤ 步:将 n 的值先右移 8 位后与 n 进行 或运算(两者都为 0 结果为 0,其它情况都为 1),下面是具体的计算过程:

第 ⑥ 步:将 n 的值先右移 16 位后与 n 进行 或运算(两者都为 0 结果为 0,其它情况都为 1),下面是具体的计算过程:

经过以上 6 步计算后得出 n 的值为 31,大于 0 小于 MAXIMUM_CAPACITY 返回 n + 1,所以经过计算后的初始容量大小为 32。稍微总结一下,我们可以得出:如果我们传入的初始容量大小不是 2 的整数次幂,那么经过计算后的初始容量大小为大于我们传入初始容量值的最小值并且是 2 的整数次幂。细心的朋友会发现,为什么第一步要进行 cap - 1 的操作呢?那是因为,如果不进行 - 1 运算的话,当我们传入的初始容量大小为 2 的整数次幂的时候,通过以上步骤计算出来的结果值为传入值的 2 倍。假设我们传入的初始容量大小为 32,此时没有第 ① 步(cap - 1)的操作,那么依次通过以上 ②、③、④、⑤、⑥ 后为 63,最后再进行 n + 1 操作,结果为 64 是 传入值 32 的 2 倍,显然和预期结果(32)不符。这个计算初始容量的算法还是很巧妙的,先进行了 -1 的操作,保证传入初始容量值为 2 的整数次幂的时候,返回传入的原始值。

1.3 hash 方法是如何实现的

不管是通过 get 方法获取 key 对应的 Value 值或者通过 put 方法存储 Key-Value 键值对时,都会先根据 key 的哈希值定位到数组的位置,我们看看 HashMap 里的 hash 方法是如何实现的,源码如下:

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

当 key 为 null 时,返回 0,否则进行 h = key.hashCode()) ^ (h >>> 16 运算,先调用 key 的 hashCode 方法获取 key 的哈希值,然后与 key 的哈希值右移 16 位后的值进行异或运算(相同为 0,不同为 1,简称 同假异真),为什么获取 key 的哈希值还要再进行异或运算,直接返回 key 的哈希值好像也没什么问题,如果没有后面的异或运算,直接返回哈希值,我们假设数组的长度为 16,现在要往 HashMap 存入的三个键值对的 key 的哈希值分别为 32831、33554495、2097215,根据 hash 方法返回值定位到数组的位置((n - 1) & hash),以上三个值和 15(16 - 1)进行 & 运算(都为 1 才为 1,其它情况都为 0) 如下:

可以发现以上三个哈希值都定位的数组下标为 15 的位置上。所以 hash 如果方法没有后面与哈希值右移 16 位后的值进行异或运算的话,当数组长度比较小时很容易造成 哈希碰撞,即多个 key(不同的哈希值)都会定位到数组上的同一个位置,也就是说会放入到同一个链表或者红黑树中,因为此时 key 的哈希值只有低位的才会参与运算,显然和我们的预期不符合。可见 hash 方法将 key 的哈希值与其右移 16 位后进行异或运算能减少哈希碰撞的次数,把高位和低位都参与了运算,提高了分散性。

1.4 总结

HashMap 其实还有很多值得我们深入研究的点,看懂了上面两个方法后,不得不佩服作者的代码设计能力,JDK 中有很多优秀源码都值得我们好好品味,看代码的时候一定要多看几遍多问几个为什么,特别是经典的源代码,然后将这些思想运用到我们的实际工作中。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,425评论 4 361
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,058评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,186评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,848评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,249评论 3 286
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,554评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,830评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,536评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,239评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,505评论 2 244
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,004评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,346评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,999评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,060评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,821评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,574评论 2 271
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,480评论 2 267

推荐阅读更多精彩内容