【JAVA】HashMap

1、hashmap 的数据结构

要知道 hashmap 是什么,首先要搞清楚它的数据结构,在 java 编程语言中,最基本的结构就是两种,一个是数组,另外一个是模拟指针(引用),所有的数据结构都可以用这两个基本结构来构造的,hashmap 也不例外。Hashmap 实际上是一个数组和链表的结合体(在数据结构中,一般称之为 “链表散列 “),请看下图(横排表示数组,纵排表示数组元素【实际上是一个链表】)。

构造图

从图中我们可以看到一个 hashmap 就是一个数组结构,当新建一个 hashmap 的时候,就会初始化一个数组。我们来看看 java 代码:

    /** 
     * The table, resized as necessary. Length MUST Always be a power of two. 
     *  FIXME 这里需要注意这句话,至于原因后面会讲到 
     */  
    transient Entry[] table;  

    static class Entry<K,V> implements Map.Entry<K,V> {  
        final K key;  
        V value;  
        final int hash;  
        Entry<K,V> next;  
    ..........  
    }  

上面的 Entry 就是数组中的元素,它持有一个指向下一个元素的引用,这就构成了链表。

当我们往 hashmap 中 put 元素的时候,先根据 key 的 hash 值得到这个元素在数组中的位置(即下标),然后就可以把这个元素放到对应的位置中了。如果这个元素所在的位子上已经存放有其他元素了,那么在同一个位子上的元素将以链表的形式存放,新加入的放在链头,最先加入的放在链尾。从 hashmap 中 get 元素时,首先计算 key 的 hashcode,找到数组中对应位置的某一元素,然后通过 key 的 equals 方法在对应位置的链表中找到需要的元素。从这里我们可以想象得到,如果每个位置上的链表只有一个元素,那么 hashmap 的 get 效率将是最高的,但是理想总是美好的,现实总是有困难需要我们去克服,哈哈~

2、hash 算法

我们可以看到在 hashmap 中要找到某个元素,需要根据 key 的 hash 值来求得对应数组中的位置。如何计算这个位置就是 hash 算法。前面说过 hashmap 的数据结构是数组和链表的结合,所以我们当然希望这个 hashmap 里面的元素位置尽量的分布均匀些,尽量使得每个位置上的元素数量只有一个,那么当我们用 hash 算法求得这个位置的时候,马上就可以知道对应位置的元素就是我们要的,而不用再去遍历链表。

所以我们首先想到的就是把 hashcode 对数组长度取模运算,这样一来,元素的分布相对来说是比较均匀的。但是,“模” 运算的消耗还是比较大的,能不能找一种更快速,消耗更小的方式那?java 中时这样做的,

    static int indexFor(int h, int length) {  
       return h & (length-1);  
   }  

首先算得 key 得 hashcode 值,然后跟数组的长度 - 1 做一次 “与” 运算(&)。看上去很简单,其实比较有玄机。比如数组的长度是 2 的 4 次方,那么 hashcode 就会和 2 的 4 次方 - 1 做 “与” 运算。很多人都有这个疑问,为什么 hashmap 的数组初始化大小都是 2 的次方大小时,hashmap 的效率最高,我以 2 的 4 次方举例,来解释一下为什么数组大小为 2 的幂时 hashmap 访问的性能最高。

看下图,左边两组是数组长度为 16(2 的 4 次方),右边两组是数组长度为 15。两组的 hashcode 均为 8 和 9,但是很明显,当它们和 1110 “与” 的时候,产生了相同的结果,也就是说它们会定位到数组中的同一个位置上去,这就产生了碰撞,8 和 9 会被放到同一个链表上,那么查询的时候就需要遍历这个链表,得到 8 或者 9,这样就降低了查询的效率。同时,我们也可以发现,当数组长度为 15 的时候,hashcode 的值会与 14(1110)进行 “与”,那么最后一位永远是 0,而 0001,0011,0101,1001,1011,0111,1101 这几个位置永远都不能存放元素了,空间浪费相当大,更糟的是这种情况中,数组可以使用的位置比数组长度小了很多,这意味着进一步增加了碰撞的几率,减慢了查询的效率!

在这里插入图片描述

所以说,当数组长度为 2 的 n 次幂的时候,不同的 key 算得得 index 相同的几率较小,那么数据在数组上分布就比较均匀,也就是说碰撞的几率小,相对的,查询的时候就不用遍历某个位置上的链表,这样查询效率也就较高了。

说到这里,我们再回头看一下 hashmap 中默认的数组大小是多少,查看源代码可以得知是 16,为什么是 16,而不是 15,也不是 20 呢,看到上面 annegu 的解释之后我们就清楚了吧,显然是因为 16 是 2 的整数次幂的原因,16-1的二进制是 1 1 1 1 ,能够尽量报错hash值的结果,在小数据量的情况下 16 比 15 和 20 更能减少 key 之间的碰撞,而加快查询的效率。

所以,在存储大容量数据的时候,最好预先指定 hashmap 的 size 为 2 的整数次幂次方。就算不指定的话,也会以大于且最接近指定值大小的 2 次幂来初始化的,代码如下 (HashMap 的构造方法中):

// Find a power of 2 >= initialCapacity  
        int capacity = 1;  
        while (capacity < initialCapacity)   
            capacity <<= 1;  

3、hashmap 的 resize

当 hashmap 中的元素越来越多的时候,碰撞的几率也就越来越高(因为数组的长度是固定的),所以为了提高查询的效率,就要对 hashmap 的数组进行扩容,数组扩容这个操作也会出现在 ArrayList 中,所以这是一个通用的操作,很多人对它的性能表示过怀疑,不过想想我们的 “均摊” 原理,就释然了,而在 hashmap 数组扩容之后,最消耗性能的点就出现了:原数组中的数据必须重新计算其在新数组中的位置,并放进去,这就是 resize。

那么 hashmap 什么时候进行扩容呢?当 hashmap 中的元素个数超过数组大小 * loadFactor 时,就会进行数组扩容,loadFactor 的默认值为 0.75,也就是说,默认情况下,数组大小为 16,那么当 hashmap 中元素个数超过 16*0.75=12 的时候,就把数组的大小扩展为 2*16=32,即扩大一倍,然后重新计算每个元素在数组中的位置,而这是一个非常消耗性能的操作,所以如果我们已经预知 hashmap 中元素的个数,那么预设元素的个数能够有效的提高 hashmap 的性能。比如说,我们有 1000 个元素 new HashMap (1000), 但是理论上来讲 new HashMap (1024) 更合适,不过上面 annegu 已经说过,即使是 1000,hashmap 也自动会将其设置为 1024。 但是 new HashMap (1024) 还不是更合适的,因为 0.75*1000 < 1000, 也就是说为了让 0.75 * size > 1000, 我们必须这样 new HashMap (2048) 才最合适,既考虑了 & 的问题,也避免了 resize 的问题。

  • 如果需要扩容,调用扩容的方法 resize ()

void resize(int newCapacity) {
    Entry[] oldTable = table;
    int oldCapacity = oldTable.length;
    //判断是否有超出扩容的最大值,如果达到最大值则不进行扩容操作
    if (oldCapacity == MAXIMUM_CAPACITY) {
      threshold = Integer.MAX_VALUE;
      return;
    }
 
    Entry[] newTable = new Entry[newCapacity];
    // transfer()方法把原数组中的值放到新数组中
    transfer(newTable, initHashSeedAsNeeded(newCapacity));
    //设置hashmap扩容后为新的数组引用
    table = newTable;
    //设置hashmap扩容新的阈值
    threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
  }
  • transfer () 在实际扩容时候把原来数组中的元素放入新的数组中

void transfer(Entry[] newTable, boolean rehash) {
    int newCapacity = newTable.length;
    for (Entry<K,V> e : table) {
      while(null != e) {
        Entry<K,V> next = e.next;
        if (rehash) {
          e.hash = null == e.key ? 0 : hash(e.key);
        }
        //通过key值的hash值和新数组的大小算出在当前数组中的存放位置
        int i = indexFor(e.hash, newCapacity);
        e.next = newTable[i];
        newTable[i] = e;
        e = next;
      }
    }
  }

4、key 的 hashcode 与 equals 方法改写

在第一部分 hashmap 的数据结构中,就写了 get 方法的过程:首先计算 key 的 hashcode,找到数组中对应位置的某一元素,然后通过 key 的 equals 方法在对应位置的链表中找到需要的元素。所以,hashcode 与 equals 方法对于找到对应元素是两个关键方法。

Hashmap 的 key 可以是任何类型的对象,例如 User 这种对象,为了保证两个具有相同属性的 user 的 hashcode 相同,我们就需要改写 hashcode 方法,比方把 hashcode 值的计算与 User 对象的 id 关联起来,那么只要 user 对象拥有相同 id,那么他们的 hashcode 也能保持一致了,这样就可以找到在 hashmap 数组中的位置了。如果这个位置上有多个元素,还需要用 key 的 equals 方法在对应位置的链表中找到需要的元素,所以只改写了 hashcode 方法是不够的,equals 方法也是需要改写滴~当然啦,按正常思维逻辑,equals 方法一般都会根据实际的业务内容来定义,例如根据 user 对象的 id 来判断两个 user 是否相等。
在改写 equals 方法的时候,需要满足以下三点:
(1) 自反性:就是说 a.equals (a) 必须为 true。
(2) 对称性:就是说 a.equals (b)=true 的话,b.equals (a) 也必须为 true。
(3) 传递性:就是说 a.equals (b)=true,并且 b.equals (\c)=true 的话,a.equals (\c) 也必须为 true。
通过改写 key 对象的 equals 和 hashcode 方法,我们可以将任意的业务对象作为 map 的 key (前提是你确实有这样的需要)。

总结:
本文主要描述了 HashMap 的结构,和 hashmap 中 hash 函数的实现,以及该实现的特性,同时描述了 hashmap 中 resize 带来性能消耗的根本原因,以及将普通的域模型对象作为 key 的基本要求。尤其是 hash 函数的实现,可以说是整个 HashMap 的精髓所在,只有真正理解了这个 hash 函数,才可以说对 HashMap 有了一定的理解。

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

推荐阅读更多精彩内容