JDK8中HashMap底层源码解析-put和putVal方法以及数组下标的计算方式


前言

我想只要JAVA程序员,都躲不过HashMap这一关,面试官的HashMap夺命连环问经常把求职者虐的体无完肤。因此,本篇文章以JDK8为例,对HashMap的底层源码做一个解析,帮助大家更好的理解HashMap的底层原理。


一、HashMap的重要属性

我想大家都应该知道在JDK8中HashMap的数据结构是数组+链表+红黑树。但是如果我们拆分的在细点,HashMap的数据结构就可以拆为数组+单向链表+双向链表+红黑树。(这一点以前很少接触到,但在HashMap中确实有用到双向链表,后面会讲解到,这里了解即可)

在学习HashMap底层源码之前,我们先来看一下HashMap内部有什么属性,现在我将这些常用的属性罗列出来,大家也可以自行去源码中查看加深印象。

1.存放元素的数组,JDK8以Node类型(一个由K和V组成的键值对)存放元素

transient Node<K,V>[] table;  

1.默认初始容量(16)

static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

2.默认负载因子

static final float DEFAULT_LOAD_FACTOR = 0.75f;

3.TREEIFY_阈值,即链表长度超过8转红黑树

static final int TREEIFY_THRESHOLD = 8;

4.Untreify_阈值,链表长度小于等于6,则会由树重新退化为链表

static final int UNTREEIFY_THRESHOLD = 6;

5.最小树容量,在转变成树之前,还会有一次判断,只有HashMap的元素数量大于 64 才会发生转换。

 static final int MIN_TREEIFY_CAPACITY = 64;

6.最大容量,HashMap所能存储的最大元素个数(2的30次方)

 static final int MAXIMUM_CAPACITY = 1 << 30;

7.JDK8以Node类型作为元素的存储单位,从源码中可以看出,Node保存了key和value的值,以及用key算出来的hash值,同时还有一个next指针,这是链表的实现(单向链表)
在这里插入图片描述
8.修改次数,HashMap每进行一次修改,例如put和remove,这个值就会加一(get不会,因为读并没有发生修改)

transient int modCount;

二、put和putVal方法

我们先来回顾一下HashMap的基本用法:

public class MyTest {
    
    
    public static void main(String[] args) {
    
    
        HashMap<String, String> hashMap = new HashMap();
        hashMap.put("jzh", "good");
    }
}

1.首先我们要创建一个HashMap对象,在源码中我们可以看出,如果我们选择无参的构造方法创建HashMap,那么它刚开始并没有初始化数组(即table属性还未null),它会把loadFactor(负载因子)设置为默认的负载因子值(0.75)。

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

2.当我们调用put方法往里面添加一个元素时,在put方法中会调用putVal方法,同时会根据key来计算hashcode,相比于JDK7,JDK8的hash算法有所简化。

public V put(K key, V value) {
    
    
    // 根据key计算hashcode,相对于JDK7中hash算法有所简化
    return putVal(hash(key), key, value, false, true);
}

在这里,我们先来看一下hash(key)的源码:首先判断key是否为空,如果为空直接返回0,如果不为空会先调用Object类中hashcode()方法得到哈希值,并且对这个值做一些位运算和异或运算(这一步可以称为扰动)

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

注意:看到这可能有些人就开始疑惑了,为什么不直接使用key的hashcode()值,而是做一系列扰动操作?别着急,后面会介绍原因,这里先理解通过hash()这个方法返回key的哈希值。

有了key的hash值,我们就可以进入核心方法putVal(),由于JDK8中的putVal()方法源码可读性不高,在这里我们可以先思考一下这个方法要做什么:

  1. 由于我们的数组还没创建出来,所以第一步要初始化我们的数组table。
  2. 需要把我们传进来的key,value,hash封装到一个Node中去。
  3. 需要计算一个数组下标,代表Node要存放的位置。

先知道我们要做什么,在去看源码,就不会那么难懂了,接下来看putVal()的底层源码,注意第一次看可能会有点懵,但是多看几遍一定会有收获。

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
    
    
        //不直接使用属性,而是声明一堆变量,后面会把属性赋值给变量,如下面的tab = table
        //因为变量在栈中存储,属性在堆中,而操作栈的速度更块,这里不用太过纠结
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        
        //给tab赋值,并判断数组是否为null,如果是则初始化数组,并得到数组⼤⼩n
        if ((tab = table) == null || (n = tab.length) == 0)
           //调用resize()方法初始化数组(默认16),resize()方法有两个作用 1.初始化 2.扩容 
           //这也是JDK8代码可读性不高原因,resize()方法将初始化和扩容揉在一起了
            n = (tab = resize()).length;
            
        // 根据hash值计算出对应的数组下标i,并判断该位置是否存在元素:
		// 如果为null,则⽣成⼀个Node对象赋值到该数组位置
		// 否则,将该位置对应的元素取出来赋值给p
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        else {
    
    
            // 如果该下标位置存在元素,则进⾏⼀系列判断
            Node<K,V> e; K k;
            /*⾸先判断该下标位置存在的元素的key是否和当前put进来的key是否相等,
            如果相等,则在后续代码中更新value,并返回oldValue。如果不相等继续往下判断。*/
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
            /*如果该下标位置存在的元素的类型是TreeNode,表示该位置存的是⼀颗红⿊树,那么就会把新元素添加到红⿊树中,
            并且也会判断新key是否已经存在红⿊树中,如果存在则返回该TreeNode,并在后续代码中更新value。如果不是TreeNode,就继续往下走
			*/ 
            else if (p instanceof TreeNode)
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            /* 否则该位置存的是⼀个链表,那就要把新元素插⼊到链表中, 因为要看当前链表的⻓度,所以就需要遍历链表
			 在遍历链表的过程中,⼀边记录链表上的元素个数,⼀边判断是否存在相同的key
			 */ 
            else {
    
    
                for (int binCount = 0; ; ++binCount) {
    
    
                //遍历到尾节点后,将新元素封装为Node对象并插⼊到链表的尾部
                    if ((e = p.next) == null) {
    
    
                        p.next = newNode(hash, key, value, null);
                        //如果链表上的元素个数大于等于8个了(不包括新元素对应的节点),则将链表改造为红⿊树
                        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;
                }
            }
            //如果key存在相同的,则更新value,并返回oldValue
            if (e != null) {
    
     // existing mapping for key
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                //直接返回oldValue,不再执行下面的代码
                return oldValue;
            }
        }
        //如果不存在相同key,则需要增加修改次数
        ++modCount;
        //新元素插⼊之后,判断是否需要扩容
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }

之前就打了个预防针,JDK8的putVal()方法可读性不高,第一次看的小伙伴可能会内心崩溃,接下来带大家梳理一下整个过程。

在这里插入图片描述
等这一系列判断操作完成之后,如果不存在相同key,则需要增加修改次数,并判断新元素插⼊之后,判断是否需要扩容。从源码也可以看出如果我们put进来的key之前已经存在,则会更新value并返回之前的旧值(oldValue),同时不增加修改次数,不进行扩容判断。

如果当前要插入的位置是一个链表,且链表上的元素个数大于等于8个了(不包括新元素对应的节点),则将链表改造为红⿊树,则需要在treeifyBin方法里面判断是否要转换为红黑树(以后会解析treeifyBin这个方法)。我们都知道JDK7中链表的插入方式是头插法(可能会出现环形链表),而JDK8换成了尾插法,在尾插法就需要遍历找到最后一个元素的位置,然后在它后面插入,在遍历的过程中顺便用binCount++记录链表上的元素个数,用来判断是否要转红黑树,这样的设计真是一举两得!


三、数组下标的计算方式

在整个过程中我们可以看到一个非常有意思的地方,算数组下标时采用了一种这样的方式: tab[i = (n - 1) & hash]。其中n为数组的长度,即用数组的长度-1与之前算出来的hash值进行与运算,得到数组的下标。

假设我们的HashMap的容量n为16,则数组下标的范围为0-15,我们利用%运算就能得到范围为0-15的下标

hash % 16     ------->   0-15

这样做完全没有问题,可是为什么HashMap使用&运算也能得到同样的结果?以数组长度为16为例:i = (n - 1) & hash

15:   0000  1111(注意前面还有很多的高位,全部是0,只是没写出来)
&
hash:  0101  0101(前面也有很多的高位,可能是0可能是1)
--------------------------
i:     0000  0101    ->5  

即我们要求计算下标的时候下标要符合0-15这个区间,且算出来的下标要尽可能的分散,如果我们拿16算会怎么样?

16:   0001  0000
&
hash:  0101  0101
--------------------------
i:     0001  0000    ->  16(数组越界!!)

我们都知道,在HashMap中,数组的长度会被控制,一定要是2的幂次方数,因为2的幂次方数只有一位是为1,其余全为0。减一之后就能把高位全部变成0,低位全部为1,这样的结果与我们的hash值进行与运算才能得到我们想要的结果

注意:如果我们在HashMap的构造函数中传入数组大小值,例如10,那么HashMap会调用tableSizeFor方法创建一个大于等于10的最小2幂次方数,对于10而言就是16,对于31就是32,对于32就是32。

hash值是int类型的,一共有32位,如果按照上面那种运算,hash的高位根本没有影响到最后的结果,这样子就会使哈希冲突的概率增加,因为最后结果只看了hash的低位。

但是事实如此吗?别忘了我们的hash值可不是简单的hashcode()方法算出来的值,而是经过了一系列的位运算和异或运算(扰动),这样就能让也高位也影响到下标计算结果(即参与结果的hash值那几位是高位和低位共同作用的结果),这样可以使下标更加分散,减少哈希冲突!

(h = key.hashCode()) ^ (h >>> 16)       JDK8中的hash算法

总结

刚开始阅读HashMap底层源码时可以会感到困惑,但只要理清楚逻辑多看几遍,就会对HashMap有一个更清晰的认识。在以后的文章中还将解析其他的HashMap方法源码,供大家参考学习。


猜你喜欢

转载自blog.csdn.net/qq_52173163/article/details/125981161
今日推荐