HashMap元素的插入流程以及扩容操作

数据结构与算法之HashMap

  • 1.元素的存取流程
  • 2.hash函数
  • 3.源码解读
  • 4.一些问题的探讨
    • 为什么我们需要*hash()*函数,而不是直接用*key*的*hashcode*直接计算下标
    • java8中为什么头插法改成尾插法
    • HashMap如何解决Hash冲突
    • 为什么 HashMap 中 String、Integer 这样的包装类适合作为 key 键
    • HashMap 中的 key若 Object类型, 则需实现哪些方法?
    • 为什么HashMap具备下述特点:键-值(key-value)都允许为空、线程不安全、不保证有序、存储位置随时间变化

前言
本文主要探讨 HashMap插入以及扩容流程涉及到的算法,有关 HashMap的简单介绍可以看这篇文章 《HashMap的构成与大小的确定》。

1.元素的存取流程

Java8为参考,HashMap插入元素的流程为:

  1. 判断当前容量大小是否为空,如果为空(未设置容量初始值),则把容量扩充为16。
  2. 获取keyhashCode,对hashCode进行扰动处理,计算出元素的下标。
  3. 根据下标判断有无hash碰撞,如果没有,则直接放入桶中。
  4. 如果发生碰撞,比较两个key是否相同,相同则覆盖,不同则则以链表的方式插入到尾部。(尾插法)。
  5. 如果插入后链表的长度超过了阈值(TREEIFY_THRESHOLD = 8),则把链表转为红黑树。
  6. 插入成功后,如果元素个数到达了阈值(size = 容量 * 阈值 ),则执行扩容操作判断(容量最大值为1<<31)
  7. 扩容成功后,对元素的下标进行重新计算。

2.hash函数

为了后续方便理解扩容后重新计算下标的流程,这里我们现看看两个版本的hash函数

  • Java7

    /**
     * Retrieve object hash code and applies a supplemental hash function to the
     * result hash, which defends against poor quality hash functions.  This is
     * critical because HashMap uses power-of-two length hash tables, that
     * otherwise encounter collisions for hashCodes that do not differ
     * in lower bits. Note: Null keys always map to hash 0, thus index 0.
     * 检索对象的哈希代码,并对结果哈希应用补充哈希函数,这可以防御质量差的哈希函数。
     * 这一点很关键,因为HashMap使用的是2次方长度的哈希表,否则就会遇到低位无差异的哈希代码的碰撞。
     * 注意:空键总是映射到哈希0,因此索引为0。
     */
    final int hash(Object k) {
        int h = hashSeed;//hashSeed为一个hash掩码值,被应用于键的哈希代码,以使哈希碰撞更难发现
        if (0 != h && k instanceof String) {//如果hash掩码值初始化过,并且key值为String类型
            return sun.misc.Hashing.stringHash32((String) k);//这个方法在下面会解释
        }
        h ^= k.hashCode();
        // This function ensures that hashCodes that differ only by
        // constant multiples at each bit position have a bounded
        // number of collisions (approximately 8 at default load factor).
        //这个函数确保在每个比特位置只相差常数倍的哈希码有一定数量的碰撞(在默认负载系数下约为8)
        h ^= (h >>> 20) ^ (h >>> 12);
        return h ^ (h >>> 7) ^ (h >>> 4);
    }
    
    

    通过查阅源码,stringHash32((String) k)这个方法最终调用为getStringHash32()

    public int getStringHash32(String string) {
        return string.hash32();
    }
    

    点进去看这个hash32();

     /**
     * Calculates a 32-bit hash value for this string.
     * 为这个字符串计算一个32位的哈希值
     * @return a 32-bit hash value for this string.
     */
    int hash32() {
        int h = hash32;
        if (0 == h) {
           // harmless data race on hash32 here.
           h = sun.misc.Hashing.murmur3_32(HASHING_SEED, value, 0, value.length);
           //此处代码经过了混淆,阅读性比较差,故不继续往下贴
           // ensure result is not zero to avoid recalcing
           h = (0 != h) ? h : 1;
           hash32 = h;
        }
        return h;
    }
    
    

    举个例子我们来手写一下这个方法的计算流程;
    假设我现在put一个元素,元素的key值为"code";由于掩码没有初始化,我们跳过调用stringHash32();
    第1步:

    h ^= k.hashCode();

    通过计算,"code"的*hashCode()*为3059181(关于如何计算hashCode可以看这篇文章:《String 源码中的hashCode算法》)。

    h ^= k.hashCode();
    h  = h ^ k.hashCode();
       = 0 ^ 3059181;//由于掩码没有初始化,所以此处h=0
       = 0000 0000 ^ 10 1110 1010 1101 1110 1101;//转为为二进制
       = 10 1110 1010 1101 1110 1101;//^(异或:对位不相等为1,相等为0)
       = 3059181;
    1次异或,共计1次扰动处理。
    

    第2步:

    h ^= (h >>> 20) ^ (h >>> 12);

    h ^= (h >>> 20) ^ (h >>> 12);
    h  = h ^ ((h >>> 20) ^ (h >>> 12));
       = 3059181 ^ ((3059181 >>> 20) ^ (3059181 >>> 12));
       = 10 1110 1010 1101 1110 1101 ^ ((10 1110 1010 1101 1110 1101>>> 20) ^ (10 1110 1010 1101 1110 1101>>> 12));
       = 10 1110 1010 1101 1110 1101 ^ (10 ^ 10 1110 1010);
       = 10 1110 1010 1101 1110 1101 ^ 10 1110 1000;
       = 10 1110 1010 1111 0000 0101;
       = 3059461;
       2次异或,2次移位,共计4次扰动处理
    

    第3步:

    return h ^ (h >>> 7) ^ (h >>> 4);

    h ^ (h >>> 7) ^ (h >>> 4)
    = 3059461 ^ (3059461 >>> 7) ^ (3059461 >>> 4);
    = 10 1110 1010 1111 0000 0101 ^ (10 1110 1010 1111 0000 0101 >>> 7) ^ (10 1110 1010 1111 0000 0101>>> 4);
    = 10 1110 1010 1111 0000 0101 ^ 10 1110 1111 0010 0101 1011 ^ 10 1100 0100 0101 1111 0101; 
    = 00 0000 0101 1101 0101 1110 ^ 10 1100 0100 0101 1111 0101;
    = 10 1100 0001 1000 1010 1011;
    = 2889899;
    2次异或,2次移位,共计4次扰动处理
    

    我们把这三步计算过程统计一下:

    步骤 异或(次) 移位(次) 扰动总计
    h ^= k.hashCode() 1 0 1
    h ^= (h >>> 20) ^ (h >>> 12) 2 2 4
    ^ (h >>> 7) ^ (h >>> 4) 2 2 4

    把次数加起来*hash()*函数总计经过了9次扰动处理,计算过程是比较复杂的。

    接下来我们看看计算下标的函数:

     public V put(K key, V value) {
    	...
    	int hash = hash(key);
        int i = indexFor(hash, table.length);
    	...
    }
    /**
     * Returns index for hash code h.
     * 返回哈希代码h的索引
     */
    static int indexFor(int h, int length) {
        // assert Integer.bitCount(length) == 1 : "length must be a non-zero power of 2";
        //长度必须是2的非零次方,这里指HashMap的容量
        return h & (length-1);
    }
    

    计算下标的函数还是相对简单的,我们来手写一下过程:

    index = hash(key) & (length-1);
    	  = 2889899 & (16-1);
    	  = 2889899 & 15;
    	  = 10 1100 0001 1000 1010 1011 & 1111;
    	  = 1011;
    	  = 11;
    

    最终计算得出key为"code"的元素会存入下标为11的桶中。

  • Java8
    Java8HashMaphash函数相对来说简单许多,直接上代码

     /**
     * Computes key.hashCode() and spreads (XORs) higher bits of hash
     * to lower.  Because the table uses power-of-two masking, sets of
     * hashes that vary only in bits above the current mask will
     * always collide. (Among known examples are sets of Float keys
     * holding consecutive whole numbers in small tables.)  So we
     * apply a transform that spreads the impact of higher bits
     * downward. There is a tradeoff between speed, utility, and
     * quality of bit-spreading. Because many common sets of hashes
     * are already reasonably distributed (so don't benefit from
     * spreading), and because we use trees to handle large sets of
     * collisions in bins, we just XOR some shifted bits in the
     * cheapest possible way to reduce systematic lossage, as well as
     * to incorporate impact of the highest bits that would otherwise
     * never be used in index calculations because of table bounds.
     * 计算key.hashCode(),并将hash的高位传播(XOR)到低位。 
     * 因为该表使用了2次方屏蔽,所以只在当前屏蔽以上的位数上有变化的哈希值组总是会发生碰撞。
     * (已知的例子包括在小表中持有连续整数的Float键的集合)。) 
     *  因此,我们应用一个转换,将高位的影响向下分散。在速度、实用性和位传播的质量之间有一个权衡。
     * 因为许多常见的哈希集已经是合理分布的(所以不受益于传播),而且因为我们使用树来处理大集的碰撞,
     * 我们只是以最便宜的方式XOR一些移位的比特,以减少系统损失,
     * 以及纳入最高比特的影响,否则由于表的界限,永远不会被用于索引计算。
     */
    static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }
    

    假设我现在put元素的key还是"code",来看一下运算流程:

    	int h = key.hashCode();
          = 3059181;
          = 10 1110 1010 1101 1110 1101;
    int hash = h ^ (h >>> 16);
    		 = 10 1110 1010 1101 1110 1101 ^ (10 1110 1010 1101 1110 1101 >>> 16);
    		 = 10 1110 1010 1101 1110 1101 ^ 10 1110;
    		 = 10 1110 1010 1101 1100 0011;
    		 = 3059139;
    	1次异或,1次移位,共计2次扰动处理	 
    

    计算下标:

    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)
            n = (tab = resize()).length;
        if ((p = tab[i = (n - 1) & hash]) == null)//这一行
        ...
        }
    

    此处计算步骤与Java7中的*indexFor()*相同,故不再重复展示

    我们来比较一下两个版本的hash函数:

    版本号 异或(次) 移位(次) 扰动总计
    java7 5 4 9
    java8 1 2 2

可以看到,在java8的实现中,优化了高位运算的算法,仅仅只需要2次扰动计算,而java7 需要9次扰动计算,就效率来说,还是java8hash函数更高。
至于为什么java8进行了扰动次数缩减,推测可能是因为做多了离散分布提升不明显,还是为了效率考虑,进行了缩减。

3.源码解读

  • Java7

    首先是调用put方法插入一个元素

    /**
     * Associates the specified value with the specified key in this map.
     * If the map previously contained a mapping for the key, the old
     * value is replaced.
     * 将指定的值与该地图中指定的键相关联。
     * 如果该地图以前包含一个键的映射,那么旧的值将被替换
     */
    public V put(K key, V value) {
         if (table == EMPTY_TABLE) {//如果表为空,则先初始化容量
            inflateTable(threshold);
        }
        if (key == null)
            return putForNullKey(value);//如果key为空,则插入到下标为0的桶中,采取的方式是头插法
        int hash = hash(key);//对key的hashCode进行扰动计算
        int i = indexFor(hash, table.length);//根据扰动计算后的hash值求下标
        //遍历表判断该key是否已经存在,如果存在则覆盖旧值,并返回旧值
        for (Entry<K,V> e = table[i]; e != null; e = e.next) {
            Object k;
            if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
                V oldValue = e.value;
                e.value = value;
                e.recordAccess(this);
                return oldValue;
            }
        }
        modCount++;//modCount为此hash表被修改的次数
        addEntry(hash, key, value, i);//重点在这一行,如果插入的元素在表中不存在,则插入一个新节点
        return null;
    }
    

    接着进入addEntry()方法

    /**
     * Adds a new entry with the specified key, value and hash code to
     * the specified bucket.  It is the responsibility of this
     * method to resize the table if appropriate.
     * 在指定的桶中添加一个具有指定的键、值和哈希代码的新条目。
     * 这个方法的责任是在适当的时候调整表的大小。
     */
    void addEntry(int hash, K key, V value, int bucketIndex) {
    //如果元素个数超过了阈值且插入的元素的桶中已经有其它元素,则执行扩容操作
        if ((size >= threshold) && (null != table[bucketIndex])) {
            resize(2 * table.length);//扩充为原来的两倍大小,最大容量为 Integer.MAX_VALUE
            hash = (null != key) ? hash(key) : 0; //对新元素key的hashCode进行扰动计算
            bucketIndex = indexFor(hash, table.length);//根据扰动计算后的hash值求下标
        }
    	//创建新节点 
        createEntry(hash, key, value, bucketIndex);
    }
    

    可以看到这里是先进行扩容,然后再插入新元素

    createEntry()

    /**
     * Like addEntry except that this version is used when creating entries
     * as part of Map construction or "pseudo-construction" (cloning,
     * deserialization).  This version needn't worry about resizing the table.
     * 与addEntry类似,只是这个版本在创建条目作为地图构建或 "伪构建"(克隆、反序列化)的一部分时使用。 
     * 这个版本不需要担心调整表的大小。
     */
    void createEntry(int hash, K key, V value, int bucketIndex) {
        Entry<K,V> e = table[bucketIndex];//根据下标获取链表上的头元素
        table[bucketIndex] = new Entry<>(hash, key, value, e);//创建一个新节点,把原链表中的头元素作为新节点的next引用,这里可以看出使用的是头插法
        size++;//元素个数+1
    }
    

    我们看看resize()是怎么执行扩容的

    /**
     * Rehashes the contents of this map into a new array with a
     * larger capacity.  This method is called automatically when the
     * number of keys in this map reaches its threshold.
     * 将该地图的内容重新洗成一个容量更大的新数组。 
     * 当这个地图中的键的数量达到阈值时,这个方法会被自动调用。
     * If current capacity is MAXIMUM_CAPACITY, this method does not
     * resize the map, but sets threshold to Integer.MAX_VALUE.
     * 如果当前容量是MAXIMUM_CAPACITY,这个方法不会调整地图的大小,
     * 而是将阈值设置为Integer.MAX_VALUE。
     * This has the effect of preventing future calls.
     * 这具有防止未来调用的效果。
     */
    void resize(int newCapacity) {
        Entry[] oldTable = table;//保存旧的数组
        int oldCapacity = oldTable.length;//保存旧数组的长度
        if (oldCapacity == MAXIMUM_CAPACITY) {//如果旧数组长度达到了1 << 30
            threshold = Integer.MAX_VALUE;//把阈值设为 1 << 31 - 1,并且后续不会触发扩容
            return;
        }
        Entry[] newTable = new Entry[newCapacity];//建立新的数组,长度为旧数组的两倍
        //把旧表中的元素转移到新表,重新计算旧表中元素的下标
        transfer(newTable, initHashSeedAsNeeded(newCapacity));//注:此时新加入的元素并没有一起重新计算下标,而是在扩容成功后再单独计算下标,计算成功后才把新元素添加到容器中
        table = newTable;//将容器替换为新表
        threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);//计算下一次触发扩容的阈值
    }
    

    initHashSeedAsNeeded()函数的作用为初始化hash掩码值,此处不做多展示

    /**
     * Transfers all entries from current table to newTable.
     * 将所有元素从旧表转移到新表。
     */
    void transfer(Entry[] newTable, boolean rehash) {
        int newCapacity = newTable.length;//保存新表的长度
        //遍历表,重新计算下标
        for (Entry<K,V> e : table) {
            while(null != e) {
                Entry<K,V> next = e.next;
                if (rehash) {
                    e.hash = null == e.key ? 0 : hash(e.key);//对key的hashCode进行扰动计算
                }
                int i = indexFor(e.hash, newCapacity);//根据扰动计算后的hash值求下标
                e.next = newTable[i];
                newTable[i] = e;
                e = next;//放到链表中的头部(头插法)
            }
        }
    }
    

    总结一下java7中插入元素的流程为:

    HashMap元素的插入流程以及扩容操作_第1张图片

  • Java8
    同样我们先来看put方法

       /**
         * Associates the specified value with the specified key in this map.
         * If the map previously contained a mapping for the key, the old
         * value is replaced.
         * 将指定的值与该地图中指定的键相关联。
         * 如果该地图以前包含一个键的映射,那么旧的值将被替换。
         */
        public V put(K key, V value) {
            return putVal(hash(key), key, value, false, true);
        }
    

    可以看到这里调用了hash(key)方法,我们后面再分析这个函数,点进去putVal()方法

      /**
     * Implements Map.put and related methods.
     *	实现了Map.put和相关方法
     * @param hash key的hash值
     * @param key 要存储的key
     * @param value 要存储的value
     * @param onlyIfAbsent 如果当前位置已存在一个值,是否替换,false是替换,true是不替换
     * @param evict 表是否在创建模式,如果为false,则表是在创建模式
     * @return previous value, or null if none 返回旧值,没有则返回空
     */
    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)
            n = (tab = resize()).length;//调用resize()进行扩容,Java7中初始化和扩容是两个函数,Java8中整和为一个函数了
        //计算下标,判断该下标的桶中是否有元素,如果没有则直接放入桶中
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);//放入桶中的头元素
        else {
            Node<K,V> e; K k;
             //如果有元素并且发生了hash碰撞,判断两个元素的key是否相等,相等则覆盖
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
             //如果key不相等,判断桶中的元素是否为红黑树节点,如果是则插入到树中
            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) {//p.next = null代表此元素是尾部元素
                        p.next = newNode(hash, key, value, null);//插入到尾部
                        //判断当前链表的元素是否超过要转换为红黑树的阈值 (节点数超过8并且数组长度超过64就要转换为红黑树)
                        if (binCount >= TREEIFY_THRESHOLD - 1) //此处-1只是为了树化做缓冲,并不代表元素个数达到8-1=7就进行树化,真正判断树化的方法在下一行
    
                            treeifyBin(tab, hash);//判断是否满足树化条件,满足则转成红黑树储存
                        break;
                    }
                    //遍历链表,看链表中是否存在hash和key与要插入进来的元素相同,如果相同则跳出循环;
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;//此处跳出表示链表中发生了hash冲突,并且两个元素的key相同
                    p = e;
                }
            }
            //链表中存在相同的key,直接覆盖旧值并返回旧值
            if (e != null) { // existing mapping for key
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;//覆盖旧值
                afterNodeAccess(e);//访问后回调
                return oldValue;//返回旧值
            }
        }
        ++modCount;//表的操作记录+1
        //判断当前的元素个数是否达到阈值,达到则扩容;可以看到这里是插入成功后才扩容,而Java7中是先扩容后插入
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict); //插入后回调
        return null;
    }
    

    扩容操作

     /**
     * Initializes or doubles table size.  If null, allocates in
     * accord with initial capacity target held in field threshold.
     * Otherwise, because we are using power-of-two expansion, the
     * elements from each bin must either stay at same index, or move
     * with a power of two offset in the new table.
     * 初始化或加倍表的大小。如果为空,则按照字段阈值中的初始容量目标进行分配。
     * 否则,因为我们使用的是2次方扩展,所以每个bin中的元素必须保持在相同的索引上,
     * 或者在新表中以2的幂的偏移量移动。
     * @return the table 返回正在使用的表
     */
    final Node<K,V>[] resize() {
        Node<K,V>[] oldTab = table;
        int oldCap = (oldTab == null) ? 0 : oldTab.length;
        int oldThr = threshold;
        int newCap, newThr = 0;
        //如果原表容量>0(表示初始化过,Java8中扩容和初始化为一个方法)
        if (oldCap > 0) {
        	//如果原容量已经达到最大容量了,无法进行扩容,直接返回
            if (oldCap >= MAXIMUM_CAPACITY) {
                threshold = Integer.MAX_VALUE;//把阈值设置为int最大值,会导致后续无法触发扩容操作
                return oldTab;//返回旧表
            }
             //如果旧容量扩容后的值大小大于等于默认值并且小于最大值,把容量扩充为原来的两倍
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
                newThr = oldThr << 1; //设置新容量为旧容量的两倍
        }
        /**
        * 从构造方法我们可以知道
        * 如果没有指定initialCapacity, 则不会给threshold赋值, 该值被初始化为0
    	* 如果指定了initialCapacity, 该值被初始化成离initialCapacity的最小的2的次幂
    	* 这里这种情况指的是原table为空,并且在初始化的时候指定了容量,
    	* 则用threshold作为table的实际大小
    	*/
        else if (oldThr > 0) // 初始容量被置于阈值中
            newCap = oldThr;
        //构造方法中没有指定容量,则使用默认值(此情况一般出现于调用空构造方法场景)
        else {
            newCap = DEFAULT_INITIAL_CAPACITY;//新容量为1<<16
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);//计算新的阈值
        }
        // 计算指定了initialCapacity情况下的新的 threshold
        if (newThr == 0) {
            float ft = (float)newCap * loadFactor;
            newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                      (int)ft : Integer.MAX_VALUE);
        }
        threshold = newThr;
       /**
       	 * 从以上操作我们知道, 初始化HashMap时, 
    	 * 如果构造函数没有指定initialCapacity, 则table大小为16
         * 如果构造函数指定了initialCapacity, 则table大小为threshold,
         * 即大于指定initialCapacity的最小的2的整数次幂
         * 从下面开始, 初始化table或者扩容, 实际上都是通过新建一个table来完成
         */ 
         
        @SuppressWarnings({"rawtypes","unchecked"})
        /**
       	  * 此处的执行逻辑类似于Java7的transfer()函数,实质上是通过旧表和新表做数据迁移
       	  * 只是在Java8中设计到红黑树节点的变换,所以逻辑比Java7复杂
       	  */
        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) {
                  /**
                  	* 这里注意, table中存放的只是Node的引用,
                  	* 这里将oldTab[j]=null只是清除旧表的引用,  
                  	* 但是真正的node节点还在, 只是现在由e指向它,  
                  	*/
                    oldTab[j] = null;
                    //下标为j的旧桶中只有一个节点,直接放入新桶中
                    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 { 
                        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) {//e的hash值相对于旧容量的最高位是否是0,为0表示此节点不需要被转移,即新下标 = 旧下标
                                if (loTail == null)//不需要转移链表的尾节点为空代表检索刚开始,此时的e是桶中的第一个元素
                                    loHead = e;//把e保存为不需要转移链表的头节点
                                else//代表此时正在被检索的节点不是原桶中的头元素
                                    loTail.next = e;//把当前正在检索的节点作为不需要转移链表尾节点的next节点,即当前被检索的节点作为新的尾节点(这里可以看出执行的是尾插法)
                                loTail = e;	//把e记录为不需要转移链表的尾节点	
                            }
                            //找出拆开后不在原下标桶中的节点,保存在需要转移的链表中
                            else {//e的hash值相对于旧容量的最高位是否是1,为1表示此节点需要被转移,即新下标=旧下标+旧容量
                                if (hiTail == null)//需要转移链表的尾节点为空代表检索刚开始,此时的e是桶中的第一个元素
                                    hiHead = e;//把e保存为需要转移链表的头节点
                                else//代表此时正在被检索的节点不是原桶中的头元素
                                    hiTail.next = e;//把当前正在检索的节点作为需要转移链表尾节点的next节点,即当前被检索的节点作为新的尾节点
                                hiTail = e;	//把e记录为需要转移链表的尾节点	
                            }
                        } while ((e = next) != null);//一直检索到原桶中的尾元素,结束循环(尾节点没有next节点)
                        //这里做了进一步的验证,判断分出来的不需要转移链表是否有尾节点
                        if (loTail != null) {//为空则表示原桶中的所有节点都需要转移
                            loTail.next = null;//如果有则把尾节点的next引用置空(因为此时链表中的尾节点不代表是原桶中的尾节点,如果不置空可能会导致链表连接错误)
                            newTab[j] = loHead;//把不需要转移链表放入新表中,位置为原下标
                        }
                        //同上,判断分出来的需要转移链表是否有尾节点
                        if (hiTail != null) {//为空则表示原桶中的所有节点都不需要转移
                            hiTail.next = null;//如果有则把尾节点的next引用置空(防止错误引用链)
                            newTab[j + oldCap] = hiHead;//把需要转移链表放入新表中,位置为原下标+旧容量
                        }
                    }
                }
            }
        }
        return newTab;//返回新表
    }
    

    java8resize()函数有点复杂,我们拆分为两个问题来看

    • 为什么java8不需要调用hash函数重新计算下标
      下标计算公式为 index = (n - 1) & hash
      假设扩容前的长度为16,keyhash值为xxxx 1011,则扩容前元素的下标计算过程为:

      int index  =(n - 1) & hash;
      	 	   = (16 - 1) & xxxx 1011
      	 	   = 15  & xxxx 1011
      	 	   = 1111 & xxxx 1011
      	 	   = 1011
      	 	   = 11
      

      因为与(&)计算原理为必须对位的两个值都是1才为1,所以这里实际参与计算的只有hash值的低4位;
      由于HashMap的容量位2的次方,所以容量-1可以转化为N个相连的数位1;
      在发生扩容后,容量增长为原来的两倍 - 32,此时参与重新计算下标的值为(n-1)31,转化为二进制为 1 1111;

      int index  =(n - 1) & hash;
      	 	   = (32 - 1) & xxxx 1011
      	 	   = 31  & xxxx 1011
      	 	   = 1 1111 & xxxx 1011
      

      我们暂停在这一步,画图看一下两次运算结构的差别

      HashMap元素的插入流程以及扩容操作_第2张图片
      可以看到扩容前hash值实际参与运算的数位为4,扩容后实际参与运算的数位为5;由于扩容前后hash值是不变的,所以运算结果的后4位也不会发生改变;
      HashMap元素的插入流程以及扩容操作_第3张图片
      当扩容后的最高位,也就是第5位对应的x值为0时,最后的运算结果和原来一样,即此节点在新表中的下标和原来一样,归入不需要迁移的链表中;
      当第5位对应的x值为1时,最后运算结果的后4为和原来一样,只是第五位变成了1,下标值发生了改变,归入需要迁移的链表中;
      可以理解为:

      11011 = 1011 + 1 0000 ;
      新下标 = 旧下标 + 旧容量;
      

      总结

      • 当x=0时(也就是源码中 (e.hash & oldCap) == 0),此节点在新表中的位置不需要移动。
      • 当x=1时(也就是源码中 (e.hash & oldCap) == 1),此节点在新表中的位置发生了移动,移动的方式为:新下标 = 旧下标 + 旧容量。

现在总算知道为什么需要拆成两个链表了吧。

  • java8怎样拆分原桶中的数据为两个链表的(需要转移链表和不需要转移链表)
    我们来画图分析一下它拆分数据的过程:

    假设在旧表下标为n的桶中发现有数据链需要进行拆分
    HashMap元素的插入流程以及扩容操作_第4张图片
    首先我们检索n中的第一个元素,data1,假设经过计算data1需要迁移
    HashMap元素的插入流程以及扩容操作_第5张图片
    继续检索data1的next节点data2,经过计算,data2也需要迁移;
    注意此时需要迁移链表中data1的next引用仍未改变,指向data2;
    此时准备检索data1的next节点,这个data1并非是需要迁移列表中的data1,而是原桶中的data1;
    HashMap元素的插入流程以及扩容操作_第6张图片
    继续检索data2的next节点data3,经过计算data3不需要迁移;HashMap元素的插入流程以及扩容操作_第7张图片
    此时data2的next引用依旧指向data3;
    继续检索data3的next节点data4,计算得出data4不需要迁移;
    HashMap元素的插入流程以及扩容操作_第8张图片
    继续检索data4的next节点data5,计算得出data5需要迁移;
    HashMap元素的插入流程以及扩容操作_第9张图片
    原桶检索完成,开始最后的验证;
    HashMap元素的插入流程以及扩容操作_第10张图片
    判断需要迁移的链表是否有尾节点,检索到尾节点data5,把data5的next引用置空,迁移到新表n+旧表长度的位置;
    HashMap元素的插入流程以及扩容操作_第11张图片
    判断不需要迁移的链表是否有尾节点,检索到尾节点data4,把data4的next引用置空,迁移到新表n的位置;
    HashMap元素的插入流程以及扩容操作_第12张图片
    至此旧表中下标为n的桶中的数据已经迁移到新表,继续往下检索旧表n+1的桶,直到整个旧表中的桶被检索完成,结束扩容操作。

    总结一下java8中插入元素的流程为:

    HashMap元素的插入流程以及扩容操作_第13张图片

4.一些问题的探讨

  • 为什么我们需要hash()函数,而不是直接用keyhashcode直接计算下标

    举个例子:
    假设现在我们有一个HashMap,容量为16,现在我往里面put两个元素A和B;
    元素A的key记为keyA,B为keyB
    经过计算,keyAhashcode为 xxxx 1001,keyBhashcode为 xxxx 1001,x有可能为1或0;
    假设keyAkeyBhashcode最低位相同,最高位不同;
    现在我们通过取余计算这两个元素的下标 h & (length-1);

    indexA = keyA.hashCode() & (16-1);
       = xxxx 1001 &  (16-1);
       = xxxx 1001 &  15;
       = xxxx 1001 &  1111;
       = 1001;
       = 9;
    
    indexB = keyB.hashCode() & (16-1);
       = xxxx 1001 &  (16-1);
       = xxxx 1001 &  15;
       = xxxx 1001 &  1111;
       = 1001;
       = 9;
    

    通过计算可以发现,当容量为16时,通过对两个最高位不同、最低位相同的key求下标时,最后的结果是相等的;假如这样的key很多,则可能导致大量元素都被放入同一个桶中,造成hash冲突的可能性非常高。

    为了避免这种情况出现,在进行下标计算前,我们需要通过hash函数对keyhashCode进行扰动运算,让高位也参与运算,使得分布更加散列,最终达到降低哈希冲突的目的。

  • java8中为什么头插法改成尾插法

    头插法

    流程图
    HashMap元素的插入流程以及扩容操作_第14张图片
    下标为3的桶中要插入一个新节点,值为data4
    第1步:取出原桶中的头节点-data1,把原头节点作为新节点data4的next节点;
    第2步:将新节点取代原头节点的位置;
    如图:HashMap元素的插入流程以及扩容操作_第15张图片

    代码中的实现为:

    /**
     * Transfers all entries from current table to newTable.
     * 把旧表中的数据转移到新表
     */
    void transfer(Entry[] newTable, boolean rehash) {
        int newCapacity = newTable.length;
        //遍历表
        for (Entry<K,V> e : table) {//循环遍历表中的每个index
            while(null != e) {//开始循环
                Entry<K,V> next = e.next;//保存该节点的next节点,作为下一次循环使用
                if (rehash) {//是否重新计算hash值
                    e.hash = null == e.key ? 0 : hash(e.key);
                }
                int i = indexFor(e.hash, newCapacity);//重新计算下标
                e.next = newTable[i];//把next引用指向新下表的头节点
                newTable[i] = e;//放入新下标链表的头节点
                e = next;//取出保存的next节点,准备开始下一次循环
            }
        }
    }
    

    尾插法

    流程图
    HashMap元素的插入流程以及扩容操作_第16张图片
    下标为3的桶中要插入一个新节点,值为data4
    第1步:取出原桶中的头节点-data1,找到data1的next节点data2
    第2步:重复步骤1,继续寻找找到data2的next节点,如果next节点为空,表示此元素是链表尾部的元素,停止寻找;
    第3步:将新节点作为链表尾部节点的next节点,把新节点插入到链表尾部;
    如图:
    HashMap元素的插入流程以及扩容操作_第17张图片

    代码中的实现为:

     final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
       ...
       for (int binCount = 0; ; ++binCount) {//遍历此链表的节点
    		if ((e = p.next) == null) {//如果下一个节点的next节点为空,代表此节点就是尾节点
              	p.next = newNode(hash, key, value, null);//把原尾节点的next节点指向新节点
                   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;
                }
       ...
    }
    

    从效率上看,找头节点的开销远比找尾节点小的多,那为什么java8要冒着开销增大的风险转成尾插法呢?

    我们来看看发生扩容时,头插法的数据转移的正常流程:

    例1:

    假设HashMap的初始容量是2,现在我们往容器里面put3条数据;
    PS:此处主要是为了做迁移数据的展示,元素扩容时机与实际情况有差距
    put(5,data)、 put(7,data)、put(3,data);HashMap元素的插入流程以及扩容操作_第18张图片
    发生扩容,新建一个表,大小为原来的两倍;
    HashMap元素的插入流程以及扩容操作_第19张图片
    开始迁移数据,遍历链表,查询到下标为1的桶中有数据;
    取出链表中的头节点key=3,计算在新表中的下标为3%4=3,迁移到新表;
    HashMap元素的插入流程以及扩容操作_第20张图片
    取出下一个节点key=7,计算下标为7%4=3,迁移到新表;
    HashMap元素的插入流程以及扩容操作_第21张图片取出下一个节点key=5,计算下标为5%4=1,迁移到新表;HashMap元素的插入流程以及扩容操作_第22张图片
    旧表中所有元素迁移,迁移结束。
    可以看到,旧表中的元素顺序为节点key3 —节点key7;
    而新表中顺序倒置了过来节点key7 —节点key3。

    多线程状况下,可能出现异常的数据转移流程:

    我们把*transfer()*这个函数简化一下,重点观察它的转移流程

      Entry<K,V> next = e.next;//保存正在迁移节点的next节点  ![请添加图片描述](https://img-blog.csdnimg.cn/a2d998f174544ccaaec64a9102aca4b1.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3FpY2hhMzcwNQ==,size_16,color_FFFFFF,t_70)
    
      int i = indexFor(e.hash, newCapacity);//计算迁移节点的新下标
      e.next = newTable[i];//把新下表桶中的头节点设为迁移节点的next节点
      newTable[i] = e;//
      e = next;
    

    例2:

    假设HashMap的初始容量是2,现在我们往容器里面put3条数据(),put(5,data)、 put(7,data)、put(3,data);
    现在有A、B两个线程同时对表进行迁移操作;假设线程A执行到刚刚对数组完成扩容的那一步就挂起了;
    线程A让出时间片,线程B开始执行,直到迁移完成,当前的结构为:
    HashMap元素的插入流程以及扩容操作_第23张图片由于线程B已经执行完毕,根据Java内存模型,现在表中的节点都是主存中最新值,即:7.next = 3,3.next = null,5.next = null。
    此时切换回线程A上,在线程A挂起的时间点继续执行:
    HashMap元素的插入流程以及扩容操作_第24张图片
    继续执行;
    HashMap元素的插入流程以及扩容操作_第25张图片
    继续执行;
    HashMap元素的插入流程以及扩容操作_第26张图片
    我们整理一下图:
    HashMap元素的插入流程以及扩容操作_第27张图片
    可以看到此时key3.next指向key7key7.next指向key3,形成了环形链;在后续操作中只要涉及轮询hashmap的数据结构,就会在这里发生死循环——Infinite Loop;

总结:

  1. Java7及之前为什么采用头插法

    因为 Java7的作者认为最近插入的元素最大概率下次还会访问到(缓存的时间局部性原则),将最近插入的数据放在头节点可以提高查找的效率,减少查询的次数。

  2. 既然头插法有链表成环的问题,为什么直到 Java8 才采用尾插法来替代头插法

    只有在并发情况下,头插法才会出现链表成环的问题;多线程情况下,HashMap 本就非线程安全的,所以直到 Java8才修复这个bug。

  3. 既然 Java8 没有链表成环的问题,那是不是说明可以把 Java8 中的 HashMap 用在多线程中

    即使解决了链表成环的问题中,并不代表HashMap 在多线程情况下没有其它并发问题;比如:上秒 put 的值,下秒 get 的时候却不是刚 put 的值;因为操作都没有加锁,不是线程安全的;如果需要保证线程的安全性请使用ConcurrentHashMap

  • HashMap如何解决Hash冲突

HashMap元素的插入流程以及扩容操作_第28张图片

  • 为什么 HashMap 中 String、Integer 这样的包装类适合作为 key 键

HashMap元素的插入流程以及扩容操作_第29张图片

  • HashMap 中的 key若 Object类型, 则需实现哪些方法?

HashMap元素的插入流程以及扩容操作_第30张图片

  • 为什么HashMap具备下述特点:键-值(key-value)都允许为空、线程不安全、不保证有序、存储位置随时间变化

HashMap元素的插入流程以及扩容操作_第31张图片

你可能感兴趣的:(数据结构与算法,java,算法,hashmap)