HashMap 实现细节点整理

提及 HashMap,大家都耳熟能详了,本文不会再讲它的实现原理,只对其中的一些小的实现细节进行罗列。

hashmap

首先先要明白的两点:

  • 图中的 table 是一个大小为 2n的一维数组,其中存放的是一个个的 HashMapEntry,而 HashMapEntry 是包含了 hash、key 与 value 值及一个指向 HashMapEntry 的 next 指针。

  • HashMapEntry 在 object 数组中的获取及存放是根据 HashMapEntry 结构的中的 hash 与当前的 table 数组大小一减去已进行与操作的结果来作为相应的索引值( index = (table.length -1 ) & hash)。若在存放的过程中,index 值相同,则会链接当前 entry 的 next 指针上。

只允许放置一个 key 为 null 的元素

知道 HashMap 中允许存放 key 为 null 的元素,纠其原理是因为其中有一个 entryForNullKey 的属性,专门来存放 null 相应的 value 值,再进行 putremove方法的时候,都会对 key 进行判断,若为 null,则会只进行 entryForNullKey相应的更新操作。

/**
 * The entry representing the null key, or null if there's no such mapping.
 */
transient HashMapEntry<K, V> entryForNullKey;

容量获取算法 roundUpToPowerOfTwo

我们在初始化 HashMap 的时候,可以通过 new HashMap(int capacity)来构造新的 HashMap,而这个参数的值是可以任意填写的,没做任何规范及限制的。(PS:最好根据要存放元素的数量来填写)
但是在 HashMap 的实现当中,而是以大于等于 capacity 并且是 2 的幂数的整数来作为一个新的容量值。修正的算法是通过调用 Collections 类中的 roundUpToPowerOfTwo 方法:

/**
* Returns the smallest power of two >= its argument, with several caveats:
* If the argument is negative but not Integer.MIN_VALUE, the method returns
* zero. If the argument is > 2^30 or equal to Integer.MIN_VALUE, the method
* returns Integer.MIN_VALUE. If the argument is zero, the method returns
* zero.
* @hide
*/
public static int roundUpToPowerOfTwo(int i) {
  i--; // If input is a power of two, shift its high-order bit right.

  // "Smear" the high-order bit all the way to the right.
  i |= i >>>  1;
  i |= i >>>  2;
  i |= i >>>  4;
  i |= i >>>  8;
  i |= i >>> 16;

  return i + 1;
} 

可以看到这个算法进行了 5 次移位操作,乍一看,一脸懵逼,这是在干嘛?

细细一想啊,我现在要获取一个是 2 的幂数的整数,那其二进制的表现形式就是其最高位为1 ,低位全部为 0;或者其低位全部为 1,只需再对其加 1,即可变成 2 的倍数。

举个栗子:

1000 0000 
0100 0000  // 无符号右移一位
1100 0000  // 上面两个执行或操作的结果

1100 0000
0011 0000 // 无符号右移二位
1111 0000 // 上面两个执行或操作的结果

1111 0000
0000 1111 // 无符号右移三位
1111 1111 // 上面两个执行或操作的结果

其实我们只需将我们的关注点放置其元素为1的最高位上,执行右移操作,紧接着是或操作,最后的结果就是将高位的1,向后涂抹 (smear),全部变为1。

移位5次的原因在于 Java 中的整数是32位的,正好是2的 5次方。

算法刚开始的减一操作,则是为了防止刚开始传入的数字便是 2 的幂数。用来保证最终的结果是大于等于传入的参数的值。

扩容的临界值

HashMap 能够放置元素的最小容量为 4,最大容量为 1 << 30,并且每次扩容之后的容量大小都是 2 的幂数。
什么会扩容呢?在每次调用 HashMap 的 put 方法时,在进行 size++ 时,会与 threshold 进行比较,当超过 threshold时,就会自动扩容。而这个 threshold 的取值为容量的 3/4 ,其值的更新之时,是在一维数组 table 根据乘以2的容量,申请空间之时,即 makeTable 方法:

/**
* Allocate a table of the given capacity and set the threshold accordingly.
* @param newCapacity must be a power of two
*/
private HashMapEntry<K, V>[] makeTable(int newCapacity) {
  @SuppressWarnings("unchecked") HashMapEntry<K, V>[] newTable
          = (HashMapEntry<K, V>[]) new HashMapEntry[newCapacity];
  table = newTable;
  threshold = (newCapacity >> 1) + (newCapacity >> 2); // 3/4 capacity
  return newTable;
}

这里主要研究一下这个获取容量为 3/4 的算法,将 newCapacity 右移1位,即获取的是 1/2;右移2位,即获取 1/4;另外再加上 newCapacity 为2的倍数,所以这里的 3/4 毫无破绽。

扩容 * 2实现中的元素转移

假如我当前 HashMap 中的容量(capacity)为8,在执行元素的获取及存放是,都是先拿元素的 hash 值,跟 capacity - 1 即 7,对应的二进制为 111,执行与操作,获取的结果即为对应所存放数组中的索引。

而当 HashMap 在进行扩容 * 2 之后,元素的获取也要满足上面的方法,那在扩容的时候,就要先前数组到新数组的赋值,那它这个重新索引的算法是怎么进行的呢?

在执行 doubleCapacity方法时候,会进行这个操作:

for (int j = 0; j < oldCapacity; j++) {
   // 获取不为空的元素
   HashMapEntry<K, V> e = oldTable[j];
   if (e == null) {
       continue;
   }
   int highBit = e.hash & oldCapacity;
   newTable[j | highBit] = e;

    /**省略 **/
}

oldCapacity 为2的幂数,获取到的 highBit 也是 2 的幂数。若是 e.hash 对应在 oldCapacity 的高位也是相同的 1,则 newTable的索引 j | highBit则是相当于 j 加上了 2 的幂数,即 j | highBit 相对于 newCapacity 来说,从前半段移动到后半段;若是 e.hash 对应在 oldCapacity 的高位不为1,则获取到 highBit 为零,则相应旧数组迁移至新数组的索引位置还是维持原样。

简化上面的公式:

公式一: oldIndex = ( 2n -1) | hash
公式二: hash & 2n | oldIndex = newIndex

可知新的 newIndex 同时也满足:

公式三: newIndex = (2n+1 - 1) | hash

若是我们将公式一和公式三都代入公式二中,消除 oldIndex 和 newIndex 可得到:

公式四: hash & (2n -1) | (hash & 2n) = hash & ( 2n+1 - 1)

这里发现这个公式,很是符合结合律,即:

( a & x ) | ( a & y ) = a & ( x | y )

不要问我怎么证明这个公式是对的。(我是问了个妹子,妹子直接告诉不要纠结公式,直接读字面量,即与或操作,就可发现这个公式是正确的。)

这样的话,将上面的公式执行结合律的话,可得公式四的左边:

hash & ((2n -1) | 2n ) = hash & ( 2n+1 - 1)

确定是跟公式四的右半部分相等。至此,可证明这个扩容移位的操作是毫无破绽的,不过一时半会理解起来还是颇为费劲的,也不得不感叹作者的强大。

Hash 值的生成

在进行元素的存放及获取时,都会获取元素的 hash 值,(即是 key 的 hash值),而一个好的 HashMap,应该是元素均匀地放置在数组中,而不应该大量地出现簇拥现象,(即对应的 HashMapEntry 的 next 指针不为空)。所以仅仅依靠原始的 key 的 hash 值,则是不靠谱的。所以 HashMap 的算法都会进行二次 hash,但我发现 java 中的版本 跟 android 版本实现完全不一致:

  • Android的实现(以 Android API 23 为例)
    这里真正的实现是在 Collections 类中:
private static int secondaryHash(int h) {
  // Spread bits to regularize both segment and index locations,
  // using variant of single-word Wang/Jenkins hash.
  h += (h <<  15) ^ 0xffffcd7d;
  h ^= (h >>> 10);
  h += (h <<   3);
  h ^= (h >>>  6);
  h += (h <<   2) + (h << 14);
  return h ^ (h >>> 16);
}

这个算法看的是一脸懵逼,若是有哪位大神看懂了,希望能够指点一下小弟。这里可去参看注释中提到的 Wang/Jenkins hash,这里是借鉴这位大神的实现。算法最终达到的效果就是一个网友提到的“崩坏性”,即微小的改动,也会触发结果的大变样。

  • Java的实现 (以 JDK 1.8 为例)
static final int hash(Object key) {
  int h;
  return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

明显地看出它这里处理就相当简单了,只将高位进行后移16位,然后进行异或就结束了。不过官方文档给出的说法是,因为其 table 大小是 2n ,其实发生碰撞的概率很小,另外在 1.8 中碰撞的链表已经修改为了树结构,所以这里是针对速度、实用及位扩展操作复杂度的一个权衡。

线程同步

另外需要谨记的是,HashMap 不是线程安全的,若出现多线程访问的问题,需要实用 Collections.synchronizedMap(Map<K, V> map) 方法来对 map 再进行一次包装。不过其返回的数据结构 SynchronizedMap 也是很简单的,采用组合的方式持有 map,然后针对 map 的每个操作,都加上了同步块。

至此,HashMap 应该了然于胸了,若是小伙伴还有什么疑问的话,欢迎一起交流。

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

推荐阅读更多精彩内容

  • HashMap 是 Java 面试必考的知识点,面试官从这个小知识点就可以了解我们对 Java 基础的掌握程度。网...
    野狗子嗷嗷嗷阅读 6,650评论 9 107
  • 前言 这次我和大家一起学习HashMap,HashMap我们在工作中经常会使用,而且面试中也很频繁会问到,因为它里...
    liangzzz阅读 7,965评论 7 102
  • 1. HashMap概述: HashMap是基于哈希表的Map接口的非同步实现(Hashtable跟HashMap...
    Ten_Minutes阅读 577评论 0 5
  • 飞奔而来 NO.1 当美丽的护士小姐从产房走出来,告诉你是个女儿时,你双手合十,连连感恩。 你的夸张举动让美丽的护...
    阳光Sunflower阅读 343评论 3 6