zoukankan      html  css  js  c++  java
  • Java源码系列2——HashMap

    HashMap 的源码很多也很复杂,本文只是摘取简单常用的部分代码进行分析。能力有限,欢迎指正。

    HASH 值的计算

    前置知识——位运算

    按位异或操作符^:1^1=0, 0^0=0, 1^0=0, 值相同为0,值不同为1。按位异或就是对二进制中的每一位进行异或运算。

       1111 0000 1111 1110
    ^  1111 1111 0000 1111
    ______________________
       0000 1111 1111 0001
    

    按位右移补零操作符>>> :左操作数按右操作数指定的位数右移,移动得到的空位以零填充。

            1110 1101 1001 1111
    >>> 4  
    ___________________________
            0000 1110 1101 1001
    

    扰动函数

    为什么要做扰动?

    理论上哈希值是一个int类型,如果直接拿哈希值做下标的话,考虑到2进制32位带符号的int表值范围从-2147483648到2147483648。前后加起来大概40亿的映射空间。这么大的数组,内存是存不下的,所以这个散列值是不能直接拿来用的。用之前还要先做对数组的长度取模运算,得到的余数才能用来访问数组下标。

    因为只取最后几位,所以哈希碰撞的可能性大大增加,这时候扰动函数的价值就来了。

    扰动计算

    先调用hashCode()方法得出hash值,再进行扰动操作。

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

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

    取模,计算出下标

    在计算下标的时候,让列表长度对哈希值做取模操作,让计算出来的哈希值在列表范围内,n 为list长度

    i = (n - 1) & hash
    

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

    因为这样(数组长度 - 1)正好相当于一个“低位掩码”。&操作的结果就是散列值的高位全部归零,只保留低位值,用来做数组的下标访问。以初始长度16为例,16-1=15,2进制表示是0000 1111。和某散列值做&操作如下:

        1010 0011 0110 1111 0101
    &   0000 0000 0000 0000 1111
    ____________________________
        0000 0000 0000 0000 0101
    

    是什么存入了 table

    HashMap存入table的值并不只有value,而是构造成一个 Node 对象实例存入 table。

    Node对象里有:hash, key, value, next(哈希冲突时的链表)

    理论最大容量

    int MAXIMUM_CAPACITY = 1 << 30;

    2的30次方

    负载因子

    负载因子是用来计算负载容量(所能容纳的最大Node个数)的,当前list长度 length,负载因子 loadFactor

    负载容量计算公式为:

    threshold = length * loadFactor

    默认负载因子为 0.75。也就是说,当Node个数达到当前list长度的75%时,就要进行扩容,否则会增加哈希碰撞的可能性。负载因子的作用是在空间和时间效率上取得一个平衡。

    float DEFAULT_LOAD_FACTOR = 0.75f

    扩容做了哪些操作

    1. 创建一个新的Entry空数组,长度是原数组的2倍。
      当Node个数超过负载容量时,进行扩容。

    old << 1 左移一位相当于 old * 2。

    1. 重新Hash

      遍历原Entry数组,把所有的Entry重新Hash到新数组中。

      为什么要重新hash?因为长度扩大以后,hash值也随之改变(数组下标的计算是数组长度对hashcode进行取模)。

      这样就可以把原先哈希冲突的链表拉平,使数组变得稀疏。

    final Node<K,V>[] resize() {
        // 保存现有的数组
        Node<K,V>[] oldTab = table;
        // 保存现有的数组长度
        int oldCap = (oldTab == null) ? 0 : oldTab.length;
        // 保存现有的负载容量
        int oldThr = threshold;
        int newCap, newThr = 0;
        if (oldCap > 0) {
            // 如果现有容量已经超过最大值了,就没办扩容了,只好随你碰撞了
            if (oldCap >= MAXIMUM_CAPACITY) {
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
            // 原有容量左移一位,相当于 oldCap * 2
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                        oldCap >= DEFAULT_INITIAL_CAPACITY)
                // 负载容量也扩大一倍
                newThr = oldThr << 1; // double threshold
        }
        else if (oldThr > 0) // initial capacity was placed in threshold
            newCap = oldThr;
        else {               // zero initial threshold signifies using defaults
            newCap = DEFAULT_INITIAL_CAPACITY;
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
        }
        // 负载容量为0,根据数组大小和负载因子计算出来
        if (newThr == 0) {
            float ft = (float)newCap * loadFactor;
            newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                        (int)ft : Integer.MAX_VALUE);
        }
        threshold = newThr;
        @SuppressWarnings({"rawtypes","unchecked"})
            Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
        table = newTab;
        // 遍历数组中所有元素,重新进行hash
        if (oldTab != null) {
            for (int j = 0; j < oldCap; ++j) {
                Node<K,V> e;
                if ((e = oldTab[j]) != null) {
                    // 删除旧索引位置的值
                    oldTab[j] = null;
                    if (e.next == null)
                        // 给新的索引位置赋值
                        newTab[e.hash & (newCap - 1)] = e;
                    else if (e instanceof TreeNode)
                        ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                    else { // 优化链表
                        // 把原有链表拆成两个链表
                        // 链表1存放在低位(原索引位置)
                        Node<K,V> loHead = null, loTail = null;
                        // 链表2存放在高位(原索引 + 旧数组长度)
                        Node<K,V> hiHead = null, hiTail = null;
                        Node<K,V> next;
                        do {
                            next = e.next;
                            // 链表1
                            // 这个位运算的原理可以参考第三篇参考资料
                            if ((e.hash & oldCap) == 0) {
                                if (loTail == null)
                                    loHead = e;
                                else
                                    loTail.next = e;
                                loTail = e;
                            }
                            // 链表2
                            else {
                                if (hiTail == null)
                                    hiHead = e;
                                else
                                    hiTail.next = e;
                                hiTail = e;
                            }
                        } while ((e = next) != null);
                        // 链表1存放于原索引位置
                        if (loTail != null) {
                            loTail.next = null;
                            newTab[j] = loHead;
                        }
                        // 链表2存放原索引加上旧数组长度的偏移量
                        if (hiTail != null) {
                            hiTail.next = null;
                            newTab[j + oldCap] = hiHead;
                        }
                    }
                }
            }
        }
        return newTab;
    }
    

    树化改造

    链表长度太长,会被改造成红黑树。

    当链表的长度超过MIN_TREEIFY_CAPACITY 最大树化临界值,就会进行树化改造。

    final void treeifyBin(Node<K,V>[] tab, int hash) {
      int n, index; Node<K,V> e;
      if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
        resize();
      else if ((e = tab[index = (n - 1) & hash]) != null) {
        ...
      }
    }
    

    为什么要树化?

    本质上是个安全问题。因为链表查询影响性能,如果有人恶意造成哈希碰撞,就会构成哈希碰撞拒绝服务攻击,服务端CPU被大量占用用于链表查询,造成服务变慢或不可用。

    源码系列文章

    Java源码系列1——ArrayList

    Java源码系列2——HashMap

    Java源码系列3——LinkedHashMap

    参考

    Java 8系列之重新认识HashMap

    JDK 源码中 HashMap 的 hash 方法原理是什么?胖君的回答

    HashMap 源码详细分析(JDK1.8)

    本文首发于我的个人博客 http://chaohang.top

    作者张小超

    转载请注明出处

    欢迎关注我的微信公众号 【超超不会飞】,获取第一时间的更新。

  • 相关阅读:
    macOS 修改键盘重复按键延迟
    stdout 与 stderr 区别
    E. 1-Trees and Queries
    Codeforces Round #615 (Div. 3)
    Codeforces Round 613(div 2)
    Codeforces Edu80
    SPOJ
    快读
    《货车运输》题解--最大生成树&倍增
    倍增思想求lca
  • 原文地址:https://www.cnblogs.com/chaohangz/p/12360151.html
Copyright © 2011-2022 走看看