HashMap元素存储原理解析

在聊HashMap的原理前,我们先来熟悉两种数据结构:
  数组(array):数组对象用下标进行定位,索引速度很快,但是缺点也比较明显,一旦定义不能动态扩容。
  链表(list):链表物理存储单元上非连续、非顺序的存储结构,数据元素的逻辑顺序是通过链表中的指针链接次序实现的,因此链表有着可扩容的天然特性,由此带来的缺点是索引较慢。

有没有办法将两者的优点结合起来,取长补短?聪明的jdk coder们定义了一种将两者相结合的数据结构:散列表(又名HashTable)
HashTable
散列表将多个Node节点作为一个数组,既HashTable = Node[ ],同时在Node节点上又可以存储多个元素形成链表或者红黑树(两者的区别以及出现条件我们后续讨论)。
了解完HashMap的基本结构之后,由此引出思考:一个元素经过HashMap被put进去之后发什了什么呢?上图:
在这里插入图片描述
我们看一下HashMap的put方法源码:

public V put(K key, V value) {
        return putVal(hash(key), key, value, false, true);
    }

put方法中将key传入了hash()方法中,并作为参数传入了putVal中,这里的hash()方法就是我们上图所指的哈希值扰动函数。
哈希值扰动函数hash()区别于hashCode(),hashCode方法返回初代哈希值,而在put进入HashMap中的key值并不是初代哈希值,而是将哈希值经过扰动函数计算之后得到的二代哈希值,来看hash()的源码:

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

例如对象经过object.hashCode()得到的哈希值为: 0010 0110 0001 0100 1101 1010 0010 0111
先将对象哈希值右移16位,即为:0000 0000 0000 0000 0010 0110 0001 0100
再将两者异或:

        0010 0110 0001 0100 1101 1010 0010 0111
    异或  0000 0000 0000 0000 0010 0110 0001 0100
    ————————————————————————————
        0010 0110 0001 0100 1111 1100 0011 0011

最后得到的数值即为经过哈希扰动函数计算之后的二代哈希值。
为什么需要这样设计???事出反常必有妖,我们还得深挖,先来看putVal的源码:


    /**
     * Implements Map.put and related methods
     *
     * @param hash hash for key
     * @param key the key
     * @param value the value to put
     * @param onlyIfAbsent if true, don't change existing value
     * @param evict if false, the table is in creation mode.
     * @return previous value, or null if none
     */
    final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        Node<K,V>[] tab; Node<K,V> p; int n, i;
		
		//延迟初始化逻辑,第一次调用putVal时会初始化hashMap对象中的最耗费内存的散列表
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;

		//最简单的一种情况:寻址找到的Node节点刚好时null,这个时候直接创建新的节点
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);

		//
        else {
            Node<K,V> e; K k;

			//表示Node节点中的该院告诉,与当前插入的原元素的key完全一致,表示后续需要进行替换操作
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;

			//插入红黑树
            else if (p instanceof TreeNode)
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            else {

				//链表的情况,而且链表的头元素与我们要插入的key不一致
                for (int binCount = 0; ; ++binCount) {

					//条件成立的话,说明迭代到最后一个元素了,也没找到一个与你要插入的key一致的node节点,说明需要加入到当前链表的末尾
                    if ((e = p.next) == null) {
                        p.next = newNode(hash, key, value, null);

						//条件成立的话,说明当前链表的长度,达到了树化标准,需要进行树化
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                            treeifyBin(tab, hash);
                        break;
                    }
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }
            if (e != null) { // existing mapping for key
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;
            }
        }

		//modeCount:表示散列表结构被修改的次数,替换Node元素的value不计数
        ++modCount;
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }

putVal方法中对不同情况的put情况作了判断处理,单纯的看源码晦涩难懂,取其精华,我们可以截取到HashMap的节点寻址算法,它将确定键值对被存储的具体节点位置:

tab[i = (n - 1) & hash]

节点下标 = (哈希表长度 - 1) [相与] 扰动之后的哈希值

先得出一个结论:扰动函数结果直接关系到哈希表的键值对存放节点位置

不妨用反证法来论证扰动函数的意义,假设我们直接使用哈希值来对元素进行定位,即把HashMap节点寻址算法假设为:
节点下标 = (哈希表长度 - 1) [相与] 原始哈希值
考虑下面一种情况:
  对象object1的hashCode值假设为: 0101 0110 0001 0010 1011 0011 1010 1100
  对象object2的hashCode值假设为:0010 0111 0110 0019 1011 0011 1010 1100
假设此时hashMap的初始长度为16(tips:缺省初始长度也为16),不经过扰动函数,计算出他的节点位置:
object1:
      0101 0110 0001 0010 1011 0011 1010 1100
  相与  0000 0000 0000 0000 1111 1111 1111 1111
———————————————————————————
      0000 0000 0000 0000 1011 0011 1010 1100

object2:
      0010 0111 0110 0010 1011 0011 1010 1100
  相与  0000 0000 0000 0000 1111 1111 1111 1111
———————————————————————————
      0000 0000 0000 0000 1011 0011 1010 1100

两个不同的hashCode,得出来的节点位置竟然相等?
这种情况称为哈希碰撞,既不同的元素得到一样的节点值,节点将被迫由单元素节点变为节点链表或成为红黑树,导致HashMap效率下降。
我们仔细分析上述计算过程会发现,由于HashMap长度较短,导致HashCode的高16位(table.length – 1)都为0,0与任何数相与都得0,既可以认为hashCode的高16位没有参与运算,由此导致发生哈希碰撞的概率增加一倍。

为了解决这个问题,由此引入了扰动算法hash(),将hashCode异或HashCode的高16位,以最终的结果作为HashMap元素节点定位的依据:

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

再来使用object1、object2在有扰动函数hash()的情况下计算一遍:

object1二代哈希值,将hashCode异或hashCode的高16位:

      0101 0110 0001 0010 1011 0011 1010 1100
  异或  0000 0000 0000 0000 0101 0110 0001 0010
———————————————————————————
      0101 0110 0001 0010 1110 0101 1011 1111

hashMap长度为16时的object1的最终节点定位:

      0101 0110 0001 0010 1110 0101 1011 1111
  相与  0000 0000 0000 0000 1111 1111 1111 1111
———————————————————————————
      0000 0000 0000 0000 1110 0101 1011 1111

object2二代哈希值,将hashCode异或hashCode的高16位:

      0010 0111 0110 0019 1011 0011 1010 1100
  异或  0000 0000 0000 0000 1111 1111 1111 1001
———————————————————————————
      0010 0111 0110 0010 0100 1100 0101 0101

hashMap长度为16时的object2的最终节点定位:

      0010 0111 0110 0010 0100 1100 0101 0101
  相与  0000 0000 0000 0000 1111 1111 1111 1111
———————————————————————————
      0000 0000 0000 0000 0100 1100 0101 0101

经过hash()扰动函数计算后,由于有了哈希值高16位的参与,object1和object2的最终节点定位不同。
我们可以得出结论:为了避免在HashMap长度较短时哈希碰撞概率增加,Hash函数对hashcode右移16位再先相异或,得到的值作为最终的hashcode(右移16位的目的就是为了让高16位参与运算),在HashMap长度很小的情况下,这种方式可以显著减少哈希碰撞。
以上即为HashMap存储元素的原理。

原创不易,如对您有帮助,记得点赞~

  • 4
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值