JDK8 HashMap源码解析—P1

417 阅读9分钟

  本文已参与「新人创作礼」活动, 一起开启掘金创作之路。

前言

  HashMap作为日常开发中最常使用的一个数据结构了,同时个人也认为这是最重要的一个数据结构,因此涉及到数组、链表、Hash算法、红黑树等等,基本HashMap搞懂了,数据结构这块没啥问题了。因为HashMap涉及到的知识点很多,所以也分几个部分来讲,这篇是P1,先了解一下HashMap中一些重要的属性和put方法。

概览

  先来总览一下HashMap的继承关系吧

public class HashMap<K,V> extends AbstractMap<K,V>
    implements Map<K,V>, Cloneable, Serializable {

   ...............

}

  HashMap继承自AbstractMap类,实现了MapSerializablecloneable接口。

  下面以一个常见场景引入今天的分析。

public class HashMapTest {


    public static void main(String[] args) {
        
        Map<Integer, String> map = new HashMap<>();//1

        map.put(1, "1");//2

        System.out.println(map.get(1));//3
    }
}

构造方法

  先从构造方法1开始看起吧,HashMap提供了三种构造方法,其中涉及到的变量分别如下所示:

//默认加载因子 实验所得 时间和空间的折中
static final float DEFAULT_LOAD_FACTOR = 0.75f;

 //无参构造
public HashMap() {
        this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
    }

//容量入参的构造方法
public HashMap(int initialCapacity) {
        //底层调用的是双参数构造方法
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }

// HashMap容量上限  2的30次方
static final int MAXIMUM_CAPACITY = 1 << 30;

public HashMap(int initialCapacity, float loadFactor) {
        //入参校验
        if (initialCapacity < 0)
            throw new IllegalArgumentException("Illegal initial capacity: " +
                                               initialCapacity);
        // 如果传入的initialCapacity大于MAXIMUM_CAPACITY
        if (initialCapacity > MAXIMUM_CAPACITY)
            //则将initialCapacity设为MAXIMUM_CAPACITY
            initialCapacity = MAXIMUM_CAPACITY;
        // 负载因子参数校验
        if (loadFactor <= 0 || Float.isNaN(loadFactor))
            throw new IllegalArgumentException("Illegal load factor: " +
                                               loadFactor);
        this.loadFactor = loadFactor;
        //计算阈值  此时数组table还没有初始化 会在put方法中重新进行赋值
        this.threshold = tableSizeFor(initialCapacity);
    }

  这里重点讲一下tableSizeFor方法,这个方法将返回大于等于给定的initialCapacity的最小的2的n次幂,什么意思?打个比方,如果initialCapacity为10的话,大于等于10,且是2的n次幂的有很多,16,32,64...等等,但只有16是与10的差值最小的那个,所以tableSizeFor最后返回的就是16。下面看一下这个方法是如何实现的。


/** 
   返回大于等于目标容量的最小的2次幂
*/
static final int tableSizeFor(int cap) {
       //先减去1
        int n = cap - 1;//1
        // 先无符号右移 再 | 运算
        n |= n >>> 1;//2
        n |= n >>> 2;//3
        n |= n >>> 4;//4
        n |= n >>> 8;//5
        n |= n >>> 16;//6
        return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;//7
    }

  就以10为例,运行完第1行后,n=9,然后下面开始进行右移以及位运算,如下所示:

n=10
n-1=9
                                      
			0000 1001                          
	>>> 1	0000 0100   	                   
	    |   0000 1101
-----------------------
            0000 1101
	>>> 2	0000 0011
        |   0000 1111
-----------------------
            0000 1111
	>>> 4   0000 0000
	    |   0000 1111
-----------------------
            0000 1111
	>>> 8   0000 0000
        |   0000 1111
-----------------------
            0000 1111
	>>> 16	0000 0000
        |   0000 1111

  最后所得的n为15,且满足由于 n>0 && n<MAXIMUM_CAPACITY,因而最后返回的为n+1即为16。需要注意的是,这里为什么一定要先进行n=cap-1这个操作。假设此时,传入的是2的n次幂,如果不先减去1的话,此时cap为2^n形式,最后进行计算的结果一定是2*2^n这种形式,但tableSizeFor方法要求的是返回大于等于cap的最小的2^n,结果应该就是2^n而不是2*2^n。举个例子,传入的cap为16,已经是2^4,如果不先减去1的话,返回结果是32,违背了tableSizeFor的含义,读者可自行验证一下。

  接下来,再来思考几个问题:

  • 为什么这里要进行1、2、4、8、16这样的运算呢?
    • 对于局部来说,其实就是为了把高位移到低位(对于4位来说,前两位是高位,后两位是低位)这样之后再进行|操作,那么就可以将局部得到全1
  • 为什么这里只是到16就结束了呢?
    • 因为我们这里针对的数值都是int类型,在Java当中int类型占到4个字节也就是32位。为什么不进行32位右移呢,这是因为32位右移之后就变成全0了|操作就没有什么意义,也不会影响结果,只是多余的操作**。

常用方法

put方法

  接下来就是常用的put方法,其底层实际调用的是putVal方法。

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

  hash方法用来计算keyhashcode

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

  put方法

//存放数据的数组
transient Node<K,V>[] table;

// 入参 hash:通过 hash 算法计算出来的值。
// 入参 onlyIfAbsent:false 表示即使 key 已经存在了,仍然会用新值覆盖原来的值,默认为 false
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        //数组tab
        Node<K,V>[] tab; 
    // n 表示数组的长度,i 为数组索引下标,
        int n, i;
    // p 为 i 下标位置的 Node 值
        Node<K,V> p; 
    
        //1.1若数组为空的话
        if ((tab = table) == null || (n = tab.length) == 0)
            //1.2 使用resize方法进行初始化
            n = (tab = resize()).length;
        //如果当前索引位置tab[i]是空的
        // (n-1)&hash 为了使key分散的更均匀
        if ((p = tab[i = (n - 1) & hash]) == null)
            //直接生成新的节点在当前索引位置上
            tab[i] = newNode(hash, key, value, null);
        else {
            // 否则的话 则说明此处产生了hash冲突
            // e 当前节点的临时变量
            Node<K,V> e; 
            // key的临时变量
            K k;
            // 如果 key 的 hash 和值都相等  即相同的key val可能相同,可能不同 此时直接覆盖即可
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                //直接把当前下标位置的 Node 值赋值给临时变量
                e = p;
            // 如果是红黑树,使用红黑树的方式新增
            else if (p instanceof TreeNode)
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            // 如果是个链表,把新节点放到链表的尾端
            else {
                //自旋
                for (int binCount = 0; ; ++binCount) {
                    // e = p.next 表示从头开始,遍历链表
                    // p.next == null 表示p后面没有节点,即是链表的尾节点
                    if ((e = p.next) == null) {
                        // 把新节点放到链表的尾部
                        p.next = newNode(hash, key, value, null);
                        // 当链表的长度大于等于 8 时,链表转红黑树
                        //  static final int TREEIFY_THRESHOLD = 8; 树化的阈值
                        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=p.next 用于在遍历过程中 p一直向后移动
                    p = e;
                }
            }
             // 此时已经插入成功
            if (e != null) { 
                //记录一下旧值
                V oldValue = e.value;
                //当 onlyIfAbsent 为 false 时,才会覆盖值  此值默认为false
                if (!onlyIfAbsent || oldValue == null)
                    //进行赋值操作
                    e.value = value;
                // hashMap中这个方法无用
                afterNodeAccess(e);
                //返回旧值
                return oldValue;
            }
        }
    // 记录 HashMap 的数据结构发生了变化 增删改都可以算作 数据结构变化
        ++modCount;
    // 根据size大小判断是否要开始扩容
        if (++size > threshold)
            // 扩容
            resize();
    // hashMap中这个方法无用
        afterNodeInsertion(evict);
        return null;
    }

总结一下,HashMapput过程:

  • 保存数据的数组是否为空,若为空则直接初始化;
  • 如果数组下标所在位置为空,则直接进行赋值操作;
  • 如果此时数组下班不为空,即产生了hash冲突,则使用链地址法进行解决;
  • 如果此时链表中存在相同的key,则直接进行覆盖;
  • 如果不同,此时如果是链表的话,则直接插入到链表尾部;
  • 如果是红黑树的话,则直接插入到红黑树中;
  • 插入成功后,根据onlyIfAbsent来判断是否直接覆盖旧值
  • 返回旧值

put方法的流程图如下所示:

2022-04-17 000204.png

resize方法

  resize方法一般有两个场景会触发:

  • 一个是调用put方法时,若是此时HashMap尚未初始化,则会调用resize方法进行初始化;
  • 另一个是当目前HashMap中元素个数大于阈值threshold时,调用resize方法进行扩容。
final Node<K,V>[] resize() {
        Node<K,V>[] oldTab = table;
        //判断此时hashmap是否已经初始化了
        int oldCap = (oldTab == null) ? 0 : oldTab.length;
        int oldThr = threshold;
        int newCap, newThr = 0;
        // 1.根据oldCap是否大于0来判断是初始化还是扩容
        //旧容量大于0 说明是扩容
        if (oldCap > 0) {
            if (oldCap >= MAXIMUM_CAPACITY) {//MAXIMUM_CAPACITY=2^30
                //如果此时hashmap中元素个数已经超过最大容量 直接退出
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
            // 不是的话 新容量为旧容量的2倍 且小于最大容量
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
                //新阈值也是之前旧阈值的两倍
                newThr = oldThr << 1; // double threshold
        }
      
      //2.若是初始化 在使用了带有capacity构造函数时,threshold就是此时hashmap的容量大小
        else if (oldThr > 0) // initial capacity was placed in threshold
            //新容量就等于旧阈值
            newCap = oldThr;
    //2.若是初始化,但使用了无参构造,则容量和阈值都使用默认的参数
        else {               // zero initial threshold signifies using defaults
            //新容量等于默认容量
            newCap = DEFAULT_INITIAL_CAPACITY;
            //新阈值就等于默认负载因子与默认容量的乘积
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
        }
        
        //用户自定义了map的初始化操作
        if (newThr == 0) {
            float ft = (float)newCap * loadFactor;
            newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                      (int)ft : Integer.MAX_VALUE);
        }
        //更新threshold字段等于新阈值
        threshold = newThr;
        @SuppressWarnings({"rawtypes","unchecked"})
            //实例化新的数组 容量为newCap
            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;
                    //如果是树的话 调用split方法进行处理
                    else if (e instanceof TreeNode)
                        ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                    else { // preserve order
                        //如果没超过8个 是链表
                        //两条链表 高位和低位 分别用来存储同一个链表上的数据
                        // 后面根据分配分别插入到新数组中不同的位置
                        Node<K,V> loHead = null, loTail = null;
                        Node<K,V> hiHead = null, hiTail = null;
                        Node<K,V> next;
                        do {
                            //下一个节点
                            next = e.next;
                            //当前元素的hash值 & 旧数组容量==0 使用低位链表来进行记录
                            if ((e.hash & oldCap) == 0) {
                                if (loTail == null)
                                    loHead = e;
                                else
                                    loTail.next = e;
                                loTail = e;
                            }
                            //如果 hash值 & 旧数组容量==1 使用高位链表来进行记录
                            else {
                                if (hiTail == null)
                                    hiHead = e;
                                else
                                    hiTail.next = e;
                                hiTail = e;
                            }
                        } while ((e = next) != null);
                        //开始将这两条链表移动到新数组中
                        // lowHead  与旧数组的index保持一致 然后放到新数组中的index位置上
                        if (loTail != null) {
                            loTail.next = null;
                            newTab[j] = loHead;
                        }
                        //highHead 在旧数组index的基础上+旧数组的容量,然后放到新数组的 
                        //index+oldCap位置处
                        if (hiTail != null) {
                            hiTail.next = null;
                            newTab[j + oldCap] = hiHead;
                        }
                    }
                }
            }
        }
      // 返回新的hashMap
        return newTab;
    }

总结一下resize的流程:

  • 判断当前数组是否已经初始化了,如果没有则进行初始化

    • 如果使用的无参构造:
      • 则数组容量=16;
      • 阈值=容量*阈值=12;
    • 如果使用带有capacity的构造方法:
      • 则数组容量就是此时的阈值大小;
  • 如果已经初始化过了,则是扩容操作

    • 旧数组容量是否已经达到最大容量,2^30:
      • 是,新阈值直接设为Integer.MAX_VALUE,直接退出;
      • 否,新数组容量为旧数组容量的2倍,新阈值也是旧阈值的两倍;
  • 创建新数组,开始遍历旧数组中的元素移动到新数组中:

    • 当前元素是单节点元素,则直接计算在新数组中的index位置,然后移动到新数组中;

    • 当前元素是红黑树类型,则调用 split方法进行处理;

    • 当前元素是一个链表,则开始遍历这个链表,使用两条新链表来存储元素:

      • 链表上单个节点 e.hash & oldCap ==0 则移动到lowHead这个链表上

      • 链表上单个节点 e.hash & oldCap==1 则移动到highHead这个链表上;

      • lowHead这个链表,直接使用旧数组中的索引index,放入到新数组中;

      • highHead这个链表,在新数组中的索引为 index+oldCap;

与JDK7中的resize方法的区别

  • JDK7中的resize方法只有扩容这一个功能;JDK8中的resize方法兼具初始化(懒加载,执行put的时候才去初始化数组)和扩容两个功能
  • JDK7resize时,会重新计算每个元素在新数组中的位置JDK8中的resize方法,在移动链表时,利用了元素的hash值,使用e.hash & oldCap这个值来判断这个元素是在新数组中保持与旧数组相同的索引index,还是index+oldCap
  • JDK7中,resize方法在移动链表上的元素时会改变链表元素的相对顺序,如a—>b就会变成b—> a,又因为使用的头插法,所以导致在多线程环境下进行扩容时可能导致链表成环这样在调用get方法时陷入死循环JDK8中采用尾插法,插入到新链表的时候不会改变链表中元素的相对位置,解决了死循环问题;

jdk8中resize方法中的链表移动示意图:

为什么使用 e.hash & oldCap

  计算在新数组中的索引位置使用的e.hash & (newCap-1),由于newCapoldCap两倍,这就会导致参与&运算时, newCap-1将会比oldCap-1多了一位参加运算。如果这个需要新判断的位置上为0,那么index不变,否则变为需要迁移到oldIndex + oldCap这个位置上去。

总结

  这里先简单介绍了JDK8HashMap,如构造方法、put方法以及resize方法,同时对比了一下JDK7JDK8resize方法的不同,以及JDK8中的HashMap是如何解决JDK7中的HashMap在多线程情况下链表成环的问题。后面几篇文章再详细介绍JDK8中的其他方法。 (PS:工作三年后的我再看HashMap的源码,有一番新的感受,如果能熟练掌握了HashMap,对于一个程序员来说,基本上数据结构这块不成问题。写出这个类的程序员们真的🐮)