HashMap树化的条件和扩容机制令人疑惑,语焉不详,正好好久没写了,补上一篇,涉及原理都建议debug

从我学习Java的时候,学到HashMap,我记过的答案是HashMap在Jdk1.8的时候底层实现改为数组+链表+红黑树,当链表长度为8时转为红黑树,红黑树长度减少到6时又转为链表,这样的内容在csdn中能找到很多,但感觉他们都没有说清楚具体细节。

原因是我最近看到有些文章说数组容量达到64才会触发树化,这就和我固有的认识(错误的)产生了冲突,然后我就开始未知探索之路

以下是我的结论(查看大佬的解析+b站视频双管齐下)

HashMap链表转红黑树的条件:容量大于等于64且链表长度为8才会进行树化,否则只会进行扩容。
HashMap数组的扩容机制:键值对个数(size)超过(如果容量是16(默认值),负载因子是0.75(默认值)的话,阈值就是12,要第13个才会扩容)阈值(依次类推,容量32时阈值为24)触发扩容,还有就是当一条链表长度达到8且数组容量小于64也会进行扩容

1.HashMap的put()方法,put()方法里是调用putVal()方法

		HashMap<Integer, String> map = new HashMap<>();
        //第一次HashMap在第一次调用put()时才会初始化
        map.put(1, "小明");

        /*
        //HashMap的属性,table数组,每个索引位置称为桶
        transient Node<K,V>[] table;
    ----------------------------------以下是put()方法的代码实现(为了便于查看我把所有注释加了个制表符)
        //hash:新键值对的hash值,key是键(就是加入的1),value是值(就是小明)
        final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        	//tab数组:临时的HashMap底层存储数组,
        	//p节点:就是用来存放头节点的
        	//n是tab.length就是桶的数量
        	//i是索引位置(也就是桶,桶:我的理解就是还数组索引位置及hash冲突之后的链表)
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        	//对hashMap进行判空(HashMap底层存储数组,是HashMap类的属性)
        	//!!##我有个大大的疑问,第二次put()时,tab不为空不会进入这个if,那n(数组长度)不就是未初始化的状态吗
        	//!!##这个疑惑已解决(我放在最下面的代码段)
        ------第1层先对table数组进行判空(分割线,便于阅读)------------------------------------------------
        if ((tab = table) == null || (n = tab.length) == 0)
            	//如果为空则调用resize(),初始化
            n = (tab = resize()).length;

        	//i = (n - 1) & hash  "n-1"是数组长度-1,然后和hash(hash值)相与,得到传入节点的桶位置
        	//x mod 2^n = x & (2^n - 1) 这是 HashMap 在速度上的优化,因为 & 比 % 具有更高的效率。
        	//i就是table数组(hashMap底层的那个数组)的索引,桶的位置
        	//对桶位置的判空(是否有头节点)
        ------第2层根据节点hash值确实存储位置(桶,并判断桶是否为空)(分割线,便于阅读)---------------------------
        if ((p = tab[i = (n - 1) & hash]) == null)
            	//无头节点则直接把新节点存在桶位置
            tab[i] = newNode(hash, key, value, null);
        ------第2层 else 桶不为空----------------------------------------------------------------------------
        else {//有头节点
            	//泛型类型的变量k是头节点的key
            Node<K,V> e; K k;
            	//再解释一个p是头节点
            	//当新加入的节点的hash值和key对象的地址相同(即同一对象) 
            	//或者 两个key的值相等时(地址不同比较对象,这就是作为Key的类要重写hashcode方法和equals方法)
            	//其实就是做替换value的操作(在下一个的if中操作的)
            ------第3层是否要覆盖头节点,否即进入第5步------------------------------------------------------
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;//相等就把头节点的地址(是地址)赋值给临时节点变量e,并进入下一个if #if (e != null) {}#
            ------第3层判断头节点是否为红黑树再新增节点-----------------------------------------------------
            else if (p instanceof TreeNode)//是否是红黑树的实例
               		 //把Node<K,V>类型的p强转为TreeNode<K,V>(红黑树类型),然后调用红黑树的putTreeVal()方法
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            ------第3层新增链表节点---------------------------------------------------------------------
            else {//只是单纯的链表
                for (int binCount = 0; ; ++binCount) {//死循环,写源码的人特别喜欢用for(;;)来写死循环
                		//注意,在这个循环里p,e表示的意思和上面不一样了(因为在遍历链表),
                		//p:第一次循环开始是指头节点,之后指的是这条链表上的任一节点,e:用来存储p.next(任一节点的下一个节点)
                		
                    	//##能理解要遍历链表但不明白每次循环调用next节点是如何操作的(已解决)
                    	//!!这个if中e存储的是p的next的地址(类似于递归,一直调next节点),每次都先存储当前节点的下一个节点
                    ------第3.1层直接在链表尾部新增节点--------------------------------------------------------
                    if ((e = p.next) == null) {
                    		//当前节点的next为空,说明当前节点就是最后一个节点
                        	//加到最后一个节点后面
                        p.next = newNode(hash, key, value, null);
                        	//加入节点后要判断链表长度是否为8,如果是则调用树化方法(但不一定会树化)
                        	//binCount从0开始自增
                        	//当binCount >=7时
                        	//      (我对链表长度的理解:链表是不包括头节点的,当所有节点都在一个桶里时
                        	//       (可以让HashMap的key返回相同hashcode来设计程序)第9个节点才会进行扩容,
                        	//        比较合理的解释就是链表不包括在数组上的那个头节点)
                        	//     我现在都感觉迷糊了,理论上第8个就执行treeifyBin()方法,但debug第9个才执行,看第5点详谈
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                            	//树化!!!(但不是马上树化,)
                            treeifyBin(tab, hash);//#####下个代码段我把treeifyBin()方法中有利于本文理解的代码贴出来
                            	//会进行这么一个判断,MIN_TREEIFY_CAPACITY:最小树化容量为64
                            	//if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
                            	//resize();//也就是说table表的长度不到64是不会把链表转化为红黑树的,而是进行扩容操作
                            	//真正的链表转红黑树是table.length=64,
                            	//且且且(手没抽筋,重要的事说三遍)当某个桶内的节点大于等于8时才树化
                        break;
                    }
                    	//当新加入的节点的hash值和key对象的地址相同(即同一对象) 
                    	//或者 两个key的值相等时(地址不同比较对象,这就是作为Key的类要重写hashcode方法和equals方法)
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    	//(注意,这是Node节点)直接替换地址(地址变了),而不是把旧值替换
                    	//真是秒啊,之前e = p;现在p = e;不知道小伙伴们看懂了没,源码果然牛逼
                    p = e;//把e(for循环里的第一个if中把p的next的地址赋值给了e)的地址赋值给p,对每一个节点查询其next节点,
                    	  //这样就实现了对链表的遍历
                }
            }
            	//前面提到的那个if,头节点的替换value操作(此e的地址就是桶地址)
            	//此时修改e就是修改桶的头节点,地址不变
            if (e != null) { // existing mapping for key
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;//替换旧value
                afterNodeAccess(e);
                return oldValue;//返回旧值
            }
        }
        	//修改次数
        ++modCount;//只要调用put()方法就会自增一次
        	//threshold是阈值等于容量(table数组长度)*负载因子(默认是0.75)
        	//当容量为16时,阈值为12,当存入第13个节点时触发扩容
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }*/

2.HashMap的树化代码

final void treeifyBin(Node<K,V>[] tab, int hash) {
        int n, index; Node<K,V> e;
        	//看这,某条链表长度为8时进入这个方法,
        	//tab肯定不为null,当tab长度小于MIN_TREEIFY_CAPACITY时
        	
        	//#我对MIN_TREEIFY_CAPACITY的理解(最小树化容量值为64,capacity是容量的意思(在HashMap中			 	     
        	//同样用到这个单词的只有table数组的容量,默认为16),
        	//我看有些博客说什么链表长度为8时和64个节点就树化应该是错误的理解)#
        if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
        		//所以只有数组容量大于等于64时,链表长度到8时才会树化,把链表转红黑树,其他情况都是扩容
            resize();
            	//下面的代码以后再看了😂
        else if ((e = tab[index = (n - 1) & hash]) != null) {
            TreeNode<K,V> hd = null, tl = null;
            do {
                TreeNode<K,V> p = replacementTreeNode(e, null);
                if (tl == null)
                    hd = p;
                else {
                    p.prev = tl;
                    tl.next = p;
                }
                tl = p;
            } while ((e = e.next) != null);
            if ((tab[index] = hd) != null)
                hd.treeify(tab);
        }
    }

3.HaspMap的扩容机制

在IDEA中使用debug调试可以很清晰看到HashMap的扩容机制(有些人可能只能看到普通数组而看不到HashMap中的数组,我之前也是,那是因为IEDA中对集合的数据视图进行了设置)
在这里插入图片描述

写一个简单的例子,然后debug
  HashMap<String, Integer> map = new HashMap<>();

        for (int i = 0; i < 13; i++) {
            map.put("小明"+i, i);
        }
debug截图

在这里插入图片描述

当键值对个数也就是map.size()==13时触发扩容

在这里插入图片描述

4.第二次put()时n的值

如果对HashMap第二次put()方法没疑惑别浪费时间,路过
在这里插入图片描述
模拟第二次put方法时,n在何时赋值?

public static void main(String[] args) {
        int[] table = {1};
        int[] tab;
        int n;
        //模拟HashMap第二次put()
        //首先,进入if的条件判断,tab显然不为空为false,然后在n = tab.length赋值时,n的值已经为1了,不管能不能进{}代码块,n已被赋值
        // 所以第二次put()不进入这个if,n也已经等于数组长度了
        if ((tab = table) == null || (n = tab.length) == 0) {
            //哪怕第二次不执行这条语句,这条语句也要存在,不然编译不通过
            //java: 可能尚未初始化变量n
            n = 0;
        }
        if (n == 0) {}
        System.out.println("已被赋值n = " + n);//已被赋值n = 1

    }

5.链表长度为8中的链表长度应该是不含桶里的头节点的(待议!!!还得仔细推敲)

我以前是认为链表长度是桶中的节点数,但现在我对此产生了怀疑?原因是桶中节点数达到9个时才进行了扩容(数组容量小于64)
当所有节点在一条链表上,第九个节点执行扩容到32,第10个到64,第11变红黑树,所以唯一合理的解释就是桶里的头节点不算在链表, (这样当链表(不含桶里的头节点)长度为8时调用树化方法进行树化或者扩容)

public class HashMapTest {
    public static void main(String[] args) {
        HashMap<Study, Integer> map = new HashMap<>();
        for (int i = 1; i < 14; i++) {
            //当所有节点在一条链表上时,第九个节点执行扩容到32,第10个到64,第11变红黑树
            //static final int TREEIFY_THRESHOLD = 8;//源码
            //所以我的解释就是桶里的头节点应该不算在链表
            // (这样当链表(不含桶里的头节点)长度为8时调用树化方法进行树化或者扩容)
            map.put(new Study(i), i);
        }
    }
}
class Study{
    int i;

    public Study(int i) {
        this.i = i;
    }
/*    @Override//不要重写equals方法
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Study)) return false;
        Study study = (Study) o;
        return i == study.i;
    }*/

    @Override
    public int hashCode() {
        return 200;
    }
}

6.最后

小记:

HashMap转红黑树:容量大于等于64且链表长度为8才会进行树化,否则只会进行扩容
HashMap的扩容机制:键值对个数(size)超过(如果容量是16,负载因子是0.75的话,阈值就是12,要第13个才会扩容)阈值触发扩容,还有就是当一条链表长度达到8且数组容量小于64也会进行扩容

最近感悟,HashMap在网上的解析非常多但是参差不齐,好多自己都没debug过,所以遇到不懂的还是得信源码和debug,一定要结合源码,一定要有自己的思考

参考这两个大佬的,看了韩顺平的b站视频才终于懂了debug,开心!!!

参考了[程序员囧辉]史上最详细的 JDK 1.8 HashMap 源码解析

【韩顺平讲Java】Java集合专题

  • 14
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
HashMapJava中常用的数据结构之一,它实现了一个键值对的映射表。底层原理是基于数组和链表(或红黑树)的组合实现的。 在HashMap内部,有一个Node数组,每个数组元素称为一个桶(bucket)。当我们put一个键值对时,HashMap会根据键的哈希值计算出对应的桶的索引,然后将键值对放入该桶中。多个键值对可能会被放入同一个桶中,这就形成了链表。 但是,当链表的长度超过一定阈值(默认为8),链表会转换成红黑树。这是为了提高查找效率,当键值对数量较多时,使用红黑树可以减少查找时间复杂度。 在HashMap扩容机制方面,当HashMap中元素数量超过负载因子(默认为0.75)与容量(数组长度)的乘积时,会触发扩容操作。扩容时,HashMap会将原有的数组扩大一倍,并重新计算每个键值对在新数组中的位置。这个过程涉及到重新计算哈希值、重新分配桶和重新放置键值对。 在扩容过程中,由于涉及大量的元素重新计算和移动操作,会比较耗费时间和内存空间。因此,在设计HashMap时,我们要尽量选择合适的初始容量和负载因子,以减少扩容的频率和代价。 总结起来,HashMap的底层原理是基于数组和链表(或红黑树)的组合实现的,当元素数量超过一定阈值时,会触发扩容操作。这种设计使得HashMap能够提供高效的查找、插入和删除操作。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值