HashMap数据结构和源码分析

HashMap的数据结构

在java编程中最基本的结构就两种:一个是数组,另一个是模拟指针(引用),所有的数据结构都可以通过这两种类构造,HashMap也不例外。HashMap实际上就是一个数组和链表的结合体(JDK1.8之前)。JDK1.8之后增加了红黑树。(本文图片均来自网络,侵删)

HashMap的主干是Entry数组,每个数组元素Entry存放着一个键值对和同时指向另一个Entry的引用,如果发生哈希冲突,该引用即指向另一个Entry。


HashMap的数据结构

上图中横向表示数组,纵排表示数组元素,实际上是一个链表,链表是为了解决哈希冲突(或者哈希碰撞)。
文章一下所有的实例和源码都是基于JDK1.8

HashMap的“扰动函数”
//java8中hash的优化函数
static final int hash(Object key){
    int h;
    return (key==null)?0:(h=key.hashCode())^(h>>>16);
}

这个叫做“扰动函数”,做了一次16位 右位移 异或 混合运算。

图片来自《知乎》,侵删

右位异16位正好是32bit的一半,自己的高半区和低半区进行异或,就是为了混合原来哈希码的高位和地位,以此来加大低位的随机性。而且低位掺杂了高位的部分特征,这样高位的信息也被变相的保存下来。

为什么不直接用key.hashCode()访问HashMap的下标。

key.hashCode()调用的是key的类型自带的哈希函数,返回的是int类型的散列值。而2进制32位带符号的int的取值范围是-2147483648到2147483648,差不多40亿的映射空间,只要哈希函数的散列值均匀松散,就很慢出现碰撞。

但问题是40亿的映射空间,内存是放不下的。所有这个散列值是不能直接拿来用的。

java8目前采用的什么方式定位HashMap的下标。
index = hash(Key)&(Length-1) 

把key的哈希值和数组长度做取模运算?取模运算的方式固然简单,但Java中的取模运算一般需要用“乘法,整除,减法”等步骤来实现,效率不高。

但是对“2的n次幂“之类的数进行取模有一种效率更高的算法,即hash(Key)&(Length-1), 也就是说可以用与运算来进行取模。

因为hashMap的长度正好取2的整次幂,这样Length-1 之后的二进制都是高位全为0,低位全为1的形式。假设hashMap的长度是16,Length-1的十进制就是15,二进制00000000 00000000 00001111。 与运算的结果就是把散列值的高位全部归零,只保留低4位,用来做下标方位。

    10100101 11000100 00100101
 &  00000000 00000000 00001111
 ----------------------------------
    00000000 00000000 00000101//高位全部归零,只保留末四位

以上两个做与运算,十进制是5,所以index=5,可以说Hash算法最终得到index,完全取决于key的hashcode的最后几位。

为什么HashMap的数组长度要取2的整次幂

1、取2的整次幂,不但结果等同于取模,而且还提高了性能,Length为2的整次幂,hash(Key)&(Length-1) 才等价于hash(Key)%Length
2、当数组长度为2的n次幂的时候,不同的key算出的index相同的几率较小,也就是说碰撞的几率小,相对的,查询的时候就不用遍历某个位置上的链表,这样查询效率也就较高了。

假设HashMap的长度是10,

hash:       10100101 11000100 00101001
Length-1:&  00000000 00000000 00001001
 -----------------------------------------------------
index:       00000000 00000000 00001001

单独看这个结果,并没有什么问题,我们在试几个hash值:

hash:       10100101 11000100 00101111
Length-1:&  00000000 00000000 00001001
 -----------------------------------------------------
index:       00000000 00000000 00001001
hash:       10100101 11000100 00101011
Length-1:&  00000000 00000000 00001001
 -----------------------------------------------------
index:       00000000 00000000 00001001
hash:       10100101 11000100 00101101
Length-1:&  00000000 00000000 00001001
 ----------------------------------------------------
index:       00000000 00000000 00001001

运算的结果都1001,也就是说当hashmap的长度为10时。有些index结果的出现几率更大,而有些index结果永远不会出现(比如0111)。这样,显然不符合Hash算法均匀分布的原则。
反观长度为16或者2的整次幂,Length-1的所有二进制都为1,index的值就等同于hash值的后几位的值。只要输入的hashCode本身分部均匀,这种算法(index = hash(Key)&(Length-1))的结果就是均匀的。

HashMap的put和get方法

1、put(K key,V value)方法
源码如下:

/**
 * 添加key-value键值对
 *
 * @param key 键
 * @param value 值
 * @return 如果原本存在此key,则返回旧的value值,如果是新增的key-     
 *         value,则返回nulll
 */
public V put(K key, V value) {
    //实际调用putVal方法进行添加 key-value 键值对操作
    return putVal(hash(key), key, value, false, true);
}

/**
 * 根据key 键 的 hashCode 通过 “扰动函数” 生成对应的 hash值
 * 经过此操作后,使每一个key对应的hash值生成的更均匀,
 * 减少元素之间的碰撞几率(后面详细说明)
 */
static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}


/**
 * 添加key-value键值对的实际调用方法(重点)
 *
 * @param hash key 键的hash值
 * @param key 键
 * @param value 值
 * @param onlyIfAbsent 此值如果是true, 则如果此key已存在value,则不执
 * 行修改操作 
 * @param evict 此值如果是false,哈希表是在初始化模式
 * @return 返回原本的旧值, 如果是新增,则返回null
 */
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
    // 用于记录当前的hash表
    Node<K,V>[] tab; 
    // 用于记录当前的链表结点
    Node<K,V> p; 
    // n用于记录hash表的长度,i用于记录当前操作索引index
    int n, i;
    // 当前hash表为空
    if ((tab = table) == null || (n = tab.length) == 0)
        // 初始化hash表,并把初始化后的hash表长度值赋值给n
        n = (tab = resize()).length;
    // 1)通过 (n - 1) & hash 当前元素的hash值 & hash表长度 - 1
    // 2)确定当前元素的存储位置,此运算等价于 当前元素的hash值 % hash表的长度
    // 3)计算出的存储位置没有元素存在
    if ((p = tab[i = (n - 1) & hash]) == null)
        // 4) 则新建一个Node结点,在该位置存储此元素
        tab[i] = newNode(hash, key, value, null);
    else { // 当前存储位置已经有元素存在了(不考虑是修改的情况的话,就代表发生hash冲突了)
        // 用于存放新增结点
        Node<K,V> e; 
        // 用于临时存在某个key值
        K k;
        // 1)如果当前位置已存在元素的hash值和新增元素的hash值相等
        // 2)并且key也相等,则证明是同一个key元素,想执行修改value操作
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;// 将当前结点引用赋值给e
        else if (p instanceof TreeNode) // 如果当前结点是树结点
            // 则证明当前位置的链表已变成红黑树结构,则已红黑树结点结构新增元素
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        else {// 排除上述情况,则证明已发生hash冲突,并hash冲突位置现时的结构是单链表结构
            for (int binCount = 0; ; ++binCount) {
                //遍历单链表,将新元素结点放置此链表的最后一位
                if ((e = p.next) == null) {
                    // 将新元素结点放在此链表的最后一位
                    p.next = newNode(hash, key, value, null);
                    // 新增结点后,当前结点数量是否大于等于 阈值 8 
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        // 大于等于8则将链表转换成红黑树
                        treeifyBin(tab, hash);
                    break;
                }
                // 如果链表中已经存在对应的key,则覆盖value
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }
        if (e != null) { // 已存在对应key
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null) //如果允许修改,则修改value为新值
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        }
    }
    ++modCount;
    // 当前存储键值对的数量 大于 阈值 是则扩容
    if (++size > threshold)
       // 重置hash大小,将旧hash表的数据逐一复制到新的hash表中(后面详细讲解)
        resize();
    afterNodeInsertion(evict);
    // 返回null,则证明是新增操作,而不是修改操作
    return null;
}

举个简单的例子:
比如调用 hashMap.put("apple", 0) ,插入一个Key为“apple"的元素。利用哈希函数确定插入的位置index(hash("zhang")),
假定计算的index=2,那么把把这个键值对就存放到2这个位置上,如图

160123dcaff8b9c1.png

当插入的Entry键值对越来越多时,难免会出现hash冲突的情况,比如下面这种。


160123dc96908c9b.png

这个时候就需要用到上面提到的链表结构了。

HashMap数组的每一个元素不止是一个Entry对象,也是一个链表的头节点。每一个Entry对象通过Next指针指向它的下一个Entry节点。当新来的Entry映射到冲突的数组位置时,只需要插入到对应的链表即可:


2020-02-20_154724.png

新来的Entry节点插入链表时,使用的是“尾插法”。
p.next = newNode(hash, key, value, null);
p记录当前的链表结点,p结点的next引用指向新新元素结点,将新元素结点放在当前结点的next指向的位置,也就是插入到当前结点的后面位置。

put(K key,V value)方法的整体流程图如下:


hashMap put方法执行流程图- 图片来自于《美团点评技术团队文章

1、get(Object key)方法

源码如下:

 /**
 * 返回指定 key 所映射的 value 值
 * 或者 返回 null 如果容器里不存在对应的key
 *
 * 更确切地讲,如果此映射包含一个满足 (key==null ? k==null :key.equals(k))
 * 的从 k 键到 v 值的映射关系,
 * 则此方法返回 v;否则返回 null。(最多只能有一个这样的映射关系。)
 *
 * 返回 null 值并不一定 表明该映射不包含该键的映射关系;
 * 也可能该映射将该键显示地映射为 null。可使用containsKey操作来区分这两种情况。 
 *
 * @see #put(Object, Object)
 */
public V get(Object key) {
    Node<K,V> e;
    // 1.先调用 hash(key)方法计算出 key 的 hash值
    // 2.随后调用getNode方法获取对应key所映射的value值
    return (e = getNode(hash(key), key)) == null ? null : e.value;
}


/**
 * 获取哈希表结点的方法实现
 *
 * @param hash key 键的hash值
 * @param key 键
 * @return 返回对应的结点,如果结点不存在,则返回null
 */
final Node<K,V> getNode(int hash, Object key) {
    // 用于记录当前的hash表 
    Node<K,V>[] tab; 
    // first用于记录对应hash位置的第一个结点,e充当工作结点的作用
    Node<K,V> first, e; 
    // n用于记录hash表的长度
    int n; 
    // 用于临时存放Key
    K k;
    // 通过 (n - 1) & hash 当前元素的hash值 & hash表长度 - 1
    // 判断当前元素的存储位置是否有元素存在 
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (first = tab[(n - 1) & hash]) != null) {//元素存在的情况
       // 如果头结点的key的hash值 和 要获取的key的hash值相等
       // 并且 头结点的key本身 和要获取的 key 相等
        if (first.hash == hash && // always check first node 总是检查头结点
            ((k = first.key) == key || (key != null && key.equals(k))))
            // 返回该位置的头结点
            return first;
        if ((e = first.next) != null) {// 头结点不相等
            if (first instanceof TreeNode) // 如果当前结点是树结点
                // 则证明当前位置的链表已变成红黑树结构
                // 通过红黑树结点的方式获取对应key结点
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);
            do {// 当前位置不是红黑树,则证明是单链表
                // 遍历单链表,逐一比较链表结点
                // 链表结点的key的hash值 和 要获取的key的hash值相等
                // 并且 链表结点的key本身 和要获取的 key 相等
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    // 找到对应的结点则返回
                    return e;
            } while ((e = e.next) != null);
        }
    }
    // 通过上述查找均无找到,则返回null
    return null;
}

使用get方法根据key来查找value的时候,首先会把输入的key做一次hash映射,得到的hash值和数组长度-1进行与运算,得到index。

由于刚才所说的hash冲突,同一个位置有可能匹配到多个Entry,这时候就需要顺着对应链表的头节点,一个一个向下来查找。假设我们要查找的key是“banana”:

index = hash(“banana”)&(length - 1)

2020-02-20_162015.png

第一步,我们查看的是头节点Entry1,Entry1的key是apple,显然不是我们要找的结果。

第二步,我们查看的是next节点Entry6,Entry1的key是banana,正是我们要找的结果。

HashMap的扩容resize

HashMap的容量是有限的。当经过多次元素插入,使得HashMap达到一定饱和度时,Key映射位置发生冲突的几率会逐渐提高。这时候,HashMap需要扩展它的长度,也就是进行Resize。

影响发生Resize的因素有两个:

1、Capacity

HashMap的当前长度。

2、LoadFactor
HashMap负载因子,默认值为0.75f。

衡量HashMap是否进行Resize的条件如下:
HashMap.Size >= Capacity * LoadFactor

HashMap的Resize 经历一下两个步骤:

1.扩容
创建一个新的Entry空数组,长度是原数组的2倍。

2、rehashing
遍历原Entry数组,把所有的Entry重新重新放到新数组。具体的复制过程如下:

1、如果链表只有一个,则进行直接赋值
2、如果是红黑树,......
3、如果链表有多个结点,比较特殊: 它并没有重新计算元素在数组中的位置,而是采用了 原始位置加原数组长度的方法计算得到位置。

是根据(e.hash & oldCap) == 0将原来的链表拆分成两个链表,然后
如果 (e.hash & oldCap) == 0 则该节点在新表的下标位置与旧表一致都为 j
如果 (e.hash & oldCap) != 1 则该节点在新表的下标位置 j + oldCap

(e.hash & oldCap) == 0的理解:
因为oldCap是2的n次幂,所以二进制始终是高位为1,其他位都是0。
比如oldCap=8时,二进制位1000;oldCap=16时,二进制位10000;oldCap=32时,二进制位100000;
下面举个例子来看,e.hash & oldCap 运算结果:

//e.hash=13时,结果为0
oldCap=16           0001 0000
e.hash=13           0000 1101
e.hash & oldCap=0       0000 0000

//e.hash=17时,结果为16(非0)
oldCap=16           0001 0000
e.hash=17           0001 0001
e.hash & oldCap!=0      0001 0000

//e.hash=34时,结果为0
oldCap=16           0001 0000
e.hash=13           0010 0010
e.hash & oldCap=0       0000 0000

//e.hash=50时,结果为16(非0)
oldCap=16           0001 0000
e.hash=13           0011 0010
e.hash & oldCap!=0      0001 0000

也就是说(e.hash & oldCap),它有两种结果,一个是0,一个是oldCap(非0)。

参考文章:

JAVA容器-自问自答学HashMap
数据结构——浅谈HashMap
漫画:什么是HashMap?
JDK 源码中 HashMap 的 hash 方法原理是什么?
深入理解HashMap(四): 关键源码逐行分析之resize扩容
Java 1.8中HashMap的resize()方法扩容部分的理解

欢迎转载,但是请注明出处

github
个人博客

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念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

推荐阅读更多精彩内容

  • 摘要 HashMap是Java程序员使用频率最高的用于映射(键值对)处理的数据类型。随着JDK(Java Deve...
    周二倩你一生阅读 1,243评论 0 5
  • 一、HashMap概述 HashMap基于哈希表的Map接口的实现。此实现提供所有可选的映射操作,并允许使用nul...
    小陈阿飞阅读 632评论 0 2
  • 那天中午 我紧张地吃不下东西 夏天却打起寒战来 未开口 手已经没了力气 躺下 让体温使自己平静下来 却又紧张到出起...
    长马阅读 167评论 0 1
  • 啦咯来咯弄
    后羿牛牛阅读 185评论 0 0
  • 有天晚上12点多,枕头底下的手机震动了两下。豆子连续发来几条微信:我好像又选错了路,为什么有这么多糟心事呢?你觉得...
    9e2648dc0a45阅读 174评论 0 1