大话系列 - HashMap源码分析

  看到最近好多同学在准备面试,作为服务端的开发人员Java的集合一定是一个绕不开的话题。想说的是很多同学每次都是临阵磨枪,刷了一些相关的面试题,挑选的看一部分源码。这样的结果就是自己没有一个全面的理解,面试过后一段时间就基本都忘记了。所以这次想整理一下Java集合相关的源码分析,尽量做到每一行代码都有所解释吧,希望能够让每一位同学在这里都能有所收获。这里准备整理一个大话系列,就拿HashMap作为开篇吧!

  以下内容如无特殊说明,均出自JDK1.8 ,简单的看一下HashMap的源码,加上注释一共也就才2300多行的代码,所以只要我们稍微集中一下注意力,相信一定能够一鼓作气来个全面的理解。

一、成员属性

  说一下为什么第一部分想从成员属性开始说起,因为HashMap的核心就是那么几个方法,均是围绕几个核心的成员属性进行的判断,所以先把几个重要的成员属性的含义说清楚了,对我们后面更好的理解,或者大家也可以直接从第二部分开始看起,然后在源码中遇到对应的属性再回过头来看。

    /**
     * The default initial capacity - MUST be a power of two.
     */
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
  • 通过字段名称就可以知道是默认大小,<<是Java 中的有符号位操作,左移动4位就是扩大2^4,也就是16,这个属性的大小一定要是2的幂次(这个后面会解释为什么)
    /**
     * The maximum capacity, used if a higher value is implicitly specified
     * by either of the constructors with arguments.
     * MUST be a power of two <= 1<<30.
     */
    static final int MAXIMUM_CAPACITY = 1 << 30;
  • 一个HashMap的最大容量,这里也是通过位移操作来表示,解释一下:我们知道一个int类型的值在Java中是占4 Byte,也就说是32 bit 。但是这个字段是用来表示容量的,所以就一定是有符号的,那么第一位是作为符号为而保留的,那么剩下的就是31个有效位,所以最大的值就是2^31 - 1(因为包含0),而1的二进制是0001,左移动30位就是一共有31个有效位了。当然这个值也是要求必须是2的N次幂。
    /**
     * The load factor used when none specified in constructor.
     */
    static final float DEFAULT_LOAD_FACTOR = 0.75f;
  • 负载因子,这个值大家如果没有特别明确的需要还是不要去改了,现在的值是Java团队经过很多测试实验和计算最终确定的一个比较稳定的值,所以在我们使用的大多数场景中都是没有问题的,所以如无特殊需要,就不要去改这个值,但是一定要知道这个值的含义。简单的说就是当HashMap中的容量超过数组大小 * 负载因子的值,就会出发扩容。
    /**
     * The bin count threshold for using a tree rather than list for a
     * bin.  Bins are converted to trees when adding an element to a
     * bin with at least this many nodes. The value must be greater
     * than 2 and should be at least 8 to mesh with assumptions in
     * tree removal about conversion back to plain bins upon
     * shrinkage.
     */
    static final int TREEIFY_THRESHOLD = 8;

    /**
     * The bin count threshold for untreeifying a (split) bin during a
     * resize operation. Should be less than TREEIFY_THRESHOLD, and at
     * most 6 to mesh with shrinkage detection under removal.
     */
    static final int UNTREEIFY_THRESHOLD = 6;

    /**
     * The smallest table capacity for which bins may be treeified.
     * (Otherwise the table is resized if too many nodes in a bin.)
     * Should be at least 4 * TREEIFY_THRESHOLD to avoid conflicts
     * between resizing and treeification thresholds.
     */
    static final int MIN_TREEIFY_CAPACITY = 64;
  • 这里把这三个属性放在一起来说,大家肯定也知道,在JDK1.8中HashMap做了优化,将原来的链表结构在一定条件下优化成了红黑树,这样在单个链表过长的时候会提升查询的性能。这三个属性就是与这个红黑树相关的。这里简单的说一下,因为后面在源码中还会聊到这三个属性。就是在我们插入元素的时候,不是一开始就采用红黑树结构的,如果数据量比较小采用红黑树反而投入产出比不高。所以在数据比较少的时候,还是采用链表的结构的,然后当单个链表中的元素大于等于8且数组大于64的时候,就会将链表转换为红黑树。那有添加就会有删除是吧,当一个红黑树中元素的个数小于等于6的时候,就会将红黑树结构转换为单链表结构。
    /**
     * 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<K,V>[] table;
  • 是一个Node数组,用于实际保存HashMap的元素,注释里也说的比较清楚,在第一个使用的时候才初始化,并且容量大小一定是2的N次幂。
    /**
     * The number of key-value mappings contained in this map.
     */
    transient int size;
  • HashMap中实际元素的数量
    /**
     * The next size value at which to resize (capacity * load factor).
     *
     * @serial
     */
    // (The javadoc description is true upon serialization.
    // Additionally, if the table array has not been allocated, this
    // field holds the initial array capacity, or zero signifying
    // DEFAULT_INITIAL_CAPACITY.)
    int threshold;
  • 这个字段是被大家误解最多的一个,说明一下这个字段表示的不是元素的个数(size字段才是),也不是数组的大小(数组的大小可以直接通过table.length来获得)。通过注释我们知道这个字段的含义是:下一次扩容时的大小,这个值是 : capacity * load factor,也就是说当size > threshold,就会触发resize的操作了。
    /**
     * The number of times this HashMap has been structurally modified
     * Structural modifications are those that change the number of mappings in
     * the HashMap or otherwise modify its internal structure (e.g.,
     * rehash).  This field is used to make iterators on Collection-views of
     * the HashMap fail-fast.  (See ConcurrentModificationException).
     */
    transient int modCount;
  • 这个跟ArrayList中的元素是同样的含义,就表示对象被结构化修改的次数,比如rehash操作。记录这个次数的主要目的是为了在进行遍历的时候,如果元素被修改,那么就会触发fast-fail,抛出 ConcurrentModificationException。
    /**
     * Basic hash bin node, used for most entries.  (See below for
     * TreeNode subclass, and in LinkedHashMap for its Entry subclass.)
     */
    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; }

        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;
        }
    }
  • 最后说一个比较重要的内部类,上面我们看到table属性的时候看到了它是一个Node<K,V>的数组,这个Node对象是Map.Entry的一个子类,里面包含hash,K,V,next指针这四个属性,在HashMap中保存每一个元素的。

  这里提一个面试中有可能会问到的问题吧,就是为什么要重写equals方法和hashCode方法?

答:我们知道equals方法是Object中的方法,默认情况下比的是对象的引用,也就是内存地址,那这里两个Node对象在内存中的地址肯定是不一样的啊,而这里的equals实际的目的是为了比较K和V是否相等,所以这里重写了equals方法。但是为什么又要重写hashCode方法呢?这是因为要维护 hashCode 方法的一般约定,即相等的对象必须具有相等的哈希码。

二、构造函数

  HashMap中一共提供了四个构造函数,咱们分别的来看一下。

    /**
     * Constructs an empty <tt>HashMap</tt> with the default initial capacity
     * (16) and the default load factor (0.75).
     */
    public HashMap() {
        this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
    }
  • 这个就是我们平时使用最多的一个了,这里是设置了一个负载因子,就是默认值0.75F
    /**
     * Constructs an empty <tt>HashMap</tt> with the specified initial
     * capacity and the default load factor (0.75).
     *
     * @param  initialCapacity the initial capacity.
     * @throws IllegalArgumentException if the initial capacity is negative.
     */
    public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }
  • 第二个是添加了一个初始容量大小的参数,这个是在我们知道要添加元素的大致范围的时候最好指定的一个参数,因为这样可以避免多次扩容带来的开销,因为数组的扩容是需要连续的内存空间的,如果内存在使用的过程中碎片过多,那扩容操作可能还会带来一次GC操作。这里同样给的负载因子参数就是默认值。
    /**
     * Constructs an empty <tt>HashMap</tt> with the specified initial
     * capacity and load factor.
     *
     * @param  initialCapacity the initial capacity
     * @param  loadFactor      the load factor
     * @throws IllegalArgumentException if the initial capacity is negative
     *         or the load factor is nonpositive
     */
    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);
    }
  • 这个是指定了初始容量和负载因子两个入参,里面的逻辑也比较简单,就是做了一些边界值的判断,tableSizeFor函数就是对初始容量做一个调整,因为我们要求容量一定钥匙2的N次幂,但是我们输入的值不一定会满足这个要求,所以这里会做一些调整,还记得threshold字段的含义吧,但是记住在这里是个例外,这里的threshold指的是需要初始化的数组的大小。
    /**
     * Constructs a new <tt>HashMap</tt> with the same mappings as the
     * specified <tt>Map</tt>.  The <tt>HashMap</tt> is created with
     * default load factor (0.75) and an initial capacity sufficient to
     * hold the mappings in the specified <tt>Map</tt>.
     *
     * @param   m the map whose mappings are to be placed in this map
     * @throws  NullPointerException if the specified map is null
     */
    public HashMap(Map<? extends K, ? extends V> m) {
        this.loadFactor = DEFAULT_LOAD_FACTOR;
        putMapEntries(m, false);
    }
  • 这个就是我们通过一个Map来初始化另外一个Map,实际逻辑就是遍历入参Map,然后把每个元素都赋值到新的Map对象中。

通过对四个构造函数的说明我们知道,除了最后那个入参为Map对象的会直接添加元素,其他的三种方式都是只初始化了容量和负载因子,真正用来存储元素的table数组并没有被初始化,数组的初始化是在第一次添加元素的时候进行的。

三、Put 操作

    public V put(K key, V value) {
        return this.putVal(hash(key), key, value, false, true);
    }

  在这一步根据传入的key来获取hash,这个hash函数也是一个不容错过的面试点,先看一下hash函数:

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

  我们知道HashMap是允许key和value都为null,所以这里会有判断,如果key为null则hash值直接为0。那如果key不为null,则是先获取对象本身的hashCode方法,而我们知道这个方式是属于Object类的,方法会返回一个带符号的32位的int值,然后把这个hash值无符号右移16位再与原hash值进行异或操作。

 

 

  首先百度两个知识点:

  1. >>> : 是无符号右移,无论是正数还是负数,高位通通补0。>>是带符号右移。正数右移高位补0,负数右移高位补1,所以这里将一个有符号的int值进行无符号右移16位,结果就是高16位全部都为0,原高16位全部移动到低16位。
  2. ^ : 是异或符,对比两个值的二进制,如果相同则为0,不相同则为1。

  面试中可能会问到一个问题:这里为什么要进行无符号右移,然后再异或操作?

  答:这个叫做扰动函数,因为HashMap的理想状态就是hash分布足够分散,这样才能达到最优的效果。这通过Object对象的hashCode获得的hash值也足够分散,但是问题是HashMap的数组长度是从16开始进行逐步扩容的,所以大多数情况将数组长度转换成二进制之后有效位基本都集中在低位中,这样原本足够分散的hash值我们只用到了部分低位,这样就增加了hash碰撞的概率。所以这里将32位的hash值一分为二,将高位与低位进行融合,让低位携带高位的特征。这样在进行hash运算的时候会更加的分散。

  下面继续看put部分的代码,因为整个方法比较长,下面进行分段粘贴,争取把每一行代码都说清楚。

    final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
        HashMap.Node[] tab;
        int n;
        if ((tab = this.table) == null || (n = tab.length) == 0) {
            n = (tab = this.resize()).length;
        }

  这里首先是定义局部变量tab指向table,这里会判断如果table没有被初始化,会通过resize()方法来初始化table,这个resize方法下面会自己将,这里就知道是通过调用这个方法来初始化table属性就好了,n 为数组的长度。

        Object p;
        int i;
        if ((p = tab[i = n - 1 & hash]) == null) {
            tab[i] = this.newNode(hash, key, value, (HashMap.Node)null);

  定义一个局部变量p用于指向Node数组的第一个元素,这里可以看到在获取tab数组下标的时候,用n 位与 hash值,这又是一个面试点?

  答:前面我们已经获取到了key的hash值 ,而且已经通过扰动函数对hash值进行处理了,那这里为什么不能直接用这个hash值去定位数组下标呢?其实从理论上来说是完全可行的,但是我们知道一个有符号的int值的范围是大约是42亿,在我们的Jvm中放不下啊,而且就算是能放下,我们每次创建一个HashMap也不会放那么多元素,初始化数组是需要在内存中申请连续内存的,一次就申请可以容纳42亿个元素的数组是不是有点太浪费空间了呢?所以基于以上考虑,这里会将hash值与数组的长度取模计算来确定目标元素的数组下标。但是这里的取模操作没有使用%,因为通过位运算效率更高一些。

  这里如果p=null,就是说当前数组下标中没有任何元素,当前的是第一个元素,所以直接创建一个新的Node节点即可。

  但是这里我们回想一个问题,就是如果key为null,计算的hash值是0,在这里与数组长度进行异或的结果也还都是0,也就是key为null的元素一定会落在数组的一个下标中。

 } else {
            Object e;
            Object k;
            if (((HashMap.Node)p).hash == hash && ((k = ((HashMap.Node)p).key) == key || key != null && key.equals(k))) {
                e = p;

  这个else分支是对应数组下标第一个元素不为null,那就是说已经有其他元素被添加到这个数组下标中,接下来要做就是把当前的元素继续添加到这里。同样p 是这个数组中的第一个元素,判断条件中首先比较p的hash值与当前元素是否相等,&& 逻辑与来判断key。这里又定义了两个局部变量,k是p节点的key值,判断与当前对象的key是否相等,或者key不为null,且相等。总结来说就是如果待添加的元素的key不为null,且hash值和key值都与p节点相同,这样就把当前节点的引用指向e。

  这里的局部变量e就是我们要找的目标元素,因为我们在执行添加操作的目的就只有两种情况,一种是这个元素之前在当前的HashMap中不存在,那我直接添加进去就可以了,另外一种就是这个元素已经存在了(当然这里我们判断的条件是hash值和key的双重对比,避免hash碰撞产生的误操作),那根据方法的参数来决定是不是要替换掉原value。

} else if (p instanceof HashMap.TreeNode) {
                e = ((HashMap.TreeNode)p).putTreeVal(this, tab, hash, key, value);

  这里是如果数组的第一个元素是TreeNode结构,那么直接调用putTreeVal,将当前元素添加到树种就行了,这个方法后面会再聊到,这里就先整体了解一下处理的分支。

         } else {
                int binCount = 0;

                while(true) {
                    if ((e = ((HashMap.Node)p).next) == null) {
                        ((HashMap.Node)p).next = this.newNode(hash, key, value, (HashMap.Node)null);
                        if (binCount >= 7) {
                            this.treeifyBin(tab, hash);
                        }
                        break;
                    }

                    if (((HashMap.Node)e).hash == hash && ((k = ((HashMap.Node)e).key) == key || key != null && key.equals(k))) {
                        break;
                    }

                    p = e;
                    ++binCount;
                }
            }

  经过上面的处理,如果第一个元素与当前待添加元素不相等,且当前数组节点也不是红黑树,接下来就需要对剩余的元素进行遍历对比了。

  首先局部计数binCount , 这个字段是用来记录链表中元素个数的。局部变量e指向循环中被处理的元素,那么第一个if判断就是如果链表遍历到最后都没有找到hash值和key都匹配的元素,那么就是在当前HashMap对象中没有这个元素,这样就会按照添加一个新元素来进行处理,直接在当前p节点的后面链接一个新节点就行了,但是在这里还有一个判断,如果binCount大于等于7就需要尝试将当前列表转换为红黑树。

  接下来就是通过next指针不断去遍历链表中的剩余节点,然后判断的条件和之前的if是一样的。

  这里添加一个面试题:这里我们看如果一个元素不存在,就获取到链表中最后一个元素,然后在其后面添加上,这个叫做尾插法,但是看过之前HashMap扩容文章的同学可能知道,在1.7版本中是采用头插法的,头插和尾插有什么区别吗?

  答:其实头插和尾部没有绝对的优势和劣势,只是在1.7中开发者认为后来插入的值被再次查找的概率大一些,采用头插法可以提高查询的速度。但是因为在1.7中扩容采用迁移的方法,在多线程的情况下会出现环。所以在1.8中调整了resize的的扩容方式,元素采用尾插的方式能够保证扩容之后保持原有顺序。

  上面为什么要说尝试将链表转换成红黑树呢?我们看一下 treeifyBin 的源码:

final void treeifyBin(HashMap.Node<K, V>[] tab, int hash) {
        int n;
        if (tab != null && (n = tab.length) >= 64) {
            int index;
            HashMap.Node e;
            if ((e = tab[index = n - 1 & hash]) != null) {
                HashMap.TreeNode<K, V> hd = null;
                HashMap.TreeNode tl = null;

                do {
                    HashMap.TreeNode<K, V> p = this.replacementTreeNode(e, (HashMap.Node)null);
                    if (tl == null) {
                        hd = p;
                    } else {
                        p.prev = tl;
                        tl.next = p;
                    }

                    tl = p;
                } while((e = e.next) != null);

                if ((tab[index] = hd) != null) {
                    hd.treeify(tab);
                }
            }
        } else {
            this.resize();
        }

    }

  在外层我们知道判断如果一个链表的binCount大于等于7,但是这个值是没有包含当前即将被添加的元素的,所以准确的说应该是一个链表中的元素大于等于8的时候,会进入到这个 treeifyBin 方法中,但是我们要说这并不是唯一的条件。看方法中的第一个判断,要table数组的长度大于等于64才会将当前的链表转换成红黑树。

  所以说到这里还有一个隐藏的面试点:为什么单链表元素小于6个的时候要从红黑树转化成单链表,但是大于8个的时候要转换成红黑树,为什么中间要留一个7?

  答:其实这也是JDK团队的一个巧妙的设计,就是要在6和8之间留一个7作为缓冲,用于处理那种在6和8之间变换的情况,不至于总是在单链表和红黑树之间来回切换,做不必要的消耗。

  经历过上面三个分支处理过的结果就是,要不就是找到一个目标元素,要不就是创建一个新的节点添加进去。然后这个元素是的引用是保存在e中。

            if (e != null) {
                V oldValue = ((HashMap.Node)e).value;
                if (!onlyIfAbsent || oldValue == null) {
                    ((HashMap.Node)e).value = value;
                }

                this.afterNodeAccess((HashMap.Node)e);
                return oldValue;
            }

  如果是新添加的Node元素,那这里的e指向的指针就一定是null,也就不会执行这部分逻辑了。

  这里的逻辑就是取出原V的值,这里还记得在put方法中个参数 onlyIfAbsent ,就是说只有这个元素在不存在的时候才会添加或者覆盖V,那如果这里找到了hash值和key都相等元素就不满足和条件了,所以在这个if分支中就不会替换原值。

        ++this.modCount;
        if (++this.size > this.threshold) {
            this.resize();
        }

        this.afterNodeInsertion(evict);
        return null;

  最后一部分就比较简单了,modCount 进行自增,用于在遍历过程中判断数据是否被结构化的调整过,如果当前HashMap中的元素数量超过threshold就出发扩容机制。

 四、Get操作

    /**
     * Implements Map.get and related methods
     *
     * @param hash hash for key
     * @param key the key
     * @return the node, or null if none
     */
    final Node<K,V> getNode(int hash, Object key) {
        Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (first = tab[(n - 1) & hash]) != null) {
            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;
    }

  get方法逻辑上比较清晰,主要分以下几个步骤:

  1. 边界条件检查:table不为null ,tab长度大于0 , 并且当前hash到数组槽点的第一个元素不为null(如果第一个元素就null,那就说明这个链上没有可查找的元素)
  2. 判断当前数组槽点第一个元素hash值和key是否为要查找的,如果是则返回当前Node节点
  3. 如果不是,判断如果是红黑树结构,那么通过TreeNode对象来获取目标key元素
  4. 如果不是红黑树,则通过next指针遍历查找目标对象

五、扩容

  扩容操作在之前的博文中已经简单的提过了,但是很多同学反馈就是说看文章能够明白,但是一旦自己看源码的时候,就会被其中的某些代码所迷惑,那么这里咱们结合代码,争取详细的再说一遍。首先在上面聊到put方法的时候,如果当前table对象没有被初始化,是通过resize方法来初始化的,那这里咱们就看看到底是怎么初始化的。

        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;

这个是put中的方法,这次咱们进入到resize方法中去看看,还是老方法,因为整体代码比较长,我们分段来说。

    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)
                newThr = oldThr << 1; // double threshold
        }

  首先是几个局部变量,咱们顺手带一带。

  1. oldTab : 当前的table,如果是未初始化状态则为null
  2. oldCap:原数组的长度
  3. oldThr : 原数组的threshold(就是当前HashMap的size如果大于这个值,就会触发扩容机制,在put方法的最后也有这个判断)
  4. newCap ,  newThr : 用于记录扩容之后的数组容量和threshold

  接下来就是判断oldCap的长度,如果大于0就说明已经初始化过了,这里是要对现有数组进行扩容操作。然后是判断,如果原数组的长度大于等于最大容量,那么直接设置threshold为Integer的最大值,返回原数组,不进行扩容操作。

  那么如果没有到容量的最大值,新数组的长度扩大为原来的两倍(<<是带符号左移,相当于原值 * 2^1), 如果扩容之后的数组长度小于最大值,并且原数组长度大于默认值(16),newThr也扩容为原来的两倍。

        else if (oldThr > 0) // initial capacity was placed in threshold
            newCap = oldThr;

  下面就是如果判断如果oldCap等于0(因为没通过上面的分支嘛),并且oldThr大于0的情况下,直接设置newCap为oldThr。大家想一想什么情况下会走到这个分支中呢?

还记得在构造方法中,如果我们传入了一个期望容量的时候,程序会把期望值向上取整到最近一个2的N次幂的值,然后赋值给threshold。那也就是这种情况下,会按照oldThr来初始化table的大小。

        else {               // zero initial threshold signifies using defaults
            newCap = DEFAULT_INITIAL_CAPACITY;
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
        }

  最后一种情况就是咱们最常用的无参构造方法了,这种情况下的构造方式只会初始化一个默认的负载因子。所以这里会设置newCap为默认的数组大小(16),然后设置newThr。

  这里也会考察一个比较常见的面试题:为什么默认的数组长度是16?

  答:首先这里的16是开发团队经过全方面测试结合大多数使用场景,设置的一个比较合适的数值,这里咱没必要较真这个,而是应该知道这里取的16不是一个随机值,而是2^4,也就是前面一直提到的数组的大小要为2的N次幂。那为什么一定要是2的N次幂呢?这里是为了方面hash取模,因为我们知道2的N次幂的二进制是1后面跟随N个0,那在取模的时候使用的是数组长度减1,那对应的二进制就是低N位都是1,相当于是一个低位掩码,这样取模计算之后高位全部归零,方便计算。

        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;

  这里有一个newThr为0的判断,那能进入到这个分支中的情况就只有一种,设置了初始期望容量的时候。这种情况下oldThr的大小为newCap的大小。而我们知道threshold的含义是当HashMap对象中元素的个数大于这个值的时候,需要提前出发扩容机制,这也是负载因为存在的意义。所以说threshold的值一定要小于newCap的值。所以在if分支中用newCap的值 * 负载因子再加上一些边界条件的判断,设置newThr,同时初始化新数组并赋值引用。

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);
                        if (loTail != null) {
                            loTail.next = null;
                            newTab[j] = loHead;
                        }
                        if (hiTail != null) {
                            hiTail.next = null;
                            newTab[j + oldCap] = hiHead;
                        }
                    }
                }
            }
        }
        return newTab;

  上面的一些操作已经把数组进行扩容了,但是已经添加到HashMap中的数据还存在与oldTab中,需要把这部分数据迁移过来,因为这里的逻辑关联比较紧密,就不再拆分了。

  1. 首先进行边界判断oldTab不能为null,然后遍历每个数组元素,获取链表或者红黑树的第一个指针元素
  2. 局部变量e表示当前操作的元素,首先指向一个数组槽的元素,判断其不能为null(如果为null就说明当前链表中没有任何元素,也就不需要进行迁移)
  3. 将原数组对应下表元素置为null(防止重复迁移),判断e.next是否为null,如果为null就表示当前数组节点就一个元素,直接通过hash值重新定位数组下标迁移即可
  4. 判断e的类型,如果是TreeNode,就是调用TreeNode的方法拆分迁移(这个一会单独看源码来分析)
  5. 如果当前是数组节点下是链表的情况,进行迁移操作

    这里的变量稍微有一些多,但是咱们每个拆开的来说:

    • loHead,loTail : 用于记录原数组中的低位元素
    • hiHead,  hiTail : 用于记录原数组总的高位元素

  这里设计的就比较巧妙,为什么要将原链表拆分成高位元素和低位元素呢?因为上面扩容的时候提到了,扩容是要将现有数组长度扩容为原来的2倍,用位操作来表示就是带符号左移一位。那么在将元素从原数组迁移到新数组的时候,对应的数组下标就会发生变化。说的通俗一点就是,在取模的时候我们是取(length -1)的低位掩码来与hash值进行位与的计算,那数组扩容了一倍之后,新的掩码就是要比原来的掩码在左边多一位,所以在将原链表进行迁移的时候只需要判断多出来的这一位是什么值,就能确定迁移之后元素所在的位置了。

  所以这用hash值与oldCap进行位与操作,就是在判断新高位的值,如果是0那就表示即使迁移,那这些元素也还是会在现在的数组下标的位置中,但是那些为1的,就应该是直接迁移到原位置+原数组长度的位置上,而不需要对链表中的每个元素都再次进行hash槽点的计算。

  也是因为数据迁移直接迁移的是元素列表,这样也是保证了原有元素的顺序,同时也避免了1.7中的环现象的发生。

final void split(HashMap<K,V> map, Node<K,V>[] tab, int index, int bit) {
            TreeNode<K,V> b = this;
            // Relink into lo and hi lists, preserving order
            TreeNode<K,V> loHead = null, loTail = null;
            TreeNode<K,V> hiHead = null, hiTail = null;
            int lc = 0, hc = 0;
            for (TreeNode<K,V> e = b, next; e != null; e = next) {
                next = (TreeNode<K,V>)e.next;
                e.next = null;
                if ((e.hash & bit) == 0) {
                    if ((e.prev = loTail) == null)
                        loHead = e;
                    else
                        loTail.next = e;
                    loTail = e;
                    ++lc;
                }
                else {
                    if ((e.prev = hiTail) == null)
                        hiHead = e;
                    else
                        hiTail.next = e;
                    hiTail = e;
                    ++hc;
                }
            }

            if (loHead != null) {
                if (lc <= UNTREEIFY_THRESHOLD)
                    tab[index] = loHead.untreeify(map);
                else {
                    tab[index] = loHead;
                    if (hiHead != null) // (else is already treeified)
                        loHead.treeify(tab);
                }
            }
            if (hiHead != null) {
                if (hc <= UNTREEIFY_THRESHOLD)
                    tab[index + bit] = hiHead.untreeify(map);
                else {
                    tab[index + bit] = hiHead;
                    if (loHead != null)
                        hiHead.treeify(tab);
                }
            }
        }

  说完了链表数据的迁移,那我们再来看一下红黑树的迁移是怎么做的。

  首先也是定义了四个TreeNode节点,用于记录高位元素和低位元素。然后从根节点开始遍历红黑树,这里的TreeNode继承自Entry,所以他们在通过红黑树的连接之外,每个节点直接还通过next指针相关联着,所以这里直接通过next指针进行红黑树的遍历,具体的拆分过程与链表相同。

  但是有一点不同,就是拆分之后的链表的数量如果小于6个,那就需要将红黑树转换回单链表,因为元素如果比较少,那采用单链表的效率会更高一些。

   

posted @ 2021-08-06 23:09  SyrupzZ  阅读(52)  评论(0)    收藏  举报