JDK 8 HashMap源码解析

HashMap作为日常开发中最常使用的一个数据结构,其在面试过程中也是必问的一个知识点,所以我们今天就来一窥HashMap的源码。

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

JDK 8 HashMap源码解析_第1张图片

HashMap继承自AbstractMap类,实现了Map、Serializable和cloneable接口。

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

public class HashMapTest {


    public static void main(String[] args) {
        
        Map 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。下面看一下这个方法是如何实现的。

//
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需要注意的是,这里为什么一定要先进行n=cap-1这个操作。假设此时,传入的是2的n次幂,如果不先减去1的话,此时cap为2n形式,最后进行计算的结果一定是2*2n这种形式,但tableSizeFor方法要求的是返回大于等于cap的最小的2n,结果应该就是2n而不是2 * 2n。举个例子,传入的cap为16,已经是24,如果不先减去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方法用来计算key的hashcode

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

put方法

//存放数据的数组
transient Node[] table;

// 入参 hash:通过 hash 算法计算出来的值。
// 入参 onlyIfAbsent:false 表示即使 key 已经存在了,仍然会用新值覆盖原来的值,默认为 false
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        //数组tab
        Node[] tab; 
    // n 表示数组的长度,i 为数组索引下标,
        int n, i;
    // p 为 i 下标位置的 Node 值
        Node 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 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)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;
    }

总结一下,hashMap的put过程:

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

流程图如下所示:

JDK 8 HashMap源码解析_第2张图片

reSize方法

resize方法一般有两个场景会触发,一个是调用put方法时,若是此时hashMap尚未初始化,则会调用resize方法进行初始化;第二个就是当目前hashmap中元素个数大于阈值threshold时,调用resize方法进行扩容。

final Node[] resize() {
        Node[] 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[] newTab = (Node[])new Node[newCap];
        table = newTab;
        if (oldTab != null) {
            //开始将数据由数组迁移到新数组
            for (int j = 0; j < oldCap; ++j) {
                Node 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)e).split(this, newTab, j, oldCap);
                    else { // preserve order
                        //如果没超过8个 是链表
                        //两条链表 高位和低位 分别用来存储同一个链表上的数据
                        // 后面根据分配分别插入到新数组中不同的位置
                        Node loHead = null, loTail = null;
                        Node hiHead = null, hiTail = null;
                        Node 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的时候才去初始化数组)和扩容两个功能;
  • jdk7中resize时,会重新计算每个元素在新数组中的位置;jdk8中的resize方法,在移动链表时,利用链表上元素的性质, e.hash & oldCap 这个值来判断这个元素是在新数组中保持与旧数组相同的索引 index,还是 index+oldCap;
  • jdk7中,resize方法在移动链表上的元素时,会改变链表元素的相对顺序,如 a—>b 就会变成 b—> a,又因为使用的头插法,所以导致在多线程环境下进行扩容时可能导致链表成环,这样在调用get方法时陷入死循环;jdk8中采用尾插法,同时插入到新链表的时候不会改变链表中元素的相对位置,因此解决了死循环问题;

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

JDK 8 HashMap源码解析_第3张图片

为什么使用 e.hash & oldCap

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

get方法

public V get(Object key) {
        Node e;
        //调用getNode方法
        return (e = getNode(hash(key), key)) == null ? null : e.value;
    }

final Node getNode(int hash, Object key) {
        Node[] tab; Node first, e; int n; K k;
        //判断索引位置处是否有值 否则直接return null
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (first = tab[(n - 1) & hash]) != null) {
            // hash值和key值都相等 则表明命中 直接返回
            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)
                    //是红黑树的话,调用getTreeNode方法查找
                    return ((TreeNode)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方法的流程如下:

  • 根据元素的hash值和hash方法定位到数组中索引所在位置;
  • 判断该索引位置是否存在值:
    • 不存在的话,直接返回null;
    • 比较hash值和key,若是相等,则表明命中,返回该索引位置所在的元素;
    • 若不相等,判断此处是否存在红黑树或者链表结构:
      • 若是红黑树,则调用getTreeNode方法进行查找;
      • 若是链表,则遍历链表进行查找;

jdk新增的getOrDefault方法

jdk8中新增一个getOrDefault方法,该方法在查找时,若是查找不到则返回传入的默认值。源码如下:

 public V getOrDefault(Object key, V defaultValue) {
        Node e;
        //可以看出与 get方法的不同 若是查找不到 默认返回defaultValue 其余与get方法一致
        return (e = getNode(hash(key), key)) == null ? defaultValue : e.value;
    }

remove方法

public boolean remove(Object key, Object value) {
        return removeNode(hash(key), key, value, true, true) != null;
    }

final Node removeNode(int hash, Object key, Object value,
                               boolean matchValue, boolean movable) {
        Node[] tab; Node p; int n, index;
       //先定位
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (p = tab[index = (n - 1) & hash]) != null) {
            Node 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) {
                if (p instanceof TreeNode)
                    node = ((TreeNode)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不为null 说明命中了要删除的节点
            // 如果不需要对比value 或者是需要对比value 但value也相等 则开始进行删除
            if (node != null && (!matchValue || (v = node.value) == value ||
                                 (value != null && value.equals(v)))) {
                //如果是红黑树的话 调用removeTreeNode进行删除
                if (node instanceof TreeNode)
                    ((TreeNode)node).removeTreeNode(this, tab, movable);
                //如果是首节点的话 直接指向下一个节点 一个的话可以看做是只有一个节点的链表 这样也可以置
                //为null
                else if (node == p)
                    tab[index] = node.next;
                else
                    //否则的话,进行链表的移动
                    p.next = node.next;
                //更新modCount
                ++modCount;
                //更新size
                --size;
                afterNodeRemoval(node);
                //返回删除的节点
                return node;
            }
        }
        return null;
    }

remove方法流程梳理如下:

  • 利用元素的hash和hash方法进行定位
  • 若是当前索引位置处为null,直接返回null;
  • 比较hash和key,若相等,则表明找到了待删除的节点
  • 若不是,则判断该位置是红黑树还是链表:
    • 若是红黑树,则调用getTreeNode方法进行查找;
    • 若是链表,则遍历链表进行查找;
  • 若是找到了待删除节点,则开始进行删除:
    • 若该索引位置处为红黑树,则调用removeTreeNode方法进行删除;
    • 若是链表,则执行链表相关的操作进行删除;
  • 最后更新 modCount和 size等属性的值;

常见面试题

为什么数组长度都是2的倍数?

  • 当数组都是2的倍数时,2^n-1的二进制表示中所有位置都是1,这样与一个全部都是1的二进制数进行 & 操作时,速度会大大提升;
  • 计算元素的索引位置时,一般采用的是 % 操作,但是如果数组长度都是2的倍数的话,hash & (length-1) 等价于 hash % length,但是 & 操作的效率更高,因为 % 在操作系统会进行转换, & 操作不用;
  • 数组长度为2的倍数时,不同key计算出相同的index的概率较小,减少hash碰撞;

为了减少hash碰撞,hashMap做了哪些操作?

  • hash方法中,hashCode ^ hashCode >>>16,这样所得的hash值可以将hashCode的高位和低位都利用上,降低不同key通过hash方法获得相同hash值的概率,减少hash冲突;
  • 计算索引位置时,hash & (length-1),由于 length始终是2的倍数,length-1后的二进制表示中各位都是1,一个数与各个位都是1的数进行 & 操作,进一步降低hash冲突;

参考

https://juejin.im/post/6844904048185851911#heading-8

https://juejin.im/post/6847902223884779533#heading-10

https://juejin.im/post/6844904134080987144#heading-33

你可能感兴趣的:(JDK 8 HashMap源码解析)