java HashMap 源码分析(深度讲解)

举报
Cyan_RA9 发表于 2023/04/11 19:33:26 2023/04/11
【摘要】 java 集合篇章——HashMap源码分析(非常详细)。

目录

一、前言

二、HashMap简介

三、HashMap的底层实现

四、HashMap的源码解读(断点调试)

        0.准备工作 : 

        1.向集合中添加第一个元素 : 

                ①跳入无参构造。

                ②跳入put方法。

                ③跳入putVal方法。

                ④跳入resize方法。                

                ⑤回到putVal方法。

                ⑥回到演示类。           

        2.演示put方法对value的替换功能 : 

                ①第二个元素直接加进去。

                ②跳入putVal方法。

                ③value替换完成。

        3.HashMap树化机制的触发 : 

                〇准备工作。

                ①向table数组中添加8个元素。

                ②将table数组扩容至64。

                ③链表树化为红黑树。 

五、完结撒❀


一、前言

        大家好,本篇博文将通过Debug流程分析,带你全面解读HashMap类的底层源码。 之前up出过HashSet的源码分析,而HashSet的底层其实就是HashMap,只不过HashSet相当于只使用了HashMap的K(键),而没有使用V(值)罢了。所以,强烈建议各位大佬们在看这篇博文前,先去看一下up之前出的HashSet的源码分析对于HashMap和HashSet源码分析中相同的部分,up就不会讲那么细了,我们重点要说二者不同的地方。        注意 : ①解读源码需要扎实的基础,比较适合希望深究的同学; 不要眼高手低,看会了不代表你会了,自己能全程Debug下来才算有收获; 点击文章的侧边栏目录或者前面的目录可以进行跳转。 良工不示人以朴,up所有文章都会适时进行补充完善。大家有问题都可以在评论区讨论交流,或者私信up。 感谢阅读!

二、HashMap简介

        HashMap是Map集合体系中使用频率最高的一个实现类。HashMap的方法没有使用synchronized关键字修饰,因此HashMap是线程不同步的

        HashMap属于java.base模块,java.util包下。如下 :

image.png

        我们再来回顾一下HashMap的类图,如下 :

image.png


三、HashMap的底层实现

        1° HashMap底层维护了Node类型的数组table,默认为null。HashMap的底层是"数组 + 链表 + 红黑树"的结构。简单来说,即table数组的元素是一个链表,并且在某些条件下会将链表树化为红黑树。如下图所示 : 

image.png

        当创建HashMap对象时,会将加载因子(loadFactor)初始化为0.75

        当我们向table数组中添加一个key-value键值对时,会根据当前键值对的key得到一个该键值对的hash值(哈希值),然后在底层将它转化为一个索引值。这个索引值决定该元素在table数组中应该存放的位置

        当(table数组中)索引值对应的位置没有元素存在时,直接将当前元素(键值对)加入table数组

            反之,则进行判断,如果当前要添加的键值对的key与该位置处的键值对的key判断为相等,放弃添加该元素,并用新的value值替换掉key对应的旧的value值,返回旧值;如果当前元素的key与已存在元素的key不相等,则继续判断table数组该索引处的元素后面跟着的是一个链表还是一棵红黑树

        若判断table数组该索引处的元素是一个链表,则继续判断该链表中的元素有无与当前元素相同的key,若有——放弃添加该key-value键值对,用新的value值替换掉对应的旧的value值,并返回旧值;若无——直接将当前键值对挂载到当前链表的最后面(实现了数组 + 链表的结构)

            若判断table数组该索引处的元素是一棵红黑树,则用红黑树的方法去添加元素

        第一次向HashMap集合中添加元素时,底层的table数组会扩容到16,临界值(threshold) = 16 * 0.75 = 12;之后每当table数组中的元素超过临界值时,就要对table数组进行扩容,容量和临界值都扩大2倍,以此类推。
        7° 
在JDK17.0版本中,如果table数组中某一条链表的元素个数达到或超过了TREEIFY_THRESHOLD(默认是8),并且table数组的长度达到或超过了MIN_TREEIFY_CAPACITY(默认是64),底层就会对该链表进行树化,将其转化为一棵红黑树;否则仍采用数组扩容机制。(JDK8.0同)


四、HashMap的源码解读(断点调试)

        0.准备工作 : 

                up以HashMap_Demo类作为演示类,代码如下 : (main函数第一行设置断点)

package csdn.knowledge.api_tools.gather.map;
import java.util.HashMap;
import java.util.Map;
/**
 * @author : Cyan_RA9
 * @version : 21.0
 */
public class HashMap_Demo {
    public static void main(String[] args) {
        Map map = new HashMap();
        map.put("CSDN", "NB");
        map.put("Cyan", "Rain");
        map.put("CSDN", "YYDS");
        System.out.println(map);
    }
}

image.gif

                运行结果 : 

image.png

        1.向集合中添加第一个元素 : 

                ①跳入无参构造。

                OK,我们开始Debug!

                先跳入HashMap的无参构造如下图所示

image.png

                可以看到,无参构造的作用是将loadFactor(加载因子)初始为了0.75

                好的,我们跳出构造器。在测试类中我们已经可以看到table数组的请况了,如下 : 

image.png

                可以看到,此时HashMap$Node[] table = null;。

                ②跳入put方法。

                继续,跳入put方法,如下 : 

image.png

                可以看到,IDEA给出了提示信息——key = "CSDN";并且value值不再是我们在HashSet源码分析中见到的那个PRESENT了,而是"NB"

                put方法的内部又调用了putVal方法,并且传入了5个实参——hash方法的返回值;键值对中的key;键值对中的value;false(传递给onlyIfAbsent);true(传递给evict)。

                先来看看第一个实参,我们追到hash方法中看看如下 : 

image.png

                可以看到,hash方法的本质就是根据一个算法来返回键值对中key对应的哈希值。此处key显然不为null,因此会返回三目运算符的"(h = key.hashCode()) ^ (h >>> 16)"部分,这便是计算哈希值的一个算法,就是将int类型变量h 无符号右移16位后又和key的哈希码值进行了异或操作,这里大家不需要深究,了解一下该算法即可。

                第二个实参和第三个实参就不用说了吧。至于后两个布尔类型的实参,我们暂时不管他,等用到了再说,只需要知道一个是false一个是true就行。

                ③跳入putVal方法。

                继续,我们跳入putVal方法。同样地,由于putVal方法本身代码很多,up直接把源码给大家放过来,如下 : 

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)
            tab[i] = newNode(hash, key, value, null);
        else {
            Node<K,V> e; K k;
            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) {
                    if ((e = p.next) == null) {
                        p.next = newNode(hash, key, value, null);
                        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;
                }
            }
            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;
    }

image.gif

                不打紧啊,我们一步一步来看——

                首先定义了几个辅助变量如下

image.png

                tab数组其实就是table数组的一个替代品,用来做个判断、赋个值啥的,最后还是要看真正存储元素的table数组。

                p是一个Node类型的临时变量,其作用和tab一样,就是个替代品,将来可以用它临时保存table数组中某个元素的值,然后拿来做个判断 、赋个值啥的。

                n和i暂时不用管,用到再说。

                接着,有一条独立的if 条件语句,如下 : 

image.png

                它这是问你table数组现在为空吗?那肯定为空啊,啥都没干呢!

                因此,要执行这条独立的if 语句中的内容,即将一个不知道什么东西(扩容后的table数组)的长度赋值给了刚刚定义的n变量。"tab = resize()",显然这是要对table数组进行第一次扩容操作了

                ④跳入resize方法。                

                继续,跳入resize方法,resize方法源码如下 :

final Node<K,V>[] resize() {
        Node<K,V>[] oldTab = table;
        int oldCap = (oldTab == null) ? 0 : oldTab.length;
        int oldThr = threshold;
        int newCap, newThr = 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
        }
        else if (oldThr > 0) // initial capacity was placed in threshold
            newCap = oldThr;
        else {               // zero initial threshold signifies using defaults
            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;
        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;
                            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;
    }

image.gif

                同样地,不打紧啊,👴都出博文了还不给你讲清楚?我们一步一步来看——

                首先,还是那老一套(up在HashSet的源码分析中已经讲过),如下 : 

image.png

                将table( = null )赋值给了oldTab引用,oldTab,见名知意,oldTable;然后定义了oldCap变量,赋值为0,oldCap见名知意,oldCapacity;接着又定义了oldThr变量,并为其赋值为threshold,threshold此时是为0的,如下图所示 : 

image.png

                oldThr,见名知意,oldThreshold的意思。至于threshold变量(临界值),up在HashSet的源码分析一文中已经讲得很详细了,这里不再赘述

                继续,newCap变量和newThr变量与oldCap,oldThr变量相对,不解释过多。

                接着,if条件语句我们不进去,如下 : 

image.png

                else if 语句我们也不进去,如下 : 

image.png

                进入else语句,else 语句中为newCap变量初始化为了16,并且将newThr变量初始化为了16 * 0.75 = 12。如下图所示 : 

image.png

                将newThe变量返回给threshold后,终于要new出新的Node类型的数组,如下 : 

image.png

                执行完这几步后,我们已经可以看到table数组被初始化成功了,如下

image.png

                然后,返回新数组,结束resize方法,如下 : 

image.png

                ⑤回到putVal方法。

                如下 : 

image.png

                if条件语句中,利用获取到的key的哈希值,根据特定算法可以得到一个索引值i,该索引之当前键值对在table数组中应该存放的位置。显然,此处p不为空,所以会直接将当前键值对包装成Node类型,然后加入到table数组该索引处。 后面几个与HashSet源码分析中重复的步骤up就不再啰嗦了。

                ⑥回到演示类。           

                回到演示类,此时我们已经可以看到第一个键值对"CSDN"-"NB"已成功加入到了table数组中,如下 : 

image.png

        2.演示put方法对value的替换功能 : 

                ①第二个元素直接加进去。

                第二个键值对的key = "Cyan",它的哈希值肯定与第一个键值对的key("CSDN")不同。因此,第二个元素的添加过程与第一个元素一样,这里就不演示了,我们直接把它加进去,如下图所示 : 

image.png

                ②跳入putVal方法。

                先跳入put方法,如下 : 

image.png

                可以看到, 此时我们的key = "CSDN",value = "YYDS"。key相同,hash方法返回的哈希值就一定相同。

                继续,跳入putVal方法,如下  : 

image.png

                可以看到,此时的哈希值2077925与table数组中已经存在的键值对"CSDN=NB"的哈希值一样,那么最后转换成的索引值也会相同

                对于putVal方法中的第二个if语句,因为我们已经在table数组的该索引处添加过"CSDN=NB"的键值对,因此第二个if语句的判断显然不成立,要进入else语句中。

                同样地,就和我们在HashSet源码分析中说的一样(具体详解请查看HashSet源码分析一文)最外层的else语句内部,又分为了三种情况——

                第一种情况,如果table数组该索引处元素的key与当前元素的key相同,就放弃添加当前键值对。

                第二种情况,如果该索引处元素的key与当前元素的key不相同,并且该索引处元素后面跟着的是一棵红黑树,就采用红黑树的方法进行元素的添加。

                第三种情况,在不满足前面两种情况的基础上,如果该索引处元素的后面跟着的是一个链表,就要对链表中的元素挨个进行判断,只要链表中的元素出现了与当前元素key相同的情况,就立刻break出去,放弃添加当前元素。

                显然,现在符合第一种情况,于是我们来到了三种情况后面的if条件语句,如下 :

image.png

                e != null显然成立(注意,上面第一种情况中,我们已经将p赋给了e,即e代表table数组中当前存在的元素), 注意看内层if 条件语句中的内容,onlyIfAbsent其实就是我们一开始调用putVal方法时传入的第四个实参false,此处加上!就是true。

                内层if 中,进行了值的替换,将table数组该索引出的键值对的旧值替换为了当前键值对的新值,最后又返回了旧的值

                ③value替换完成。

                执行完这个if语句后,我们已经可以看到value的替换,如下图所示 : 

image.png

                可以看到,此时"NB"已经被替换为了"YYDS"。 

        3.HashMap树化机制的触发 : 

                〇准备工作。

                先简单回顾一下HashMap底层链表转换为红黑树的条件——table数组的某索引处的链表中,元素的个数达到或超过8个table数组本身的长度达到或超过64个

                对于第②个条件其实比较简单,table数组在初始化后,长度 = 16,我们只需要让它再扩容2次,16 ——> 32 ——> 64,就可以了。关键是,对于第一个条件,我们如何才能顺利地把八个元素挂载在table数组的同一索引处?欸,看过up之前HashSet源码分析的带佬们此时就会发出轻蔑的一笑,哼,重写hashCode方法。

                没错!还记不记得我们用put方法添加键值对时,底层如何判断它放在table数组的哪儿? 答案是先根据键值对的key得到它的哈希值,再根据这个哈希值得到对应的索引值。那这个哈希值是怎么得来的,得到哈希值的算法如下 : 

image.png

                注意看,起决定性作用的是key的哈希码值,因此,只要我们能让key的哈希码值相同,就能轻松将多个元素挂载到同一链表下(当然前提得value不一样)。 

                所以,我们可以定义一个类,并重写该类的hashCode方法,使其始终返回相同的值

                up以HashMap_Demo2类为演示类,并在其源文件下定义Fruit类。

                代码如下 : (main函数第一行设置断点)

package csdn.knowledge.api_tools.gather.map;
import java.util.HashMap;
/**
 * @author : Cyan_RA9
 * @version : 21.0
 */
public class HashMap_Demo2 {
    public static void main(String[] args) {
        HashMap hashMap = new HashMap();
        for (int i = 0; i < 12; ++i) {
            hashMap.put(new Fruit(), i);
        }
        for (Object o : hashMap.entrySet()) {
            System.out.println(o);
        }
    }
}
class Fruit {
    @Override
    public int hashCode() {
        return 400;
    }
    @Override
    public String toString() {
        return "Fruit{}";
    }
}

image.gif

                运行结果 : 

image.png

                ①向table数组中添加8个元素。

                进入Debug,利用for循环,先向table数组中添加元素到8个,如下图所示 : 

image.png

                并且,我们可以看到这8个键值对都挂载到了table数组的同一链表下,如下GIF图所示 :

image.png

                OK,链表树化的第一个条件我们算是轻松满足了。接下来我们只需要让table数组扩容2次,到64的长度,再向集合中添加元素就会树化了。

                ②将table数组扩容至64。

                问题是:怎么让table数组乖乖扩容至64呢?

                诶,哪儿来那么多事?人家源码都写得清清楚楚了,如下 : 

image.png

                当我们在某条链表下挂载的元素达到8后,会进入treeifyBin方法进行二次判断,如果table数组当前的长度不够64,就继续调用resize方法进行扩容;直到table数组的长度达到64后才开始树化。(PS : 这些up在HashSet的源码分析中都已经详细讲过,这里给大家再过一下就好)。

                所以,我们只要再向table数组中加入两个元素,就可以让table数组扩容到64了,如下GIF图演示 : 

image.png

                ③链表树化为红黑树。 

                目前集合中共有8 + 2 = 10个元素,如下 :

image.png

                并且我们可以看到,此时table数组中元素的类型是HashMap$Node类型,如下图所示 : 

image.png

                好的,注意看,当我们向集合中添加第11个元素时,就会对元素个数达到或超过8的链表进行树化,如下GIF图所示 : 

image.png

                可以看到,第11个元素添加后,瞬间结点中新增了一些属性,并且table表中元素的类型由HashMap$Node类型转换为了HashMap$TreeNode类型,如下图所示 : 

image.png

                这表示我们这条链表已成功转换成了一棵红黑树。


五、完结撒❀

        🆗,以上就是我们HashMap源码分析的全部内容了。这篇文章配合着之前HashSet源码分析的博文一起食用,up保证你轻松拿捏HashMap的底层源码。源码分析中涉及到了红黑树的知识,属于数据结构与算法的内容,up之后会专门出java语言描述的数据结构与算法的系列专栏,到时候我们再深度研讨。感谢阅读!

        System.out.println("END------------------------------------------------------------);

【版权声明】本文为华为云社区用户原创内容,转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息, 否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。