最详细集合源码解析之HashMap源码解析

295 阅读16分钟

小知识,大挑战!本文正在参与“程序员必备小知识”创作活动。

HashMap是以key,value这种键值对形式存储数据,在jdk1.8开始底层是由数组,链表,红黑树而形成的,在链表长度达到8的时候,并且数据总数达到64,就会将链表转为红黑树。

图片

在学习HashMap源码之前,先抛出几个问题:

  • 为什么底层中数组的长度总是保持2的次方

  • 为什么要对key的哈希值进行扰动

  • 为什么在1.8之前链表是头插法,而从1.8开始变成尾插法

这些问题现在看起来可能还一头雾水,不用怕,待我将hashMap源码分析一波,这些问题迎刃而解,吊打面试官。

在HashMap源码中大量运用了一些进制运算,一些运算符可能大家不提前了解会对下面源码的阅读带来困扰,这里先简单的列举一下。

01

<< ,>>,>>> 运算符

<<:这个是向左移,不分正负数,低位补0,例如000011,

<< 2 就是向左移两位,那么就变成001100,转为10进制就是变成了原来数值的2的2次方倍

>>: 这个是向右移,如果该位为正数,则高位补零,若为负数,则补1.

>>> :这个是无符号右移,即若该数为正,则高位补0,而若该数为负数,则右移后高位同样补0。

02

 与,或,异或

与:符号表示为&,两者相对应,如果存在0,那就是0,如果全部为1.那就是1.

例如两个二进制进行与操作:0011和0101,根据上面的规则,结果就是0001.

或:符号是| ,如果存在1,那就是1,如果全为0,那就是0

异或:符号是^,如果相等就为0,不相等就是1

这些进制运算符,如果不熟悉,或者是对进制转换不熟悉,可以再去复习一波,这样看源码才更容易理解。

01

HashMap整体继承结构

整体结构图片

HashMap是继承的AbstractMap,然后实现的Map接口,整体继承体系也不算复杂。

04

字段属性

 /**
     *1向左移位4个,00000001变成00010000,
     * 也就是2的4次方为16,使用移位是因为移位是计算机基础运算,
     * 效率比加减乘除快。
     */
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4// aka 16

    /**
     * 最大容量为2的30次方
     */
    static final int MAXIMUM_CAPACITY = 1 << 30;

    /**
     *加载因子大小,为扩容所使用
     */
    static final float DEFAULT_LOAD_FACTOR = 0.75f;

    /**
     * 当链表转为红黑树的阀值
     */
    static final int TREEIFY_THRESHOLD = 8;

    /**
     *红黑树转为链表的阀值
     */
    static final int UNTREEIFY_THRESHOLD = 6;

    /**
     *当整个hashMap元素超过64时,才有可能转为红黑树
     */
    static final int MIN_TREEIFY_CAPACITY = 64;

    /**
     * 存储元素的数组
     */
    transient Node<K,V>[] table;

    /**
     * 将数据转换成set的另一种存储形式,这个变量主要用于迭代功能
     */
    transient Set<Map.Entry<K,V>> entrySet;

    /**
     * 元素数量
     */
    transient int size;


    transient int modCount;

    /**
     * 临界值,也就是元素达到这个数量时会进行扩容
     *
     * @serial
     */

    int threshold;

    /**
     * 加载因子,这个是个变量
     *
     * @serial
     */
    final float loadFactor;

这里强调下,描述容量的CAPACITY指的是数组的长度而不是集合的元素数量,size才表示的是现有的元素数量

06

构造函数解析

 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;
         //设置阀值
        //tableSizeFor的作用就是生成比传入参数要大的并且是2的次方的数
        this.threshold = tableSizeFor(initialCapacity);
    }

    public HashMap(int initialCapacity) {
        //指定初始容量
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }

    public HashMap() {
        //指定加载因子为默认值
        this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
    }


    public HashMap(Map<? extends K, ? extends V> m) {
        this.loadFactor = DEFAULT_LOAD_FACTOR;
        putMapEntries(m, false);
    }

构造函数中设置阈值调用了tableSizeFor方法,这个方法的作用就是保证你传进来的容量如果不是2的次方,都会被调整为2的次方,例如你传入9,那对于9来说,离它最近并且比它大的

2的次方就是16.这里给大家看下这个方法的源码。

    static final int tableSizeFor(int cap) {
        int n = cap - 1;
        n |= n >>> 1;
        n |= n >>> 2;
        n |= n >>> 4;
        n |= n >>> 8;
        n |= n >>> 16;
        return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
    }

看第一步,可能就突然被整懵了,为啥要进行减一操作呢,先别急,下面会讲解到,我们看到它的计算方式就是n与n的无符号右移之后的结果进行异或运算,我们举个例子来看看

例如5,它的二进制就是00000101:

n |= n >>> 1;
     0000 0101
     0000 0010
     0000 0111

     n |= n >>> 2;
     0000 0111
     0000 0001
     0000 0111

     n |= n >>> 4;
     0000 0111
     0000 0000
     0000 0111
.....

可以看到,其最后运算后的结果都是00000111,再加1其结果就是00001000,,就是5的最小2的整数幂8.

其实这个算法思路就是将数字的最高非0位后面全部置为1


           n=     ;  1000 0000  0000 0000  0000 0000  0000 0000
      n |= n >>> 1;  1100 0000  0000 0000  0000 0000  0000 0000  将最高位拷贝到下1位
      n |= n >>> 2;  1111 0000  0000 0000  0000 0000  0000 0000  将上述2位拷贝到紧接着的2位
      n |= n >>> 4;  1111 1111  0000 0000  0000 0000  0000 0000  将上述4位拷贝到紧接着的4位
      n |= n >>> 8;  1111 1111  1111 1111  0000 0000  0000 0000  将上述8位拷贝到紧接着的8位
      n |= n >>> 16; 1111 1111  1111 1111  1111 1111  1111 1111  将上述16位拷贝到紧接着的16位

由上面可以看出其通过这五次的计算,最后的结果刚好可以填满32位的空间,也就是一个int类型的空间,这就是为什么必须是int类型,且最多只无符号右移16位!

那我们现在就来说一下为什么要减1:

  n = 8为例
     0000 1000
     最后的结果为:
     0000 1111
     对其加一得到的是16,显然没有把自身包含进去

     若减一
     n = 7
     0000 0111
     最后的结果为:
     0000 0111
     对其加一得到的是8

可以看到,当数字本身就是2的次方时,如果不进行减1操作,那么经过运算后就会出现得到的不是它本身,而是比它大的最接近的2的次方,没有把自身包含进去,这是不行的,仅仅一个减1操作就可以规避这个Bug,设计的确实是很巧妙。

06

put方法

put方法是HashMap的方法,先通过一张流程图大致了解下

图片

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

putVal方法主要的核心参数就是通过扰动函数计算出来的hash值,以及,key,value,这个扰动函数hash()的作用就是增加hash的散列性,,随机性,减少hash碰撞

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

通过让key的hash值进行无符号右移16位之后再与本身进行异或的操作,让其hash值的高16位也参与运算

图片

接着来看putVal方法

   final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        //tab:引用当前hashMap的散列表
        // p:当前散列表的元素
        // n:数组的长度
        // 表示数组的下标位置
        Node<K,V>[] tab; Node<K,V> p; int n, i;

        if ((tab = table) == null || (n = tab.length) == 0)
           //延迟初始化,懒加载
            n = (tab = resize()).length;

        //第一种情况:如果当前下标位置的桶位刚好是空的,那就直接创建node将k,v存入其中即可

        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        else {
            // e:n不为null的时候,找到了一个与当前要插入元素key一致的元素
            // k:表示临时的key
            Node<K,V> e; K k;
            //表示当前桶位的头元素与要插入的元素key相同,后续会进行值替换的操作

            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;

            //红黑树的情况
            else if (p instanceof TreeNode)
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            else {
                //链表的情况
                for (int binCount = 0; ; ++binCount) {
                    //迭代到链表最后一个元素,也没有找到要插入元素的key相同的情况
                    //加入到链表末尾
                    if ((e = p.next) == null) {
                        p.next = newNode(hash, key, value, null);
                        //达到树化条件,进行树化
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                           //树化操作
                            treeifyBin(tab, hash);
                        break;
                    }
                    //说明找到相同key的元素需要进行替换操作
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }


            if (e != null) { // existing mapping for key
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;
            }
        }
        ++modCount;
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }

根据上面那个流程图再结合源码,就很好理解了。

  1. 第一步就是判断table数组是否为空,为空就通过resize方法进行初始化,可以看到其初始化是懒加载的思想,并不是hashMap初始化的时候对数组进行初始化,而是在第一次调用put方法的时候再进行初始化,这种做法的目的就是避免资源浪费。

  2. tab[i = (n - 1) & hash],根据hash值计算数组下标位置,如果当前下标位置没有元素,那就直接插入,有的话就先比较当前桶位头元素的key是否与要插入的key相等,相等就替换

  3. 在不满足前两个条件的情况下,就要判断当前桶位是链表还是红黑树,是红黑树就用红黑树的方式进行插入

  4. 如果是链表,那就进行for循环一一遍历,比较是否有相等的key,如果遍历到最后一个结点都没有相等的key,就将值插入到链表的尾节点,插入完成还要看是否达到树化的条件,就是链表长度是否达到8,达到就调用树化的方法进行树化操作。

  5. 最后判断元素是否达到要扩容的阀值,达到就进行扩容操作。

06

resize方法

   final Node<K,V>[] resize() {
        //引用扩容前的哈希表
        Node<K,V>[] oldTab = table;
        //扩容前的table长度
        int oldCap = (oldTab == null) ? 0 : oldTab.length;
        //  扩容前的阈值
        int oldThr = threshold;
        //newCap:扩容之后的数组大小
        //扩容之后的阈值
        int newCap, newThr = 0;
        // 如果 扩容前数组长度大于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
        }
        //扩容前的阈值大于0
        else if (oldThr > 0) // initial capacity was placed in threshold
            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;
        //扩容之前,table不为null
        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;

                            //hash 0 1111
                            //hash 1 1111
                            //oldCap 10000
                            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;
    }

扩容的方法有点长,但其实从总体来看就做了三件事

  • 第一件事就是计算新桶数组的容量Cap以及新的阀值threshold.扩容就是扩容到原来的2倍

  • 第二件事就是根据新的数组容量创建新的数组

  • 第三件事就是重点了,将老数组的数据转移到新数组

重点说下这个转移数组数据的操作,转移的操作也是分三种情况:

如果当前桶位就一个数据,那就直接计算下在新数组的下标位置,然后插入即可:newTab[e.hash & (newCap - 1)] = e;

如果不是一个元素,那就要判断这个桶位的数据是链表还是红黑树,如果是红黑树,就通过红黑树的方式进行数据转移,红黑树的方式这里先不进行解析,后期会单独讲解红黑树。如果是链表,转移的操作就很神奇,也狠巧妙,让我们来看一看其操作方式:

先说下结论,链表中的结点要么是保持数组下标位置不变进行转移,要么就重新计算数组下标位置,这个计算方式也很简单,就是结点在原来数组的下标位置+原来数组大小,例如

你再数组下标位置是15,数组长度是16,那么在新数组的位置就是16,那么如何来区分哪些结点使用这两种的哪种方式进行转移呢?

在源码中,可以看到,它创建了两个Node,一个是低位链表,一个是高位链表

怎么计算结点属于高位链表,还是低位链表,通过

e.hash & oldCap) ==0,如果结点的hash值与容量与操作后结果为0,就是低位链表,不是0就是高位链表。在同一桶中不代表其hash值是一样的,所以可以根据这点进行区分;

我们都知道,容量是2的次方,因此它的二进制就是1后面全是0,那么在与hash值进行与操作,其实就是看第一位是否为0,如果是0在与操作后结果就是0,这样就将结点分到低位链表,不是就分到高位链表,这里在往链表插入时使用的是尾插法,在1.7的时候扩容使用的是头插法,分好类之后,那么低位链表就按照数组下标位置不变插入到新数组,高位链表数组下标位置就是

oldCap+oldindex,这样就完成了数据转移的操作,扩容就圆满完成了。

06

get方法

      public V get(Object key) {
        Node<K,V> e;
        return (e = getNode(hash(key), key)) == null ? null : e.value;
    }

 final Node<K,V> getNode(int hash, Object key) {
        //当前hashMap散列表
        Node<K,V>[] tab;
        //first 桶位中的头元素
        //e :临时元素
        Node<K,V> first, e;
        //数组长度
        int n;
        K k;
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (first = tab[(n - 1) & hash]) != null) {
              //第一种情况:定位出来的桶位元素即为咱们要get的数据
            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 {
                    //进行do while循环,一一遍历找到要查找的元素
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        return e;
                } while ((e = e.next) != null);
            }
        }
        return null;
    }

get方法就是看getNode方法

  • 第一种情况就是定位出来的桶元素的头结点就是要的数据,那就直接获取返回

  • 如果桶位不止一个元素,可能就是链表,或者是红黑树

  • 如果是红黑树就按照红黑树的获取方式进行获取

  • 如果是链表,就遍历结点,找到要的元素

06

remove方法

    public V remove(Object key) {
        Node<K,V> e;
        return (e = removeNode(hash(key), key, null, falsetrue)) == null ?
            null : e.value;
    }


  final Node<K,V> removeNode(int hash, Object key, Object value,
                               boolean matchValue, boolean movable) {
          // 引用当前hashMap的散列表
        Node<K,V>[] tab;
         //当前Node的头节点
        Node<K,V> p;
        // n:表示散列表的数组长度
        // index:表示数组下标位置
        int n, index;
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (p = tab[index = (n - 1) & hash]) != null) {
              // node: 要查找的元素
              // e:当前node的下一个元素
            Node<K,V> node = null, e; K k; V v;
            // 第一种情况:当前桶位的元素即为你要删除的元素
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null &&  key.equals(k))))
                node = p;



            else if ((e = p.next) != null) {

                // 第二种情况 p是红黑树
                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不为空,说明按照key查找到需要删除的数据了
            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
                    // 因为Node为查找结果,且为p的下一个元素,
                    // 因此只需要将p的下一个元素指向查找结果node的下一个元素即可
                    p.next = node.next;
                ++modCount;
                --size;
                afterNodeRemoval(node);
                return node;
            }
        }
        return null;
    }

remove方法可以分为两个步骤,第一个步骤就是找到要删除的数据,第二个步骤就是进行删除

  • 通过hash值找到所在桶的位置,然后进行查找,查找也分三个步骤,先看是不是当前桶位头结点元素,如果不是就看这个桶位是链表还是红黑树,然后不同的方式进行查找

  • 查找到后就进行移除操作,具体删除步骤在代码中我已经列的很详细。

06

问题解答

我在文章开头列的几个问题,在文末我进行一一解答,前两个问题在源码分析中已经阐述,来看最后一个问题

为什么链表在jdk1.8之前采用的是头插法,1.8开始采用尾插法

在java8之前,数据插入链表是用的头插法,但是这就可能出现环形链表,死循环的情况

例如在扩容的时候,在扩容前,链表指向是A->B->C

图片

这时候A的下一个指针是指向B的,那么在resize的时候,因为是头插法,新的元素会被放在链表的头部位置

就有可能出现下面这种情况

图片

这就形成了环形链表

图片

从jdk1.8开始,就改为尾插法,这样在扩容的时候会保持元素原本的顺序,就不会出现链表成环的情况。