HashMap源码史上最细解读(一)

目录

一、hashmap里的常量

二、hashmap的成员变量 


提到源码,可能很多人都觉得很难,并且会有些恐惧。但是,当你静下心来去学的时候,就会觉得源码其实也并不是就是不能去学习的。其实不管在牛逼的东西也好,它最终都是离不开人的思维的,因为它在牛逼都是人想出来的,它都要遵循人的思维模式,都是遵循一个所谓的“道”,而我们也是人,又怎么会不能去理解并吸收它呢?!而在本系列hashmap源码(基于jdk1.8)的解读,我就会通过最细致最通俗的语言,让大家都能够掌握hashmap源码,并且通过这个也让大家不再恐惧源码,达到帮助大家开启源码学习之路的作用,最终做一个知其然知其所以然的Java开发工程师!

那么,废话不多说。我们首先,来看看haspmap里面定义的一些常量、成员变量。

一、hashmap里的常量

//hashMap默认的容量16(1左移四位,即为2的4次方,也就是16)
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

//默认加载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//树化的阈值,同一个槽位上存储元素的个数的阈值
static final int TREEIFY_THRESHOLD = 8;
//反树化的阈值 
static final int UNTREEIFY_THRESHOLD = 6;
//这个是单链表树化成红黑树,数组的最小容量,只有数组达到这个最小容量,才有可能单链表转化成红黑树
static final int MIN_TREEIFY_CAPACITY = 64;

从hashmap定义的常量可以得知,hashmap再进行树化的时候的条件不仅是桶上单链表的长度大于等于8,而且还要满足数组的长度达大于等于64才会进行树化操作,这两个条件必须得同时满足,缺一不可。

那么,为什么要要求数组得长度大于等于64才允许树化操作呢?

这是因为当哈希桶中的链表长度超过阈值(默认为8)时,对于较小的数组长度,使用红黑树进行查找并不一定能够提供比链表更优的性能。这是因为在较小的数组中,链表的遍历开销相对较小,而构建和维护红黑树所需的额外内存和计算开销可能会超过通过链表进行查找的开销。这就是一个经验性的选择,旨在避免在较小的数组中过早地进行树化操作,从而减少额外的内存和计算开销,同时确保在较大的数组中使用红黑树可以提供比链表更好的性能。

二、hashmap的成员变量 
//存储元素的数组
//jdk8之前数组类型是Entry<K,V>类型,而jdk1.8之后改成Node<K,V>类型。其实本质还是一样的,都实现了一样的接口:Map.Entry<K,V>,都是用来存储键值对数据的。
//该数组使用了懒加载的设计模式,只在首次对hashmap进行put操作的时候,才会进行初始化
//该数组的长度始终是2的幂次方
transient Node<K,V>[] table;  
//保存缓存的entrySet()
transient Set<Map.Entry<K, V>> entrySet; 
transient int size;  //当前哈希表存在的节点个数
/*
modCount表示散列表结构修改的次数,替换操作不计在其中.
该变量的作用是用于支持快速失败机制(fail-fast),保证在多线程环境下,
避免使用过期或无效的迭代器。如果没有快速失败机制,可能会导致并发修改引起的不确定行为或数据一致性问题。
* */
transient int modCount; 
int threshold;  //当前哈希表的阈值
final float loadFactor; //当前哈希表的加载因子 

看到hashmap的成员变量,相信有些人就会有这样的疑问,为什么在hashmap的成员变量里面有些变量使用了transient进行修饰,有些则没有呢?

首先,我们要知道transient关键字的作用是当一个类实现Serializable接口并被序列化时,对于被transient修饰的属性,将不会被序列化。知道这个我们就不难明白,对于hashmap中被transient修饰的这几个变量,也就是设计hashmap的人,不想让几个属性被序列化。那么为什么这几个属性不需要被序列化呢?接下来,我们就逐一分析,为什么这些成员变量不需要被序列化?

为什么不序列化成员变量table数组? 

讲道理,这个应该需要被序列化啊,如果不序列化,那在反序列化的时候,里面存储的节点不就丢失了吗?这明显不符合,我们对序列化和反序列化的基本要求,即反序列化之后的对象与序列化之前的对象要是一致的。而此时,hashmap的存储的元素都未被序列化,那肯定是不满足这个要求的。但是,事情真的这么简单吗?显然不是!

虽然,hashmap在这里未直接序列化table数组,但是,在它的方法writeObject方法里的internalWriteEntries方法里面是把table数组里面的节点都序列化了,源码如下(这里也就是没有使用默认的序列化机制,而是通过实现readObject/writeObject两个方法自定义了序列化的内容):

    /*
    序列化hashmap中的所有节点
    * */
    void internalWriteEntries(java.io.ObjectOutputStream s) throws IOException {
        Node<K, V>[] tab;
        //判断如果table不为空,则去遍历table进行序列化
        if (size > 0 && (tab = table) != null) {
            //遍历table数组
            for (int i = 0; i < tab.length; ++i) {
                //遍历数组槽位上的单链表
                for (Node<K, V> e = tab[i]; e != null; e = e.next) {
                    //序列化当前节点
                    //此处的writeObject方法是ObjectOutputStream的,而非HashMap的
                    s.writeObject(e.key);
                    s.writeObject(e.value);
                }
            }
        }
    }

而为什么不直接序列化成员变量table数组呢?

这样做主要是出于两点考虑:

1、大家都知道,hashmap为了减少哈希冲突,是不会把整个哈希表塞满了,再去扩容的。如果,我们去直接序列化table数组的话,势必会把未使用的部分也序列化了,这样便会造成空间的浪费。

2、大家应该都知道,存储在hashmap中的节点具体在哈希表的哪个槽位上面是受hashcode方法影响的。而hashcode方法是native修饰的方法,而不同的JVM对native修饰的方法,可能会有不同的实现。这样,相同的key,在不同的JVM上,得到的最终hash值,可能就是不一样的。这样如果别的JVMh上对反序列化出来的table进行添加、删除操作可能就会发生错误(当两个JVM对hashcode的实现不同的时候,就会出现错误)。

为什么不序列化成员变量entrySet呢?

这个原因,想必大家应该都容易想出来。这是因为entrySet是通过计算得出的,而不是实际存储数据的一部分。所以,entrySet并没有序列化的必要。

为什么不序列化成员变量size呢?

这主要也是出于提高序列化的性能考虑的。因为size,也就是记录的hashmap中存储的节点的个数。而我们知道,在readObject方法中已经把hashmap的所有节点都序列化了。那么在反序列化的时候,我们将读取到的键值对放入新的table数组的时候便会重新put,而put的时候,它每put一个key不重复的元素的时候,size都会加一。这样,在反序列化完所有节点的时候,成员变量size的值便出来了。可以看readObject的源码如下:

    /**
     * Reconstitute the {@code HashMap} instance from a stream (i.e.,
     * deserialize it).
     * hashmap自己实现的反序列化方法
     */
    private void readObject(java.io.ObjectInputStream s)
            throws IOException, ClassNotFoundException {
        // Read in the threshold (ignored), loadfactor, and any hidden stuff
        //执行默认的反序列化操作
        s.defaultReadObject();
        reinitialize(); //初始化hashmap参数
        //加载因子为负数或者不是一个数字,则抛出异常
        if (loadFactor <= 0 || Float.isNaN(loadFactor))
            throw new InvalidObjectException("Illegal load factor: " +
                    loadFactor);
        //读取并忽略桶的数量
        s.readInt();                // Read and ignore number of buckets
        //读取桶的数量,将桶的数量赋值给mappings
        int mappings = s.readInt(); // Read number of mappings (size)
        //如果桶的数量小于0的话,则抛出异常
        if (mappings < 0)
            throw new InvalidObjectException("Illegal mappings count: " +
                    mappings);
        //桶的数量大于0,则进行处理
        else if (mappings > 0) { // (if zero, use defaults)
            // Size the table using given load factor only if within
            // range of 0.25...4.0
            //计算加载因子,范围在0.25-4.0之间
            float lf = Math.min(Math.max(0.25f, loadFactor), 4.0f);
            //根据加载因子计算出当前负载因子的倍数
            float fc = (float) mappings / lf + 1.0f;
            //计算出当前数组的容量
            int cap = ((fc < DEFAULT_INITIAL_CAPACITY) ?
                    DEFAULT_INITIAL_CAPACITY :
                    (fc >= MAXIMUM_CAPACITY) ?
                            MAXIMUM_CAPACITY :
                            tableSizeFor((int) fc));
            float ft = (float) cap * lf;
            //计算出当前数组的阈值
            threshold = ((cap < MAXIMUM_CAPACITY && ft < MAXIMUM_CAPACITY) ?
                    (int) ft : Integer.MAX_VALUE);
            @SuppressWarnings({"rawtypes", "unchecked"})
            Node<K, V>[] tab = (Node<K, V>[]) new Node[cap]; //定义新的table数组
            table = tab;

            // Read the keys and values, and put the mappings in the HashMap
            //遍历读取所有节点,放入到新的table数组中
            for (int i = 0; i < mappings; i++) {
                @SuppressWarnings("unchecked")
                K key = (K) s.readObject();
                @SuppressWarnings("unchecked")
                V value = (V) s.readObject();
                //把读取到的键值对,放入table数组中
                //在这里会把size的值重新计算出来
                putVal(hash(key), key, value, false, false);
            }
        }
        //桶的数量为0,不进行处理
    }

为什么不序列化成员变量modCount呢?

这是因为modCount只是用于辅助迭代器的快速失败机制,并不参与HashMap的状态恢复和数据一致性,所以在序列化时并没有必要将其也序列化。

总结:为什么hashMap中有些成员变量不需要序列化?

  • 不影响反序列化的时候对象的恢复:某些成员变量,例如:entrySet、modCount,只是为了辅助HashMap的内部实现或运行时使用,并不影响HashMap的状态恢复。这些成员变量与HashMap的结构、数据的存储和访问等没有直接关系。

  • 可通过其他方式恢复:有些成员变量的值,例如:size,可以通过其他方式重新计算或初始化。在反序列化过程中,可以根据恢复HashMap的键值对的数,来重新计算出成员变量size的值。

  • JVM的差异性导致对hashcode的实现不同:有些成员变量,例如:table,如果直接序列化这个成员变量的化,就可能会因为JVM的差异性导致反序列化table时的节点位置按照当前JVM对hashcode的实现,是不应该在当前槽位的,这便就会造成错误。

总之,不序列化这些成员变量的原因就是为了避免不必要的序列化和反序列化的开销以及考虑JVM的差异性。

下期预告:hashmap的构造方法解读。

欢迎订阅该专栏,该专栏将持续更新源码的学习。。

  • 21
    点赞
  • 26
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
HashMapJava中的一种数据结构,提供了键值对的存储和查找功能。在HashMap的底层实现中,使用了数组和链表(或者在Java 1.8中使用了红黑树)来解决哈希冲突的问题。 哈希冲突指的是当不同的键对象计算出的哈希值相同时,它们需要被存储在数组的同一个位置上。为了解决哈希冲突,HashMap中使用了两种方法,分别是开放地址法和链地址法。 开放地址法是指当发生哈希冲突时,继续寻找下一个空槽位来存储键值对。这个方法需要保证数组的长度是2的幂次方,通过hash & (length-1)的位运算来减少哈希冲突的概率[2]。 链地址法是指将发生哈希冲突的键值对存储在同一个位置上的链表或红黑树中。这个方法在Java 1.8中使用,当链表的长度超过一定阈值时,会将链表转换为红黑树,以提高查找效率。 在HashMap中,put方法用于插入键值对。当调用put方法时,首先会计算键对象的哈希值,并与数组的长度取余来确定存储位置。如果该位置已经存在键值对,则根据键对象的equals方法来判断是否是同一个键,如果是,则更新对应的值,否则将新键值对插入到链表或红黑树中。如果发生哈希冲突,就会根据选择的解决冲突的方法,继续寻找下一个空槽位或者在链表或红黑树中插入键值对。如果插入后,数组中存储的键值对的数量超过了负载因子(默认为0.75),就会触发扩容操作。 扩容操作会创建一个更大的数组,并将原数组中的键值对重新计算哈希值后插入到新数组中。扩容操作会在数组大小达到阈值(数组长度乘以负载因子)时触发。 总结起来,HashMap的底层实现是通过数组和链表(或红黑树)来解决哈希冲突的问题。它使用哈希值计算和位运算来确定存储位置,同时使用开放地址法和链地址法来解决哈希冲突。在插入键值对时,需要计算哈希值、确定存储位置,并根据解决冲突的方法进行插入。当数组中的键值对数量超过负载因子时,会触发扩容操作。<span class="em">1</span><span class="em">2</span><span class="em">3</span> #### 引用[.reference_title] - *1* *2* *3* [HashMap 底层源码解读(一行一行读,有基础就能看懂)](https://blog.csdn.net/rain67/article/details/124043769)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v93^chatsearchT3_2"}}] [.reference_item style="max-width: 100%"] [ .reference_list ]

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值