zoukankan      html  css  js  c++  java
  • HashMap源码解析

    此文已由作者赵计刚授权网易云社区发布。

    欢迎访问网易云社区,了解更多网易技术产品运营经验。


    5.1、对于HashMap需要掌握以下几点

    • Map的创建:HashMap()

    • 往Map中添加键值对:即put(Object key, Object value)方法

    • 获取Map中的单个对象:即get(Object key)方法

    • 删除Map中的对象:即remove(Object key)方法

    • 判断对象是否存在于Map中:containsKey(Object key)

    • 遍历Map中的对象:即keySet(),在实际中更常用的是增强型的for循环去做遍历

    • Map中对象的排序:主要取决于所采取的排序算法

     

    5.2、构建HashMap

     源代码:

    一些属性:

        static final int DEFAULT_INITIAL_CAPACITY = 16;    // 默认的初始化容量(必须是2的多少次方)
        static final int MAXIMUM_CAPACITY = 1 << 30;    // 最大指定容量为2的30次方
        static final float DEFAULT_LOAD_FACTOR = 0.75f;    // 默认的加载因子(用于resize)
    
        transient Entry[] table;// Entry数组(数组容量必须是2的多少次方,若有必要会扩容resize)--这就是HashMap的底层数据结构
    
        transient int size;        // 该map中存放的key-value对个数,该个数决定了数组的扩容(而非table中的所占用的桶的个数来决定是否扩容)
        // 扩容resize的条件:eg.capacity=16,load_factor=0.75,threshold=capacity*load_factor=12,即当该map中存放的key-value对个数size>=12时,就resize)
        int threshold;            
        final float loadFactor;    // 负载因子(用于resize)
    
        transient volatile int modCount;// 标志位,用于标识并发问题,主要用于迭代的快速失败(在迭代过程中,如果发生了put(添加而不是更新的时候)、remove操作,该值发生变化,快速失败)

    注意:

    • map中存放的key-value对个数size,该个数决定了数组的扩容(size>=threshold时,扩容),而非table中的所占用的桶的个数来决定是否扩容

    • 标志位modCount采用volatile实现该变量的线程可见性(之后会在"Java并发"章节中去讲)

    • 数组中的桶,指的就是table[i]

    • threshold默认为0.75,这是综合时间和空间的利用率来考虑的,通常不要变,如果该值过大,可能会造成链表太长,导致get、put等操作缓慢;如果太小,空间利用率不足。

    无参构造器(也是当下最常用的构造器)

        /**
         * 初始化一个负载因子、resize条件和Entry数组
         */
        public HashMap() {
            this.loadFactor = DEFAULT_LOAD_FACTOR;// 负载因子:0.75
            threshold = (int) (DEFAULT_INITIAL_CAPACITY * DEFAULT_LOAD_FACTOR);//当该map中存放的key-value对个数size>=12时,就resize
            table = new Entry[DEFAULT_INITIAL_CAPACITY];// 设置Entry数组容量为16
            init();
        }

    注意:

    • init()为空方法

    对于hashmap而言,还有两个比较常用的构造器,一个双参,一个单参。

        /**
         * 指定初始容量和负载因子
         */
        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))//loadFactor<0或者不是一个值
                throw new IllegalArgumentException("Illegal load factor:"+loadFactor);
    
            /*
             * 下边的逻辑是找一个2的几次方的数,该数刚刚大于initialCapacity
             * eg.当指定initialCapacity为17,capacity就是32(2的五次方),而2的四次方(16)正好小于17
             */
            int capacity = 1;
            while (capacity < initialCapacity)
                capacity <<= 1;// capacity = capacity<<1
    
            this.loadFactor = loadFactor;
            threshold = (int)(capacity * loadFactor);
            table = new Entry[capacity];
            init();
        }
    
        /**
         * 指定初始容量
         */
        public HashMap(int initialCapacity) {
            this(initialCapacity, DEFAULT_LOAD_FACTOR);//调用上边的双参构造器
        }

    注意:

    • 利用上述两个构造器构造出的数组容量不一定是指定的初始化容量,而是一个刚刚大于指定初始化容量的2的几次方的一个值。

    • 在实际使用中,若我们能预判所要存储的元素的多少,最好使用上述的单参构造器来指定初始容量,这样的话,就可以避免就来扩容时带来的消耗(这一点与ArrayList一样

    HashMap的底层数据结构是一个Entry[],Entry是HashMap的一个内部类,源代码如下:

        static class Entry<K, V> implements Map.Entry<K, V> {
            final K key;         // 该Entry的key
            V value;             // 该Entry的value
            Entry<K, V> next;    // 该Entry的下一个Entry(hash冲突时,形成链表)
            final int hash;     // 该Entry的hash值
    
            /**
             * Creates new entry.
             */
            Entry(int h, K k, V v, Entry<K, V> n) {
                value = v;
                next = n;
                key = k;
                hash = h;
            }
    
            public final K getKey() {
                return key;
            }
    
            public final V getValue() {
                return value;
            }
    
            //为Entry设置新的value
            public final V setValue(V newValue) {
                V oldValue = value;
                value = newValue;
                return oldValue;
            }
    
            public final boolean equals(Object o) {
                if (!(o instanceof Map.Entry))
                    return false;
                Map.Entry e = (Map.Entry) o;
                Object k1 = getKey();
                Object k2 = e.getKey();
                //在hashmap中可以存放null键和null值
                if (k1 == k2 || (k1 != null && k1.equals(k2))) {
                    Object v1 = getValue();
                    Object v2 = e.getValue();
                    if (v1 == v2 || (v1 != null && v1.equals(v2)))
                        return true;
                }
                return false;
            }
    
            public final int hashCode() {
                return (key == null ? 0 : key.hashCode())^(value == null ? 0 : value.hashCode());
            }
    
            public final String toString() {
                return getKey() + "=" + getValue();
            }
        }

    注:这里我去掉了两个空方法。

    • Entry是一个节点,在其中还保存了下一个Entry的引用(用来解决put时的hash冲突问题),这样的话,我们可以把hashmap看作是"一个链表数组"

    • Entry类中的equals()方法会在get(Object key)中使用

     

    5.3、put(Object key, Object value)

    源代码:

    put(Object key, Object value)

        /**
         * 向map中添加新Entry
         * 步骤:
         * 1)HashMap可以添加null的key,key==null的Entry只会放在table[0]中,但是table[0]不仅仅可以存放key==null的Entry
         * 1.1、遍历table[0]中的Entry链,若有key==null的值就用新值覆盖旧值,并返回旧值value,
         * 1.2、若无,执行addEntry方法,用新的Entry替换掉原来旧的Entry赋值给table[0],而旧的Entry作为新的Entry的next,执行结束后,返回null
         * 2)添加key!=null的Entry时,
         * 2.1、先计算key.hashCode()的hash值,
         * 2.2、然后计算出将要放入的table的下标i,
         * 2.3、之后遍历table[i]中的Entry链,若有相同key的值就用新值覆盖旧值,并返回旧值value,
         * 2.4、若无,执行addEntry方法,用新的Entry替换掉原来旧的Entry赋值给table[i],而旧的Entry作为新的Entry的next,执行结束后,返回null
         */
        public V put(K key, V value) {
            /******************key==null******************/
            if (key == null)                
                return putForNullKey(value);     //将空key的Entry加入到table[0]中
            /******************key!=null******************/
            int hash = hash(key.hashCode());     //计算key.hashcode()的hash值,hash函数由hashmap自己实现
            int i = indexFor(hash, table.length);//获取将要存放的数组下标
            /*
             * for中的代码用于:当hash值相同且key相同的情况下,使用新值覆盖旧值(其实就是修改功能)
             */
            for (Entry<K, V> e = table[i]; e != null; e = e.next) {//注意:for循环在第一次执行时就会先判断条件
                Object k;
                //hash值相同且key相同的情况下,使用新值覆盖旧值
                if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
                    V oldValue = e.value;
                    e.value = value;
                    //e.recordAccess(this);
                    return oldValue;//返回旧值
                }
            }
            
            modCount++;
            addEntry(hash, key, value, i);//增加一个新的Entry到table[i]
            return null;//如果没有与传入的key相等的Entry,就返回null
        }

    注意:该方法头部的注释写明了整个put(Object key, Object value)的流程,非常重要

    putForNullKey(V value)

        /**
         * 增加null的key到table[0]
         */
        private V putForNullKey(V value) {
            //遍历第一个数组元素table[0]中的所有Entry节点
            for (Entry<K, V> e = table[0]; e != null; e = e.next) {
                if (e.key == null) {//用新值覆盖旧值
                    V oldValue = e.value;
                    e.value = value;
                    //e.recordAccess(this);
                    return oldValue;
                }
            }
            modCount++;
            addEntry(0, null, value, 0);//将新节点Entry加入到Entry[]中
            return null;
        }

    addEntry(int hash, K key, V value, int bucketIndex)

        /**
         * 添加新的Entry到table[bucketIndex]
         */
        void addEntry(int hash, K key, V value, int bucketIndex) {
            /*
             * 这里可以看出,
             * 1)新加入的Entry会放入链头,也就是说将来遍历的时候,最先加入map的反而是最后被遍历到的
             * 2)采用的是Entry替换的方式
             * 2.1、当添加第一个Entry1时,table[bucketIndex]==null,也就是说Entry1的下一个Entry为null(链尾),之后把table[bucketIndex] = Entry1
             * 2.2、当添加第二个Entry2时,table[bucketIndex]==Entry1,也就是说Entry2的下一个Entry为Entry1,之后把table[bucketIndex] = Entry2
             * 2.3、当添加第三个Entry3时,table[bucketIndex]==Entry2,也就是说Entry3的下一个Entry为Entry2,之后把table[bucketIndex] = Entry3
             */
            Entry<K, V> e = table[bucketIndex];//新节点的下一个节点(当第一次在相应的数组位置放置元素时,table[bucketIndex]==null)
            table[bucketIndex] = new Entry<K, V>(hash, key, value, e);
            if (size++ >= threshold)//key-value对个数大于等于threshold
                resize(2 * table.length);//扩容
        }

    注意:该方法头部的注释写明了该方法的流程示例,可以自己画个图对比着理解

    hash(int h)

        /**
         * hash函数,用于计算key.hashCode()的hash值
         * Note: null的key的hash为0,放在table[0].
         */
        static int hash(int h) {
            //这样的hash函数应该可以尽量将hash值打散
            h ^= (h >>> 20) ^ (h >>> 12);
            return h ^ (h >>> 7) ^ (h >>> 4);
        }

    注意:在我们实际使用hashmap时,最好的情况是将key的hash值打散,使插入的这些Entry尽量落在不同的桶上(这样做的主要目的是提高查询效率),以上这个hash函数应该就是实现了这样的功能,但是为什么这样的hash函数可以将hash值打散,求大神指点!!!

    indexFor(int h, int length)

        /**
         * "按位与"来获取数组下标
         */
        static int indexFor(int h, int length) {
            return h & (length - 1);
        }

    注意:hashmap始终将自己的桶保持在2的n次方,这是为什么?indexFor这个方法解释了这个问题。“这个方法非常巧妙,它通过h & (table.length -1)来得到该对象的保存位,而HashMap底层数组的长度总是2的n次方,这是HashMap在速度上的优化。当length总是2的n次方时,h& (length-1)运算等价于对length取模,也就是h%length,但是&比%具有更高的效率”--http://tech.meituan.com/java-hashmap.html

    说明:在上述的addEntry(int hash, K key, V value, int bucketIndex)方法中,我们可以看到,当为把新的Entry赋值给table[i]后,会判断map中的key-value对是不是已经大于等于扩容条件值threshold了,若是,则需要调用resize函数,对Entry数组进行扩容,扩为原来二倍。

    resize(int newCapacity)

        /**
         * 扩容步骤:
         * 1)数组扩容为原来容量(eg.16)的二倍
         * 2)将旧数组中的所有Entry重新计算索引,加入新数组
         * 3)将新数组的引用赋给旧数组
         * 4)重新计算扩容临界值threshold
         */
        void resize(int newCapacity) {
            Entry[] oldTable = table;
            int oldCapacity = oldTable.length;
            //如果旧的数组的容量为2的30次方(这种情况,不考虑了,如果真达到这样的情况,性能下降的就不像话了)
            if (oldCapacity == MAXIMUM_CAPACITY) {
                threshold = Integer.MAX_VALUE;
                return;
            }
    
            Entry[] newTable = new Entry[newCapacity];//newCapacity==2*oldCapacity
            transfer(newTable);//将旧数组中的所有Entry重新计算索引,加入新数组
            table = newTable;//将新数组赋给就数组
            threshold = (int) (newCapacity * loadFactor);//重新计算threshold
        }

    transfer(Entry[] newTable)

    jdk中的实现:

        /**
         * 将所有旧的数组中的所有Entry移动到新数组中去
         */
        void transfer(Entry[] newTable) {
            Entry[] src = table;                
            int newCapacity = newTable.length;    
            for (int j = 0; j < src.length; j++) {//遍历旧数组
                Entry<K, V> e = src[j];//获得头节点
                if (e != null) {
                    /*
                     * 这样写,若同时有其他线程还在访问这个元素,则访问不到了,这里这样写,是考虑到多线程情况下,我们一般不会会用HashMap
                     * (查看ConcurrentHashMap并未将旧数组的值置为null)
                     * 这里将其置为null就方便gc回收
                     * 当然为了减小以上所说的影响,建议将src[j] = null;放在while循环结束后
                     */
                    src[j] = null;
                    do {
                        Entry<K, V> next = e.next;
                        int i = indexFor(e.hash, newCapacity);
                        e.next = newTable[i];//把之前已经存在的newTable[i]的元素赋给当前节点的下一个节点
                        newTable[i] = e;//把当前节点赋给newTable[i]
                        e = next;
                    } while (e != null);//遍历链表
                }
            }
        }

    我的修改:(注意:这是一个错误的修改,错误的根源在下边我会给出

        /**
         * 将所有旧的数组中的所有Entry移动到新数组中去
         */
        void transfer(Entry[] newTable) {
            Entry[] src = table;                //旧数组
            int newCapacity = newTable.length;    //新数组容量
            
            for (int j = 0; j < src.length; j++) {
                Entry<K, V> e = src[j];//获取旧数组中的头节点Entry
                if (e != null) {
                    src[j] = null;//将旧数组置空,让gc回收注意:这个时候table的桶并没有置空
                    /*
                     * 根据旧的hash值与新的容量值进行重新定位(注意:并没有重新计算hash值)
                     * 1、那么假设之前table[1]中存放的是Entry3,Entry3.next是Entry2,Entry2.next是Entry1,Entry1.next是null
                     *   那么假设重新计算后的i=3,那么Entry3-->Entry2-->Entry1依旧会在一起,都放入newTable[3],这样的话,我们只需要将链头的Entry3赋值给newTable[3]即可
                     * 2、既然通过indexFor(e.hash, newCapacity)不能把同一个桶下的Entry打散,为什么还要用呢?
                     *     主要是扩容后,若不用newCapacity去计算下标的话,那么扩容后,map中的Entry就都集中在了新数组的前半部分,这样就不够散了
                     */
                    int i = indexFor(e.hash, newCapacity);
                    newTable[i] = e;//将Entry3赋值给newTable[3]
                }
            }
        }

    注意:

    • 在这个方法中,并没有重新计算hash值,只是重新计算了下标索引。

    • 错误根源在于认为同一个桶下的所有Entry的hash值相同,事实上不相同,只是hash&(table.length-1)的结果相同, 

      所以当table.length发生变化时,同一个桶下各个Entry算出来的index会不同(即Entry3、Entry2、Entry1可能会落在新数组的不同的桶上

     

    5.4、get(Object key)

     源代码:

    get(Object key)

        /**
         * 查找指定key的value值
         * 1、若key==null
         * 遍历table[0],找出key==null的value,若没找到,返回null
         * 2、若key!=null
         * 1)计算key.hashCode()的hash值
         * 2)根据计算出的hash值和数组容量,调用indexFor方法,获得table的下标i,进而获得桶table[i]
         * 3)遍历该桶中的每一个Entry,找出key相等(==或equals)的Entry,获取此Entry的value即可
         * 4)最后,若没有找到,返回null即可
         */
        public V get(Object key) {
            /****************查找key==null的value****************/
            if (key == null)
                return getForNullKey();
            /****************查找key!=null的value****************/
            int hash = hash(key.hashCode());//获取key.hashCode()的hash值
            
            for (Entry<K, V> e = table[indexFor(hash, table.length)]; e != null; e = e.next) {
                Object k;
                if (e.hash == hash && ((k = e.key) == key || key.equals(k)))
                    return e.value;
            }
            return null;//若没有指定key的Entry,则直接返回null
        }

    注意:查看代码头部的注释,表明了get的整个步骤

    getForNullKey()

        /**
         * 在table[0]中查询key==null
         */
        private V getForNullKey() {
            for (Entry<K, V> e = table[0]; e != null; e = e.next) {
                if (e.key == null)
                    return e.value;
            }
            return null;//找不到的话就返回null
        }

     

    5.5、remove(Object key)

    源代码:

     

        /**
         * 删除指定key的Entry
         */
        public V remove(Object key) {
            Entry<K, V> e = removeEntryForKey(key);
            return (e == null ? null : e.value);//返回删除的节点(e为null的话,表示所给出的key不存在)
        }
        /**
         * 删除指定key的Entry
         * 1)若删除的是头节点,例如Entry3,只需将Entry2赋值给table[i]即可
         * 2)若删除的是中间节点,例如Entry2,只需将Entry3.next指向Entry2.next(即Entry1)即可
         * 3)若删除的是尾节点,例如Entry1,只需将Entry2.next指向Entry1.next(即null)即可
         */
        final Entry<K, V> removeEntryForKey(Object key) {
            int hash = (key == null) ? 0 : hash(key.hashCode());//计算hash值
            int i = indexFor(hash, table.length);//按位与计算下标
            Entry<K, V> prev = table[i];//获取桶
            Entry<K, V> e = prev;
    
            while (e != null) {
                Entry<K, V> next = e.next;
                Object k;
                if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) {
                    modCount++;
                    size--;//size-1
                    if (prev == e)//删除头节点,即示例中的Entry3
                        table[i] = next;
                    else//删除除了头节点外的其他节点
                        prev.next = next;
                    //e.recordRemoval(this);
                    return e;
                }
                prev = e;
                e = next;
            }
            return e;//返回删除的节点(e为null的话,表示所给出的key不存在)
        }

     

    注:看注释即可,最好用示例去套一下代码。

    • 若删除的key不存在于map中,返回null,不会抛异常。

     

    5.6、containsKey(Object key)

    源代码:

        /**
         * 判断map是否包含指定可以的Entry
         */
        public boolean containsKey(Object key) {
            return getEntry(key) != null;
        }
        /**
         * 判断map是否包含指定可以的Entry,与get(Object key)基本相同(只是这里将key==null与key!=null的情况写在了一起,get(Object key)也可以这样去做)
         * 1)计算key.hashCode()的hash值
         * 2)根据计算出的hash值和数组容量,调用indexFor方法,获得table的下标i,进而获得桶table[i]
         * 3)遍历该桶中的每一个Entry,找出key相等(==或equals)的Entry,获取此Entry,并返回此Entry
         * 4)最后,若没有找到,返回null即可
         */
        final Entry<K, V> getEntry(Object key) {
            int hash = (key == null) ? 0 : hash(key.hashCode());//计算hash值
            for (Entry<K, V> e = table[indexFor(hash, table.length)]; e != null; e = e.next) {
                Object k;
                if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            }
            return null;
        }

    注意:此方法与get(Object key)基本相同,只是只是这里将key==null与key!=null的情况写在了一起,get(Object key)也可以这样去做来减少代码

     

    5.7、keySet()

    遍历所有Entry链表,获取每一个Entry的key,在整个过程中,如果发生了增删操作,抛出ConcurrentModificationException。

            final Entry<K, V> nextEntry() {
                if (modCount != expectedModCount)
                    throw new ConcurrentModificationException();
                Entry<K, V> e = next;
                if (e == null)
                    throw new NoSuchElementException();
    
                if ((next = e.next) == null) {
                    Entry[] t = table;
                    while (index < t.length && (next = t[index++]) == null)
                        ;
                }
                current = e;
                return e;
            }

      

    总结:

    • HashMap底层就是一个Entry数组,Entry又包含next,事实上,可以看成是一个"链表数组"

    • 扩容:map中存放的key-value对个数size,该个数决定了数组的扩容(size>=threshold时,扩容),而非table中的所占用的桶的个数来决定是否扩容

    • 扩容过程,不会重新计算hash值,只会重新按位与

    • 在实际使用中,若我们能预判所要存储的元素的多少,最好使用上述的单参构造器来指定初始容量

    • HashMap可以插入null的key和value

    • remove(Object key):若删除的key不存在于map中,返回null,不会抛异常。

    • HashMap线程不安全,若想要线程安全,最好使用ConcurrentHashMap

     疑问:

    在我们实际使用hashmap时,最好的情况是将key的hash值打散,使插入的这些Entry尽量落在不同的桶上(这样做的主要目的是提高查询效率),以下这个hash函数应该就是实现了这样的功能,但是为什么这样的hash函数可以将hash值打散,求大神指点!!!

        static int hash(int h) {
            //这样的hash函数应该可以尽量将hash值打散
            h ^= (h >>> 20) ^ (h >>> 12);
            return h ^ (h >>> 7) ^ (h >>> 4);
        }

     

    jdk1.8对hashmap进行了改造,1.7中的hashmap最大的问题就是当链表比较长时,查询效率急剧下降;所以在1.8中,当链表长度>=8是,链表转为红黑树,提高查询效率。


    免费领取验证码、内容安全、短信发送、直播点播体验包及云服务器等套餐

    更多网易技术、产品、运营经验分享请点击


    相关文章:
    【推荐】 数据库路由中间件MyCat - 源代码篇(1)
    【推荐】 MemcachedHash算法

  • 相关阅读:
    LeetCode 295. Find Median from Data Stream (堆)
    LeetCode 292. Nim Game(博弈论)
    《JavaScript 模式》读书笔记(4)— 函数2
    《JavaScript 模式》读书笔记(4)— 函数1
    《JavaScript 模式》读书笔记(3)— 字面量和构造函数3
    《JavaScript 模式》读书笔记(3)— 字面量和构造函数2
    《JavaScript 模式》读书笔记(3)— 字面量和构造函数1
    《JavaScript 模式》读书笔记(2)— 基本技巧3
    《JavaScript 模式》读书笔记(2)— 基本技巧2
    《JavaScript 模式》读书笔记(2)— 基本技巧1
  • 原文地址:https://www.cnblogs.com/zyfd/p/10173515.html
Copyright © 2011-2022 走看看