美文网首页
重新认识JDK1.8中的HashMap

重新认识JDK1.8中的HashMap

作者: 没有氧气的鱼 | 来源:发表于2020-03-24 06:00 被阅读0次

    简介

    HashMap是Java程序员使用频率最高的用于映射(键、值对)处理的数据类型,它根据键的hashCode值存储数据,大多数情况下可以直接定位到它的值,因而具有很快的访问速度,但遍历顺序却是不确定的。 HashMap最多只允许一条记录的键为null,允许多条记录的值为null,且HashMap不是线程安全的类,可以使用ConcurrentHashMap和Collections的SynchronizedMap方法使HashMap具有线程安全的能力。在JDK1.8中对HashMap底层的实现进行了优化,引入红黑树、扩容优化等。那就重新认识一下JDK1.8的HashMap,来看看对其做了什么优化。

    存储结构

    要搞清楚HashMap,首先需要知道HashMap是什么,即它的存储结构;其次弄明白它能干什么,即它的功能如何实现。我们都知道HashMap使用哈希表来存储的数据的,根据key的哈希值进行存储的,但是不同的key之间可能存在相同的哈希值,这样就会产生冲突;哈希表为解决冲突,可以采用开放地址法和链地址法等来解决问题,Java中的HashMap采用了链地址法来解决哈希冲突,简单来说就是数组加链表的结合。在每个数组元素上都一个链表结构,当存放数据的时候如果产生了哈希冲突,先得到数组下标,把数据放在对应下标元素的链表上。这里我们思考一个问题,即使哈希算法设计的再合理,也免不了会出现拉链过长的情况,一旦出现拉链过长,则会严重影响HashMap的性能,在JDK1.8版本中,对数据结构做了进一步的优化,引入了红黑树;当链表长度太长(默认超过7)时,链表就转换为红黑树,如下图所示。

    hashMap.png

    重要字段

    HashMap是根据key的哈希值进行存取的,那HashMap的性能和哈希算法的好坏有着直接的关系,哈希算法计算结果越分散均匀,哈希碰撞的概率就越小,map的存取效率就会越高。当然,也和哈希数组的大小有关系,如果哈希数组很大,即使较差的哈希算法也会比较分散,如果哈希数组较小,即使较好的哈希算法也会出现较多的碰撞,所以就需要权衡空间和时间成本,找到比较平衡的值。

    JDK1.8版本也是权衡了时间、空间成本以及效率,对之前的版本做出了很多优化;不仅对数据结构进行了优化,除此之外还对扩容进行的优化,大大的提高的HashMap的性能。下面我们通过源码来一起看一下具体的实现。

    我们来看一下HashMap中比较重要的几个属性。

    //默认的初始容量,必须是2的幂次方.
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
    
    //所能容纳 key-value 个数的极限,当Map 的size > threshold 会进行扩容 。容量 * 扩容因子
    int threshold;
    
    //hashMap最大的容量
    static final int MAXIMUM_CAPACITY = 1 << 30;
    
    //HashMap 默认的桶数组的大小
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // 16
    
    //默认的加载因子.当容量超过 0.75*table.length 扩容
    static final float DEFAULT_LOAD_FACTOR = 0.75f;
    
    //HashMap的加载因子,在构造器中指定的.
    final float loadFactor;
    
    //链表节点数大于8个链表转红黑树
    static final int TREEIFY_THRESHOLD = 8;
    
    //红黑树节点转换链表节点的阈值, 6个节点转
    static final int UNTREEIFY_THRESHOLD = 6;
    
    //以Node数组存储元素,长度为2的次幂。
    transient Node<K,V>[] table;
    
    // 转红黑树, table的最小长度
    static final int MIN_TREEIFY_CAPACITY = 64; 
    
    // 链表节点, 继承自Entry
    static class Node<K,V> implements Map.Entry<K,V> {  
        final int hash;
        final K key;
        V value;
        Node<K,V> next;
        // ... ...
    }
    
    // 红黑树节点
    static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
        TreeNode<K,V> parent;  // red-black tree links
        TreeNode<K,V> left;
        TreeNode<K,V> right;
        TreeNode<K,V> prev;    // needed to unlink next upon deletion
        boolean red;
       
        // ...
    }
    

    HashMap中的属性还是比较好理解的。其实到这里会有一个疑问,为什么默认的哈希桶数组table的长度为16,而且长度必须为2的n次方呢?

    这里我们先说一下为什么哈希数组的长度是2的n次方。

    其实不管是在JDK1.7还是JDK1.8中,计算key索引位置都是通过hash & (length-1)计算得来的。

    我们应该知道 hash % length 等价于 hash & (length - 1)。

    假如有一个key的哈希值二进制如下:这里我们就只看低位。
    hahsCode         0010 0011       ———————转成十进制—————————>        35           
    &                                                             %
    (length-1)=15:   0000 1111                                length = 16  
    -----------------------------------------------------------------------------------------------
                 (二进制) 0011  = (十进制)3                            3
    

    为什么不用 hash % length 计算索引位,要使用 hash & (length -1)来计算呢?计算机底层是二进制数进行计算和存储,&是接近计算机底层运算,相比于% 运算效率上应该会快。

    那为什么length必须是2的n次方呢?

    hahsCode         0010 0011                     0010 1111    
    &                                     
    (length-1)=15:   0000 1111    (length-1) = 13: 0000 1111  
    ------------------------------------------------------------
                          0011                          1111 
    
    hahsCode         0010 1110                     1110 1100  
    &                                     
    (length-1)=13:   0000 0101    (length-1) = 13: 0000 0101  
    -----------------------------------------------------------
                          0100                          0100 
    

    其实我们可以发现,当哈希数组的长度为2的n次方时,length - 1的二进制码全都是1,这样的话索引的位置,完全依赖于hash值的低位值,而且产生冲突的几率要比容量不是2的n次方的概率要低,索引位就完全依赖于哈希值的低位,所以只要哈希值分布均匀,那产生冲突的概率就会低很多,故而length是2的n次方更优。

    其次,当length为2的n次方时,也方便做扩容,JDK1.8在扩容算法上也进行了优化,使用的方法也非常巧妙。会在扩容方法的时候讲到。

    功能实现

    确定索引位置

    不管是增加、删除、查找,都需要定位到哈希桶的数组位置,前面也说过HashMap的数据结构是数组和链表的结合,所以我们当然希望这个HashMap里面的元素位置尽量分布均匀些,尽量使得每个位置上的元素数量只有一个,那么当我们用hash算法求得这个位置的时候,马上就可以知道对应位置的元素就是我们要的,不用再遍历链表查询,大大优化了查询效率。

    tableSizeFor()这个方法,就是保证在HashMap()进行初始化的时候,哈希桶数组的大小永远是2^n。

    static final int tableSizeFor(int cap) {
            int n = cap - 1;
            n |= n >>> 1;
            n |= n >>> 2;
            n |= n >>> 4;
            n |= n >>> 8;
            n |= n >>> 16;
            return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
            
      /**
      假如现在传入的参数cap = 3
      那 n = 2 对应的二进制 为 10 
      n  = n | n>>>1  10|01  得到 11
      ....
      ....
      n = 11(二进制)  = (10进制) 3 
      最后return 返回的是4
      */
    }
    
    //JDK1.8的Hash算法
    static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }
    
    // JDK 1.7的Hash算法
     static final int hash(int h) {
        h ^= k.hashCode(); 
        h ^= (h >>> 20) ^ (h >>> 12);
        return h ^ (h >>> 7) ^ (h >>> 4);
     }
    
    //索引位置
    index = hash & (length-1);
    
    //JDK1.7 使用hashCode() + 4次位运算 + 5次异或运算(9次扰动)
    //JDK 1.8 简化了hash函数 = 只做了2次扰动 = 1次位运算 + 1次异或运算。
    

    在JDK1.8的实现中,优化了高位运算的算法,通过hashCode()的高16位异或低16位实现的:(h = k.hashCode()) ^ (h >>> 16),主要是从速度、功效、质量来考虑的,相比于JDK1.7来说,JDK1.8降低了哈希函数扰动的次数,也算是优化了hash算法。这么做可以在HashMap容量较小的时候,也能保证考虑到高低Bit都参与到Hash的计算中,同时不会有太大的开销。

    假如有一个key的哈希值二进制如下
    hahsCode               0000 0000 0011 0011 0111 1010 1000 1011
    
    hahsCode>>>16          0000 0000 0000 0000 0000 0000 0011 0011
     ———————————————————————————————————————————————————————————————
    位或^运算               0000 0000 0011 0011 0111 1010 1011 1000
     &
    HashMap.size()-1       0000 0000 0000 0000 0000 0000 0000 1111
     ———————————————————————————————————————————————————————————————
                           0000 0000 0000 0000 0000 0000 0000 1000 转成十进制是 8
    

    put方法

    从网上找到了一个流程图,感觉很不错,就直接拿过来用了,嘿嘿....画的也是比较清楚的。看着流程图,再结合源码一看就明白。
    <img src="https://user-gold-cdn.xitu.io/2019/12/30/16f547c76710e6d7?w=1716&h=1360&f=png&s=314628" width = "550" height = "450" alt="图片名称" align="center">

    final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                       boolean evict) {
            Node<K,V>[] tab; Node<K,V> p; int n, i;
            
            //判断哈希桶数组是否为空。
            if ((tab = table) == null || (n = tab.length) == 0)
            
             //如果哈希桶数组为空,对其进行初始化。默认的桶数组大小为16
            n = (tab = resize()).length;
                
            //如果桶数组不为空,得到计算key的索引位置,判断此索引所在位置是否已经被占用了。
            if ((p = tab[i = (n - 1) & hash]) == null)
            
            //如果没有被占用,那就封装成Node节点,放入哈希桶数组中。
                tab[i] = newNode(hash, key, value, null);
            else {
                Node<K,V> e; K k;
                //如果要插入的Node节点已经存在,那就将旧的Node替换。
                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 {
                //如果是普通节点,循环哈希桶对应的链表,将节点插入到链表末尾
                    for (int binCount = 0; ; ++binCount) {
                        
                        if ((e = p.next) == null) {d
                        
                            p.next = newNode(hash, key, value, null);
                            
                            //如果链表的长度大于7,就把节点转成树节点
                            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;
                }
            }
            ++modCount;
            //如果超过了扩容的临界点,就进行扩容。
            if (++size > threshold)
                resize();
            afterNodeInsertion(evict);
            return null;
        }
    

    get方法

    get方法相对于put方法可能简单一点,通过源码一看就能明白。废话不多说,直接上代码看一下吧。

     public V get(Object key) {
            Node<K,V> e;
            return (e = getNode(hash(key), key)) == null ? null : e.value;
    }
    
    final Node<K,V> getNode(int hash, Object key) {
    
            Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
            //哈希桶数组不为空,且根据传入的key计算出索引位置的Node不为空。
            if ((tab = table) != null && (n = tab.length) > 0 &&
                (first = tab[(n - 1) & hash]) != null) {
                //如果计算出来的第一个哈希桶位置的Node就是要找的Node节点,直接返回。
                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)
                        return ((TreeNode<K,V>)first).getTreeNode(hash, key);
                    //循环遍历哈希桶所在的链表
                    do {
                        if (e.hash == hash &&
                            ((k = e.key) == key || (key != null && key.equals(k))))
                            return e;
                    } while ((e = e.next) != null);
                }
            }
            return null;
      }
    

    扩容机制(resize)

    我个人认为HashMap的扩容算法是整个HashMap中的精髓部分,使用的方法也十分巧妙,不得不佩服,大佬还是大佬。

    final Node<K,V>[] resize() {
            //将当前的table 暂存的 oldTab中进行操作
            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) {
                //修改阈值为2^31-1
                    threshold = Integer.MAX_VALUE;
                    return oldTab;
                }  
                // 没超过最大值,就扩充为原来的2倍
                else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                         oldCap >= DEFAULT_INITIAL_CAPACITY)
                    newThr = oldThr << 1; // double threshold
            }
            else if (oldThr > 0) 
              // 当使用 new HashMap(int initialCapacity) 初始化后,第一次 put 的时候
                newCap = oldThr; 
            else {
              //如果代码到这里,说明hashMap还没有进行初始化, 对应 new HashMap();
                newCap = DEFAULT_INITIAL_CAPACITY;
                newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
            }
            // 计算新的resize上限
            if (newThr == 0) {
                float ft = (float)newCap * loadFactor;
                newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                          (int)ft : Integer.MAX_VALUE);
            }
           // 将当前阈值设置为刚计算出来的新的阈值,定义新表,容量为刚计算出来的新容量。将旧Hash桶中的元素,移动到新的Hash数组中。
            threshold = newThr;
            @SuppressWarnings({"rawtypes","unchecked"})
            Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
            table = newTab;
           /**
            如果只是对HashMap的初始化来说,到这里已经结束了
            下面代码是将老的数组中的数据,移动到扩容以后的哈希桶数组中。
           **/
            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)
                            //如果是红黑树节点,则进行红黑树的重hash分布
                            ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                        else { // preserve order
                        //如果是普通的链表节点,则需要将这个位置上的所有节点重新在新的哈希桶数组中计算位置。
                            Node<K,V> loHead = null, loTail = null;
                            Node<K,V> hiHead = null, hiTail = null;
                            Node<K,V> next;
                            do {
                                next = e.next;
              //如果要移动节点的hash值与老的容量进行 "&" 运算,如果结果为0,那在扩容以后的数组中,还是同样的位置。
                               if ((e.hash & oldCap) == 0) {
                                    if (loTail == null)
                                        loHead = e;
                                    else
                                        loTail.next = e;
                                    loTail = e;
                                }
                        //如果运算不为0,则扩容后的索引位置为:老表的索引位置+扩容之前的
                                else {
                                    if (hiTail == null)
                                        hiHead = e;
                                    else
                                        hiTail.next = e;
                                    hiTail = e;
                                }
                            } while ((e = next) != null);
                            if (loTail != null) {
                                loTail.next = null;
                                newTab[j] = loHead;
                            }
                            if (hiTail != null) {
                                hiTail.next = null;
                                newTab[j + oldCap] = hiHead;
                            }
                        }
                    }
                }
            }
            return newTab;
        }
    

    在源码中有这么一段(e.hash & oldCap) == 0,怎么理解这个呢,我们通过下面的来看一下。

    假设扩容之前 数组大小为16
    假如有两个key:
    key1(hash&hash>>>16) 0000 0000 0011 0011 0111 1010 1011 1000
    key2(hash&hash>>>16) 0000 0000 0011 0011 0111 1010 1010 1000
              &
        length-1 = 15    0000 0000 0000 0000 0000 0000 0000 1111
    ——————————————————————————————————————————————————————————————————
                                                      key1: 1000 转成十进制 8
                                                      key2: 1000 转成十进制 8
                                                      
    哈希冲突的两个key,在扩容到32之后
    key1(key的hash&hash>>>16) 0000 0000 0011 0011 0111 1010 1011 1000
                                                        
    key2(key的hash&hash>>>16) 0000 0000 0011 0011 0111 1010 1010 1000
               &
             length-1 = 31    0000 0000 0000 0000 0000 0000 0001 1111
    ——————————————————————————————————————————————————————————————————
                                                       key1:   1 1000 转乘二进制 24=16+8
                                                       key2:   0 1000 转乘二进制 8
    

    我们可以发现,代码中利用的是 hash & oldCap(key的哈希值 & 扩容之前的哈希桶长度),而不是利用hash & newCap- 1 ,为什么要这样做呢?

    通过上面的情况我们应该可以看出,原本冲突的两个key,在扩容以后的位置是由新增的那一个bit位来决定的。所以直接用 hash & 扩容之前的容量来判断新增的那个bit位是0 还是 1 ,就可以知道是在原来的位置上,还是在 原来位置+oldCap 位置上。

    哈希冲突的两个key,在扩容到32之后
    key1(key的hash&hash>>>16) 0000 0000 0011 0011 0111 1010 1011 1000
      
    key2(key的hash&hash>>>16) 0000 0000 0011 0011 0111 1010 1010 1000
               &
             oldCap=16        0000 0000 0000 0000 0000 0000 0001 0000
    --------------------------------------------------------------------
                                                         key1: 1 0000
                                                         key2: 0 0000
    

    通过以上,我们可以看到,原来在同一个位置上的两个key,通过扩容之后的位置要不在原来的位置上,要不在oldCap+原位置上。这样不需要像JDK1.7的实现那样重新计算hash,只需要看看原来的hash值新增的那个bit是1还是0就好了,是0的话索引没变,是1的话索引变成“原索引+oldCap”。同时也是更加充分的说明了,为什么HashMap的容量必须是2的n次方了。

    知道HashTable的同学应该也知道,HashTable默认的容量是11,每次扩容之后的容量是length*2+1。HashTable在设计上,想通过保证哈希桶数组为质数,来尽可能保证哈希散列均匀。这里也就不对HashTable的容量为什么是质数进行展开,感兴趣的同学可以查一下资料,为什么对质数求模会比对合数求模的哈希散列效果要好,可以参考http://blog.csdn.net/liuqiyao_01/article/details/14475159。HashMap虽然也是想通过类似HashTable中的哈希算法那样(通过对质数求模来)降低哈希冲突,显然HashMap中的哈希算法要比HashTable中哈希算法要优秀很多。

    HashMap保证哈希数组的大小为2的n次方,这个设计确实非常的巧妙,利用2的n次方 - 1 二进制码全为1这个特性,在扩容的时候,既省去了重新计算hash值的时间,而且同时,由于新增的1bit是0还是1可以认为是随机的,因此resize的过程,均匀的把之前的冲突的节点分散到新的bucket了。

    hashMap_resize.png

    总结

    1. 扩容是一个特别耗性能的操作,所以当我们在使用HashMap的时候,估算map的大小,初始化的时候给一个大致的数值,避免map进行频繁的扩容。
    2. HashMap是线程不安全的,不要在并发的环境中使用HashMap,使用ConcurrentHashMap或者SynchronizedMap
    3. 负载因子是可以修改的,也可以大于1,但是建议不要轻易修改。

    相关文章

      网友评论

          本文标题:重新认识JDK1.8中的HashMap

          本文链接:https://www.haomeiwen.com/subject/okenyhtx.html