zoukankan      html  css  js  c++  java
  • HashMap源码剖析(基于JDK1.8分析)

      HashMap是基于Hash算法通过键值方式存储数据的数据集合。具体实现原理可以根据下面图来一步步认识,首先键值对(key, value)元素都会存储在一个动态数组中,这个就跟ArrayList结构相似,但是不同于ArrayList的是HashMap的元素为键值对形式,所以HashMap的操作都会围绕key来执行,那这样在查找时,如果不做特殊处理,就需要遍历数组,再比较key从而得到对应元素,势必会导致HashMap性能极低,所以HashMap通过Hash算法将key和数组下标关联起来,通过key计算出数组下标,即n=f(k) (n是数组下标。f是Hash算法函数,k是key,具体如何通过key计算出数组下标,在剖析源码的时候再讲),从而快速定位到元素,实现效果如下图,因为HashMap源码键值对元素对象是Node,所以图中也用Node<K,V>表示。

      但是这样的方式会不会出现不同的key却计算出相同的数组下标了,答案是会的。这种不同的key计算出了相同Hash值,从而导致数组下标相同的行为称之为哈希冲突,HashMap为了解决这种冲突,引入了单链表,将这些数组下标相同的元素,存在同一个链表中,这个链表也被称为哈希桶,这样HashMap的查找就变成通过key计算数组下找到链表,再遍历链表找到对应元素。HashMap的结构也变成了下图的形式。

      上图的结构已经解决了一部分哈希冲突问题,但是如果元素特别多,这样如果哈希冲突特别严重,也就是大量key计算出了相同的数组下标,就会导致链表特别长,这样即使通过key定位到了对应的链表,但是由于链表过长,遍历链表查找元素带来的资源开销就会变大,为了解决这种问题,HashMap一方面通过改进Hash算法,让元素数组中分布更均匀,另一方面,在JDK1.8中引入了红黑树,当链表长度大于等于8时,就会将链表转化为红黑树。反之,当红黑元素少于6时,会转换为链表。所以HashMap最终的底层结构就变成下图。

      上面我们已经弄清楚了HashMap的实现原理,HashMap这种存储结构被称为哈希表,在其他语言中都比较普遍的存在,其实,HashMap的精华部分并不在于自身的实现结构,而在于为了极致提高集合的性能而做的设定和算法,比如负载因子为0.75,初始容量为是2的次幂,链表最长为8,扩容机制,哈希算法等。这些设定我们可以在源码中一个个解析,并弄清楚它们存在的意义。

    二、HashMap源码解析

      解析HashMap源码时,只拿出部分关键的代码说明,不通篇去解释实现。并且会将部分代码的排版做一下修改,方便阅读和注释。源码主要针对构建、插入、查找这三个部分说明,HashMap最重要的部分也在这三个之中。

      1、HashMap的构造
    • HashMap提供了三个构造函数,源码如下图示例,有参构造函数有两个参数,分别是initialCapacity和loadFactor,initialCapacity是指定的初始容量,这个initialCapacity并不是实际的初始容量,实际的初始容量始终是2的n次幂(initialCapacity 也不和size相等),具体到后面插入元素,table初始化再说。loadFactor是负载因子,通这两个参数分别初始化了成员变量 loadFactor和threshold。
    • threshold是扩容阈值,也就是HashMap中元素的个数超过threshold,就会触发扩容,threshold是通过initialCapacity和tableSizeFor()方法计算得出,其范围在1到2的30次方之间,tableSizeFor()方法计算得出的扩容阈值也同样不是实际的扩容阈值,具体原因同样留到table初始化再说。
    • 在无参构造方法中,只是初始化loadFactor为默认值,也就是0.75,为什么没有threshold呢?因为在没有指定initialCapacity的情况下,HashMap的初始容量会默认使用DEFAULT_INITIAL_CAPACITY(默认值为16)来计算threshold,这个计算操作会在HashMap对象第一次插入元素时执行,计算方式是threshold = 实际初始容量 * 负载因子,当然当实际初始容量到达MAXIMUM_CAPACITY(默认值为2的30次幂)时,threshold = Integer.MAX_VALUE;
    //构造
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;  //默认容量16
    static final int MAXIMUM_CAPACITY = 1 << 30;         //最大容量 2的30次方
    static final float DEFAULT_LOAD_FACTOR = 0.75f;     //默认负载因子
    final float loadFactor;     //负载因子
    int threshold;        //扩容阈值                
    transient Node<K,V>[] table;  //哈希桶数组
    public HashMap() {
        this.loadFactor = DEFAULT_LOAD_FACTOR;
    }
    public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }
    public HashMap(int initialCapacity, float loadFactor) {
        //initialCapacity取值范围是0~MAXIMUM_CAPACITY
        if (initialCapacity < 0) { 
            throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity);
        }                                       
        if (initialCapacity > MAXIMUM_CAPACITY) {    
            initialCapacity = MAXIMUM_CAPACITY;
        }
        //负载因子大于且为float类型
        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; //   cap = 20  n=19  0001 0011;
        n |= n >>> 1;  //n = n | n >>>1    0001 0011 | 0000 1001 = 0001 1011
        n |= n >>> 2;    // n = n | n>>>2   0001 1011 | 0000 0110 = 0001 1111            
        n |= n >>> 4;     // n = n | n>>>4   0001 1111 | 0000 0001 = 0001 1111
        n |= n >>> 8;   // n = n | n>>>8   0001 1111 | 0000 0000 = 0001 1111
        n |= n >>> 16;  // n = n | n>>>16  0001 1111 | 0000 0000 = 0001 1111  n= 31
        int n1 = (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;  // n1 = 32  大于等于给定容量的最小2的次幂值
        return (n < 0) ? 1 : n1;
    }
    • tableSizeFor()有什么作用?  

      构造函数中并没有做太多的事情,值得一说的是tableSizeFor()方法,tableSizeFor()方法通过initialCapacity的计算出一个值并赋给threshold,在方法中将n通过无符号右移运算之后与自身进行或运算,然后将得出的结果+1返回,在代码的注释中,我假设cap是20,n就是19,二进制就是0...0001 1011,然后通过位运算得到最后的结果为0...0001 1111,也就是31,在这个运算过程中,发现会将离最高位最近的1后面的位全部变成1,也就是如果n最初是0100,结果就是0111,如果是1000,就会是1111(int类型实际是32位,这里省略前面位数),因为cap是int类型,是32位,所以当n>>>16时,已经覆盖到32位,往后在增加移位也没有意义,那么k位1111这种二进制都是2的k次幂减1,在最后的三目运算中,又加了1,所以最终结果都会是2的k次幂。在注释测试中最后结果是32,是2的5次幂,也就是说tableSizeFor()方法最终的返回结果都是大于等于initialCapacity的最近2的次幂值,至于为什么是2的次幂值,后面再说。HashMap的作者巧妙的利用位运算实现了求大于等于给定容量的最小2的次幂值,位运算又是最接近计算机底层的运算,所以效率会变的很高,日常开发中,如果遇到类型的求值,这个算法是很值得我们借鉴的,在HashMap中有很多类似通过位运算提高性能的代码,这也是HashMap追求极致性能的体现。

    2、HashMap的插入
    public V put(K key, V value) {
        return putVal(hash(key), key, value, false, true);
    }
    //计算hash值
    static final int hash(Object key) {
        int h = key.hashCode();
        int hash = h ^ (h >>> 16);
        return (key == null) ? 0 : hash;
    }

      如上代码,HashMap的put方法第一步就是根据key来计算hash值,即hash(Object key)方法,可以看到该方法首先获取key的hashcode,在通过hashcode无符号右移16位之后和自身异或运算得到一个新的hash值返回,null值统一返回0。

    final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
        ...
        if ((p = tab[i = (n - 1) & hash]) == null) {
            tab[i] = newNode(hash, key, value, null);
        }
        ...
    }
    • 那么hash(Object key)的作用是什么?既然都是hash值为什么不直接返回key的hashCode呢?

      首先,通过putVal方法的源码,知道这个hash方法返回的hash值实际是用来计算数组下标,也就是上面这段代码中的i = (n - 1) & hash(实际就是取模运算i = hash % n,位运算效率更高),其中n是数组长度,默认是16,i是数组下标,我们假设一个hash值是654321,n是16,来通过二进制完成这个计算,其过程如下:

      

      通过上面的二进制演示,发现参与运算的只有hash值后四位0001和n-1的后四位1111,那如果n是32,参与运算就是10001和11111,也就是说,无论hash值是多少,决定最终数组下标的只有参与运算的位,在正常情况下,我们的n取值都不会超过2^16 - 1(也就是00000000000000000 1111111111111111),所以hash值的高16位在这个范围内都不会参与运算影响最终结果,key.hashCode()已经一个散列值,假设它是分布均匀的,那它的高位不参与运算,那势必这种均匀就会被打乱,这显然会使得数组下标的分布不够均匀,从而产生更多的哈希冲突为了解决这个问题,就有hash(Object key)方法,继续通过二进制还原hash = h ^ (h >>> 16)运算,我们假设h是654321,其过程如下:

           

      上面的运算中,发现h无符号右移16位之后,高16位变到了低16位,在通过异或运算,将高16位混合到了低16位中,这样在i = (n - 1) & hash的运算中,高16位也就参与了运算,影响了最终结果,用源码的注释解释就是将高16位的特性混合到低16位中,使之计算出的数组下标更加均匀,减少了哈希冲突。这里还有一个问题就是为和要用异或运算,而不用&运算或者|运算呢?因为这两个运算相比于异或,&会使结果偏向0,|会使结果偏向1,都不符合均匀的条件。

      介绍完hash(Object key)方法之后,就正式进入put方法的主体putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict)方法,方法参数中,除了已知的hash、key、value,还有onlyIfAbsent和evict,onlyIfAbsent决定key重复时是否覆盖原value,默认是false,evict是插入之后回调方法的参数,与我们的内容无关,下面是putVal()方法的源码,为了方法阅读,摘要源码中的部分变量并加了花括号、换行以及注释。

    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;  //默认容量16
    static final int MAXIMUM_CAPACITY = 1 << 30;         //最大容量 2的30次方
    static final float DEFAULT_LOAD_FACTOR = 0.75f;     //默认负载因子
    static final int TREEIFY_THRESHOLD = 8;        //链表转红黑树阈值
    final float loadFactor;     //负载因子`
    int threshold;        //扩容阈值                
    transient Node<K,V>[] table;  //桶数组
    transient int size;      //实际元素个数
    final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
        Node<K,V>[] tab; 
        Node<K,V> p; 
        int n, i; //n数组长度, i是数组下标
        
        //1、table数组判空,如果是空,通过扩容方法resize()初始化table,并将初始化后的table的length赋予变量n;
        if ((tab = table) == null || (n = tab.length) == 0) {
            n = (tab = resize()).length;
        }
        //2、通过hash计算数组下标,即插入元素在数组中要存放的位置,如果数组下标对应的位置是null,则直接创建链表,即newNode(hash, key, value, null);
        //    数组下标对应的位置如果不为空,说明产生哈希冲突,则进入else{...}块处理冲突;
        if ((p = tab[i = (n - 1) & hash]) == null) {
            tab[i] = newNode(hash, key, value, null);
        } else {
            Node<K,V> e; //声明对象e,用于后面判断key重复 
            K k;
            if (p.hash == hash &&((k = p.key) == key || (key != null && key.equals(k)))) {
                //如果要插入的元素的key和该数组下标i处的第一个元素p的key相同,将元素p赋予对象e;
                e = p;
            } else if (p instanceof TreeNode) {
                //p元素类型是红黑树时,采用红黑树的插入方法
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            } else {
                //p元素类型是链表时,循环到链表尾部插入新元素,如果循环过程中发现key重复,中止循环,并且将e=p.next;
                //如果循环过程中,链表长度binCount大于等于转换阈值TREEIFY_THRESHOLD-1,触发转换,该数组下标处i的链表转为红黑树
                for (int binCount = 0; ; ++binCount) {
                    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;
                }
            }
            //处理重复key,是否覆盖原value,并返回原value
            if (e != null) { // existing mapping for key
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;
            }
        }
        ++modCount;
        //添加完元素之后,判断元素个数size是否大于扩容阈值threshold,大于触发扩容方法resize();
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }

      通过源码阅读,putVal()方法可分为下面几步:

    1. 在第一次put元素时,初始化存储数组table。
    2. 通过hash值计算新元素的在数组中存放位置,即数组下标i。
    3. 如果存放位置tab[i]是null,直接创建链表(tab[i] = newNode(hash, key, value, null);); 否则判断元素的类型是否链表还是红黑树,是红黑树,采用红黑树的插入方法;否则循环链表p,比较新元素的key是否已经存在,存在结束循环,处理重复key,不存在,在链表p尾部插入新元素(p.next = newNode(hash, key, value, null););
    4. 新元素插入完成后,判断是否需要扩容(++size > threshold);

     在上面的插入过程中,是围绕HashMap的结构进行,在HashMap的实现原理中,我们已经描述过数据结构,现在看源码中的实现,在第一次插入时,源码中通过resize()方法初始化数组Node<K,V>[] table,即tab = resize(),resize()是HashMap的扩容方法,当元素个数到达阈值时,HashMap会通过该方法重新计算并初始化数组的大小,下面是部分resize()方法的源码:

    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;  //默认容量16
    static final float DEFAULT_LOAD_FACTOR = 0.75f;     //默认负载因子
    final float loadFactor;     //负载因子`
    int threshold;        //扩容阈值                
    transient Node<K,V>[] table;  //桶数组
    transient int size;      //实际元素个数
    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) {
            ...
        } else if (oldThr > 0) {
            // 有参数构造时,将构造参数计算出的扩容阈值赋给newCap
            newCap = oldThr;
        } else {               
            // 无参构造,使用默认值
            newCap = DEFAULT_INITIAL_CAPACITY; // 16
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); //16 * 0.75 = 12
        } 
        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;
        ...
        return newTab;
    }

      如上源码,table是在扩容方法中进行初始化:

    • HashMap无构造参数时,即oldCap=0,oldThr=0,初始化数组大小为16,扩容阈值为DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY=16 * 0.75 = 12。
    • 当有构造参数时,前面在HashMap的构造篇幅中,讲过初始化了两个变量,分别是负载因子loadFactor和扩容阈值threshold,loadFactor直接指定,threshold通过tableSizeFor(int cap)方法计算出一个大于等于cap的2最小次幂值,并且也说明构造参数中指定初始化容量initialCapacity不是HashMap实际初始化容量,这里我看到实际初始化容量变成了oldThr也就是threshold,而threshold始终是2的次幂值,所以HashMap中数组的容量始终是2的n次方,另外也说了有参构造参数初始化的扩容阈值threshold也不是实际的扩容阈值,实际扩容阈值即源码float ft = (float)newCap * loadFactor的ft;
    static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;
        final K key;
        V value;
        Node<K,V> next;
    
        Node(int hash, K key, V value, Node<K,V> 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; }
        //重写hashCode
        public final int hashCode() {
            return Objects.hashCode(key) ^ Objects.hashCode(value);
        }
    
        public final V setValue(V newValue) {
            V oldValue = value;
            value = newValue;
            return oldValue;
        }
        //重写equals
        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;
        }
    }

      有了哈系桶数组Node<K,V>[],就需要弄清楚Node<K,V>类型,上面是Node<K,V>类源码,Node<K,V>实现了Map.Entry<K,V>接口,有四个成员变量,除了key和value,hash是通过hash(Object key)计算出的,next指向下一个Node<K,V>,实际上就是一个单链表,类中重写了hashCode、toString、equals方法。hashCode由key和value的hashCode异或得出,toString变成key和value组成的字符串, equals变成同时比较key和value。

    • 为什么数组容量始终是2的次幂值?

      在前面的计算中,发现无参初始化时,数组容量是16,有参初始化时,初始容量=初始化的扩容阈值,两者都是2的次幂值,HashMap这么做有什么原因呢?首先,数组容量除了定义数组长度,另外就是参与数组下标的计算,即i = (n - 1) & hash;下面一段模拟代码,hash分别是654321~654325,n分别去8,13,14,16。

    public class ResizeDemo {
        public static void main(String[] args) {
            int n = 16;
            example(654321, n);
            example(654322, n);
            example(654323, n);
            example(654324, n);
            example(654325, n);
        }
    
        public static void example(Integer hash, Integer n) {
            System.out.println("数组下标:" +((n - 1) & hash));
        }
    }

    计算结果

     

    计算结果显示,当n是8和16时,数组下标是均匀分布的,所以根据现象得出结论就是初始化容量是2的次幂值可以有效的是数组元素均匀分布,减少哈希冲突

    下面来分析产生现象的原因,借用前面还原i = (n - 1) & hash的二进制运算的一张图,如上图,发现在n为2的次幂值时,&运算下面始终都是0...1111或者0...0111这种形式,前面我们已经知道hash是通过hash(Object key)方法将高位特性混入到低位得出的一个均匀的hash值,当进行&运算时,只有&下面参与运算的值是1时,这种均匀特性才会保留,n是13或者14时,就是0...1101或0...1110,非1的位计算结果是0,这种均匀特性消失,部分数组位置总是为空,结果也不会均匀。在前面的篇幅中,也说过i = (n - 1) & hash实际就是取模运算i = hash % n;&相对%效率更高,当n不是2的次幂值时,实际(n - 1) & hash也就不是hash % n运算了,自然不会分布均匀。

    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;
            } else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY) {
                //扩容时新容量newCap=oldCap * 2 
                newThr = oldThr << 1; // 新阈值newThr=oldThr * 2  
            }         
        }
        ...
        if (oldTab != null) {
            // 把每个bucket都移动到新的buckets中
            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 { // preserve order
                        // 链表优化rehash的代码块
                        ...
                        // 原索引+oldCap放到bucket里
                        if (hiTail != null) {
                            hiTail.next = null;
                            newTab[j + oldCap] = hiHead;
                        }
                    }
                }
            }
        }
    }

    除了上述分布均匀的原因,还有一个扩容的原因,看上面这段代码中扩容前后数组,HashMap扩容时,是将数组容量变为原来的2倍,假设扩容前数组容量是16,扩容后就是32,给定两个元素hash值是654321和654322,那么通过i = (n - 1) & hash计算,16容量下标分别是1和2,32容量下标分别是17和18,根据计算结果得出新数组下标=原数组下标 + 原数组容量,在扩容代码之后,会将链表元素重新计算数组下标分布到新数组中,也就是常说的rehash,在扩容容量为2的次幂值,部分元素的新数组下标=原数组下标+原初始容量,即newTab[j + oldCap] = hiHead;也就不用rehash计算了,优化了扩容性能。这个问题结论也解释了HashMap构造中tableSizeFor(int cap)方法的意义。

    • 综上得出问题答案:

      1、使得数组下标分布更加均匀,减少哈希冲突
      2、扩容后减少rehash运算,提高扩容性能

    • HashMap的扩容机制

    下面是HashMap扩容方法的源码,HashMap在第一次插入元素和元素个数size大于扩容阈值threshold时会触发扩容方法resize(),resize方法流程总的来说有三步,下将源码分隔成三部分:

    1. 计算新新数组的容量和阈值,在源码中新数组容量计算时,如果第一次插入元素时,会使用默认容量或者构造方法指定容量计算出的容量,否则就使用newCap = oldCap << 1即newCap = oldCap * 2,如果数组容量达到最大值MAXIMUM_CAPACITY,则使用最大值,此时将不再扩容,扩容阈值也会失效。
    2. 创建新数组,通过新容量Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap]创建出新数组
    3. 将旧数组中的元素拷贝到新数组,拷贝数组将只有一个元素的哈希桶重新计算数组下标直接插入新数组,红黑树类型的哈希桶使用红黑树方法拆分并插入到新数组,哈希桶含多个元素时,拆分链表,就是最后的else { // preserve order...}块的代码。
    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) {
            //HashMap已经初始化
            // 超过最大值就不再扩容
            if (oldCap >= MAXIMUM_CAPACITY) {
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
            // 没超过最大值,就扩充为原来的2倍,阈值也变为原来的2倍
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&  oldCap >= DEFAULT_INITIAL_CAPACITY) {
                newThr = oldThr << 1; // double threshold
            }        
        } else if (oldThr > 0) {
            //有构造参数时,使初始容量=oldThr;oldThr实际就是tableSizeFor()方法中计算出的阈值threshold,是2的n次幂
            newCap = oldThr;
        } else {
            //无构造参数时,使用默认初始容量和默认负载因子
            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;
        @SuppressWarnings({"rawtypes","unchecked"})
        //======================================================================
        //创建扩容后的新数组
        Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
        //======================================================================
        table = newTab;
        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 { // preserve order
                        // 链表元素拆分重新分配到新数组中
                        Node<K,V> loHead = null, loTail = null;
                        Node<K,V> hiHead = null, hiTail = null;
                        Node<K,V> next;
                        do {
                            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);
                        // 原索引放到bucket里
                        if (loTail != null) {
                            loTail.next = null;
                            newTab[j] = loHead;
                        }
                        // 原索引+oldCap放到bucket里
                        if (hiTail != null) {
                            hiTail.next = null;
                            newTab[j + oldCap] = hiHead;
                        }
                    }
                }
            }
        }
        //=======================================================================    
        return newTab;
    }
    • 解析链表拆分的代码

      首先方法中定义了四个变量loHead、loTail、hiHead、hiTail,顾名思义就是两个链表的头和尾,之后循环链表,循环中,有一个if ((e.hash & oldCap) == 0)的判断,这个判断用二进制还原,假设oldCap是16,newCap是32,hash值有654321和15。

      上面图示中,分别展示e.hash & oldCap和数组下标运算e.hash & (newCap - 1),通过图示:

    • 当oldCap是16时,可以发现e.hash & oldCap中,oldCap由于是2的次幂值,始终都是0...010000的形式,所以在e.hash & oldCap运算中,参与运算的位数是后五位,当与0...10000中的1对等位是0时,结果为(e.hash & oldCap) == 0,反之等于(e.hash & oldCap) != 0;
    • 对于(e.hash & oldCap) == 0的hash来说,参与数字下标运算(hash & (oldCap-1))的位数都是后四位,所以即使当oldCap变成newCap=32时,依然是后四位计算数组下标,所以扩容前后,该元素对应的哈希桶不变。
    • 对于(e.hash & oldCap) != 0的hash来说,由于0...10000中的1对等位是1,当oldCap变成newCap=32时,计算数组下标变成了后五位,所以扩容前后,该元素对应的哈希桶位置改变。
    • 总结if ((e.hash & oldCap) == 0)判断实际是将链表中的元素拆分成扩容数组下标改变和不改变两类,结合if中代码块可知,将原链表拆分成了两个链表,如下图也就对应了对应四个变量loHead、loTail、hiHead、hiTail,拆分完成之后,数组下标不变的链表loHead直接放入新数组对应newTab[j]处,改变的链表hiHead放入newTab[j + oldCap]处,这里为什么没有重新进行(e.hash & (newCap - 1))计算数组下标的原因在前面问题(为什么数组容量始终是2的次幂值?)中说过。

     

    3、HashMap的删除

    HashMap删除和和查找都是一样原理,都是围绕key展开,所以只在源码标上注释理解除了熟悉的hash,key和value,还有matchValue和movable,matchValue是删除时是否比较value,默认false,movable是红黑树删除时是否移动其他节点,默认true。

    final Node<K,V> removeNode(int hash, Object key, Object value, boolean matchValue, boolean movable) {
                               
        Node<K,V>[] tab; Node<K,V> p; int n, index;
        //如果table是null 或者 对应数组下标处为null,返回null,结束方法
        if ((tab = table) != null && (n = tab.length) > 0 && (p = tab[index = (n - 1) & hash]) != null) {
            Node<K,V> node = null, e; 
            K k; 
            V v;
            //比较节点的hash值以及key查找元素
            if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) {
                node = p;//第一个就匹配到
            } else if ((e = p.next) != null) {
                if (p instanceof TreeNode) {
                    //红黑树类型
                    node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
                } else {
                    //遍历链表匹配
                    do {
                        if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) {
                            node = e;
                            break;
                        }
                        p = e;
                    } while ((e = e.next) != null);
                }
            }
            //node != null,说明查找成功,matchValue参数决定要不要同时比较value
            if (node != null && (!matchValue || (v = node.value) == value ||  (value != null && value.equals(v)))) {
                                
                if (node instanceof TreeNode) {
                    ((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
                } else if (node == p) {
                    //查找到的元素是头节点
                    tab[index] = node.next;
                } else {
                    p.next = node.next; //非头节点,该节点直接引用被删除节点下一个节点
                }
                ++modCount;
                --size;
                afterNodeRemoval(node);
                return node;
            }
        }
        return null;
    }

      到此,HashMap的部分核心代码已经解析完成,说到底,HashMap中各种设定,无论是负载因子、初始容量、扩容机制、各种位运算(高低位混合,取模运算等),其实都是围绕解决哈希冲突设计,HashMap基于哈希表实现,哈希值分布越均匀,冲突越小,性能自然提高。剩下红黑树结构,后面慢慢总结,文章篇幅较长,主要方便自己以后查阅,毕竟背的再熟也会忘。

  • 相关阅读:
    解码.NET 2.0配置之谜(二)
    .NET (C#) Internals: Delegates (1)
    .NET (C#) Internals: Delegates (2)
    Windows Vista Beta 2 尝鲜
    assembly 需要 unload 和 update 的时候怎么办?测试工程
    让 NDoc 1.3 支持.NET 2.0 程序集,泛型输出和 Visual studio 2005 解决方案导入
    号召,有兴趣做博客园自己的网络游戏的请举手..
    将执行文件转化为bat批处理文件的工具(批处理文件方式提供)
    ISAPI Filter实现的防盗链程序终于完工
    1分钟破解3dState '学习版'得一些版权信息。
  • 原文地址:https://www.cnblogs.com/zhexuejun/p/14926350.html
Copyright © 2011-2022 走看看