温故----HashMap原理补坑

Java HashMap

之前介绍过ArrayList 和 LinkedList这两种数据结构,ArrayList具有通过索引访问快速,扩容、增删效率不高;而LinkedList增删扩容比较快速,而访问只能从头或尾结点依次遍历查找效率不高。
有没有一种兼具二者优点的结构?那就是HashMap,通过LinkedList存储具体的Entry,然后数组存储LinkedList的头结点,通过数组索引快速定位,LinkedList方便增删扩容。

小结:
1.HashMap线程不安全,典型的jdk 1.7扩容rehash 头插法,链表有可能会出现环导致死循环
2.HashMap 允许 key, value 为 null,保存在第一个桶中
3.使用Key的hashCode进行hash()计算桶的索引,hash碰撞发生之后使用Key的equals方法查找比对key.
4.保证table数组长度为2的整数冥,便于高效hash&(length-1)计算桶索引,以及保持hash值的散列性(length-1)的二进制都是1,进行&运算保持了原hash值的低位.

本文代码的Java版本

HashMap的基本组成

Node[] table -->是一个Node节点数组,其大小总是2的N次幂
Node这个节点包含Key,Value 以及下一个Node的引用--->明显这是一个链表的节点

    /**
     * The table, initialized on first use, and resized as
     * necessary. When allocated, length is always a power of two.
     * (We also tolerate length zero in some operations to allow
     * bootstrapping mechanics that are currently not needed.)
     */
    transient Node[] table;
    
    static class Node implements Map.Entry {
        final int hash;
        final K key;
        V value;
        Node next;

        Node(int hash, K key, V value, Node next) {
            this.hash = hash;
            this.key = key;
            this.value = value;
            this.next = next;
        }

        public final K getKey()        { return key; }
        public final V getValue()      { return value; }
        public final String toString() { return key + "=" + value; }

        public final int hashCode() {
            return Objects.hashCode(key) ^ Objects.hashCode(value);
        }

        public final V setValue(V newValue) {
            V oldValue = value;
            value = newValue;
            return oldValue;
        }

        public final boolean equals(Object o) {
            if (o == this)
                return true;
            if (o instanceof Map.Entry) {
                Map.Entry e = (Map.Entry)o;
                if (Objects.equals(key, e.getKey()) &&
                    Objects.equals(value, e.getValue()))
                    return true;
            }
            return false;
        }
    }

我们通过一次简单分析put(key,value)插入值看下HashMap是如何运转:

    public V put(K key, V value) {
        //这里对key进行了一次自己hash计算
        return putVal(hash(key), key, value, false, true);
    }
    /**
     * 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[] tab; Node p; int n, i;
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
        //下面这是最简单的case,table数组不为空,且 hash&(n-1)这个索引位置为空即简单使用key,value创建一个新Node赋值给table[i]
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        else {
            //下面的逻辑用于hash碰撞之后如何查找对应的Key位置
            //经过上面的逻辑 几个重要的变量值对应:
            //p --> 该key计算的索引找到的table[i]的Node引用
            Node e; K k;
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                //这里判断p这个Node的key,跟将要插入的key完全一致
                //即该key已经存在于HashMap,就是p这个节点
                e = p;
            else if (p instanceof TreeNode)
                e = ((TreeNode)p).putTreeVal(this, tab, hash, key, value);
            else {
                //这里开始从链表的头结点p开始遍历查找
                for (int binCount = 0; ; ++binCount) {
                    if ((e = p.next) == null) {
                        //如果遍历到链表尾部还没说明key需要新增,直接在链表尾部插入一个新建Node
                        p.next = newNode(hash, key, value, null);
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        //这里是java 1.8之后在链表节点数量到达TREEIFY_THRESHOLD = 8之后会将链表转换为红黑树作为Hash桶
                        treeifyBin(tab, hash);
                        break;
                    }
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        //跟上面判断一样找到了该key已经存在map内的node
                        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;
    }

从上述简单的put一个key,value的逻辑到HashMap可以看出大概步骤:
1.对key进行hash计算得到hash值 --->记为hash
2.通过上面key的hash值进行索引计算获得key在table数组的索引值i = hash&(table.length-1)
3.若table[i]这个值为空,则直接使用key,value创建新的Node插入索引i这个位置即完成了一次HashMap的键值对存储操作
4.若发生hash碰撞(两个key的hash值一致导致计算出的在table数组的索引值i也一样),那么需要遍历table[i]为head的链表且使用key.equals进行比对查找Node,不存在Key一致的Node就在链表末尾新建插入一个Node
5.在java 1.8的实现,当链表节点数>=8 && table.length>=64会将链表转换为红黑树来加快查找
6.若HashMap的实际key-value数大于threshold会触发扩容

图自美团技术团队的知乎 --- Java 8系列之重新认识HashMap

看到这里是不是需要一张图直观展示下HashMap的结构:

图自百度图片搜索

HashMap的几个要素

计算Key索引值的hash函数

hash函数的散列越均匀,Node就越均匀的散布在不同的链表上;避免出现有些链表没有值,有些链表挂了长长一串。

    //jdk 1.7一顿位操作也是为了将高位参与进来散列
    static final int hash(int h) {
        h ^= k.hashCode(); 
        h ^= (h >>> 20) ^ (h >>> 12);
        return h ^ (h >>> 7) ^ (h >>> 4);
     }
    static int indexFor(int h, int length) {  //jdk1.7的实现
         //因为数组的length是保持为2的整数次方,所以(length-1)的二进制总是最高位为0,其它位是1,按位与操作可以保证得到的索引值在0~length-1; 使用位运算性能高于取余(h % (length-1))
         return h & (length-1);  
    }

    //jdk 1.8实现:
    //1.计算hash值
    static final int hash(Object key) {
        int h;
        //优化高位参与计算的方式
        //并且在这看出允许key=null,其hash值为0 存储在table[0]
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }
    //2.计算索引 i = (table.length - 1)&hash;

如何保证Table长度是2的n次方

        //jdk 1.6 使用1进行循环左移来找到第一个大于给定容量的2的冥作为初始容量
     // Find a power of 2 >= initialCapacity
     int capacity = 1;
     while (capacity < initialCapacity)
         capacity <<= 1;

    // jdk 1.8在指定容量时,会使用tableSizeFor初始threshold为第一个大于指定容量的2的n次幂,在putVal触发首次resize会创建threshold大小的table
    public HashMap(int initialCapacity, float loadFactor) {
        if (initialCapacity < 0)
            throw new IllegalArgumentException("Illegal initial capacity: " +
                                               initialCapacity);
        if (initialCapacity > MAXIMUM_CAPACITY)
            initialCapacity = MAXIMUM_CAPACITY;
        if (loadFactor <= 0 || Float.isNaN(loadFactor))
            throw new IllegalArgumentException("Illegal load factor: " +
                                               loadFactor);
        this.loadFactor = loadFactor;
        this.threshold = tableSizeFor(initialCapacity);
    }
    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;
    }

这里的tableSizeFor函数很有趣,初看比较懵逼,仔细想想确实很高效;

n |= n >>> 1; //这一句执行完会保证n的次高位变成1,然后最高和次高位都是1,得到两个1
n |= n >>> 2; //上面保证得到了两个1,所以可以直接执行右移2位;执行完保证得到最高位的4位都是1(如果最高位位置满足的话)
n |= n >>> 4;每次都可以产生2倍的高位1数目

图自https://blog.csdn.net/fan2012huan

扩容操作

理解扩容操作前需要认识下面几个属性:

//实际存储的key-value键值对的个数
transient int size;
//阈值,当table == {}时,该值为初始容量(初始容量默认为16)或者大于构造函数指定容量的2的n次方;当table被填充了,也就是为table分配内存空间后,threshold一般为 capacity*loadFactory。HashMap在进行扩容时需要参考threshold,后面会详细谈到
int threshold;
//负载因子,代表了table的填充度有多少,默认是0.75
final float loadFactor;
//用于快速失败,由于HashMap非线程安全,在对HashMap进行迭代时,如果期间其他线程的参与导致HashMap的结构发生变化了(比如put,remove等操作),需要抛出异常ConcurrentModificationException
transient int modCount;

在HashMap初始化时table都是为空数组,当首次putVal函数触发resize(table==null || size >threshold );在resize函数内部初始化创建数组或者对已有扩容才并且更新threshold值:

final Node[] resize() {
        Node[] oldTab = table;
        int oldCap = (oldTab == null) ? 0 : oldTab.length;
        int oldThr = threshold;
        int newCap, newThr = 0;
        if (oldCap > 0) {
            //这里是真正的扩容,下面两个else case都是触发首次创建初始化table数组
            if (oldCap >= MAXIMUM_CAPACITY) {
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
            //将table数组扩容一倍
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
                //这里为啥要oldCap >=16才将threshold也一起扩容?
                //避免极小容量初始化比如初始化成4以下的容量?
                newThr = oldThr << 1; // double threshold
        }
        else if (oldThr > 0) 
             //这里的case是构造函数指定了初始threshold为一个最接近指定容量的2的n次方值(上面分析了)
             //初始创建数组的容量即为tableSizeFor来的值
            // initial capacity was placed in threshold
            newCap = oldThr;
        else {
             //这里就是数组为空并且也未指定初始容量的case,会更新容量为默认16,更新threshold             
             // zero initial threshold signifies using defaults
            newCap = DEFAULT_INITIAL_CAPACITY;
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
        }
        if (newThr == 0) {
            float ft = (float)newCap * loadFactor;
            newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                      (int)ft : Integer.MAX_VALUE);
        }
        threshold = newThr; //更新threshold
        @SuppressWarnings({"rawtypes","unchecked"})
            Node[] newTab = (Node[])new Node[newCap];
        table = newTab;  //创建并赋值新的扩容后的table数组
        //将oldTab元素rehash到newTab 思考下
        //下面单独分析如何rehash
        return newTab;
    }

这里思考下,上面注释也看到了如果table == null进入resize会根据threshold是否有初始值进行table数组首次创建:容量=默认16或者根据tableSizeFor计算的初始threshold值;对已有数组扩容的逻辑则是直接左移1位即扩容一倍。
那么创建新的扩容后数组,然后跟ArrayList一样需要将旧数组内的元素重新放进新数组。这里有个问题原本的Node相当于要重新hash散列到新的数组里面去?要将每个Node存放到新数组的索引 i = Node.hash & (newLength - 1);如何快速处理这个reHash操作?

  //方案一 jdk 1.7实现:
  //核心其实就是遍历链表的时候,重新计算新数组的索引然后将Node插入到新数组索引对应链表的head(这样会导致原本在链表尾部的rehash之后会出现在新链表的头部)
  void transfer(Entry[] newTable) {
        Entry[] src = table;                   //src代表旧的数组
        int newCapacity = newTable.length;
        for (int j = 0; j < src.length; j++) { 
            Entry e = src[j];             ////遍历旧数组每个元素e
            if (e != null) {
                src[j] = null;//释放旧数组的对象引用便于垃圾回收
                do {
                    Entry next = e.next;
                    int i = indexFor(e.hash, newCapacity); //!!重新计算在新数组的索引值i
                    e.next = newTable[i]; //将元素e的next指向新数组的newTable[i]
                    newTable[i] = e;      //再将元素e赋值给newTable[i]
                    e = next; 
                } while (e != null);
            }
        }
    }

      //方案二,即jdk 1.8上面的resize函数后半部分的实现
     //其实这里也有个比较有意思的算法,rehash计算node在新数组的索引index,仔细思考其实只跟newCap-1最高位也就是oldCap二进制的最高位有关;如果这个最高位是0则索引值不变,为1则oldIndex+oldCap即可不需要重复计算hash(下面提供了一张图帮助理解)
        if (oldTab != null) {
            for (int j = 0; j < oldCap; ++j) {
                Node 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)e).split(this, newTab, j, oldCap);
                    else { // preserve order
                        Node loHead = null, loTail = null;
                        Node hiHead = null, hiTail = null;
                        Node next;
                        do {
                            //通过上述原理进行位运算根据最高位0或1,将链表拆分成loHead(0)和 hiHead(1)两个链表
                            next = e.next;
                            if ((e.hash & oldCap) == 0) {
                                if (loTail == null)
                                    loHead = e;
                                else
                                    loTail.next = e;
                                loTail = e;
                            }
                            else {
                                if (hiTail == null)
                                    hiHead = e;
                                else
                                    hiTail.next = e;
                                hiTail = e;
                            }
                        } while ((e = next) != null);
                        //链表拆分完成,将loHead和hiHead分别赋值到新数组对应索引就行了(保证了原链表元素的顺序)
                        if (loTail != null) {
                            loTail.next = null;
                            newTab[j] = loHead;
                        }
                        if (hiTail != null) {
                            hiTail.next = null;
                            newTab[j + oldCap] = hiHead;
                        }
                    }
                }
            }
        }

Jdk 1.8 rehash的重点需要弄明白下面这张图,只需要根据hash&oldCap是0或者1就可以确定新的index值,不需要额外的hash运算:


rehash索引的变化

HashMap的树化

在Jdk 1.8优化了长链表的查找效率:

    //jdk 1.8增加了两个flag当链表节点数超过>=8 && table.length >=64 会将链表转为红黑树;<=6则会再次将红黑树转换为链表结构
    static final int TREEIFY_THRESHOLD = 8;
    static final int UNTREEIFY_THRESHOLD = 6;
    static final int MIN_TREEIFY_CAPACITY = 64;
    
  //在转化为树前多了判断table不为空且length要大于64;否则仅做扩容rehash即可
   if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
            resize();

具体链表如何转换为红黑树的分析可以参考:
1.Java 集合深入理解(17):HashMap 在 JDK 1.8 后新增的红黑树结构
2.HashMap-红黑树转换-源码解读 - Java8

为啥选择8作为链表转树的临界值?
   原作者的注释:
/* Because TreeNodes are about twice the size of regular nodes, we
     * use them only when bins contain enough nodes to warrant use
     * (see TREEIFY_THRESHOLD). And when they become too small (due to
     * removal or resizing) they are converted back to plain bins.  In
     * usages with well-distributed user hashCodes, tree bins are
     * rarely used.  Ideally, under random hashCodes, the frequency of
     * nodes in bins follows a Poisson distribution
     * (http://en.wikipedia.org/wiki/Poisson_distribution) with a
     * parameter of about 0.5 on average for the default resizing
     * threshold of 0.75, although with a large variance because of
     * resizing granularity. Ignoring variance, the expected
     * occurrences of list size k are (exp(-0.5) * pow(0.5, k) /
     * factorial(k)). The first values are:
     *
     * 0:    0.60653066
     * 1:    0.30326533
     * 2:    0.07581633
     * 3:    0.01263606
     * 4:    0.00157952
     * 5:    0.00015795
     * 6:    0.00001316
     * 7:    0.00000094
     * 8:    0.00000006
     * more: less than 1 in ten million
*/

即通常情况下hash算法能够保证所有元素比较均匀的散布在所有桶内;正常情况桶内元素达到8的概率为:0.00000006极低的概率。而个人认为在节点数比较少的情况下链表查询和树搜索效率相差不大,但是树形结构会增加复杂度和存储空间综合情况选了8作为临界值。

String、Integer这样的包装类适合作为Key

String是不可变的,也是final的,而且已经按照规范实现了equals()和hashCode()方法了。其他的wrapper类也有这个特点。不可变性防止键值改变,如果键值在放入时和获取时返回不同的hashcode的话,那么就不能从HashMap中找到你想要的对象。

参考内容

  1. 美团技术团队- Java 8系列之重新认识HashMap
  2. HashMap源码注解 之 静态工具方法hash()、tableSizeFor()
  3. Java:手把手带你源码分析 HashMap 1.7

你可能感兴趣的:(温故----HashMap原理补坑)