前言
HashMap作为我们几乎天天都要使用到集合,不把它的源码掌握好也太对不起HashMap老哥日日夜夜的辛苦的付出吧。
废话不多说,最近研究了下HashMap源码,下面我将从JDK7和JDK8两个版本好好扒一扒它风骚的代码。
JDK7
其实HashMap在JDK7和JDK8中还是有一些差异的,从简单入手,把JDK7版本搞懂,JDK8就是水到渠成的问题了。
存储的数据结构
先说结论(因为大家都知道):数组+链表,那到底是什么数组以及什么链表?
要存放数据肯定是数组或者集合,顺便先看下HashMap有哪些字段,后面再挨个把每个字段做介绍并使用到。
通过源码不难发现HashMap的存储结构最外层是:Entry<K, V>[]这么个数组,也就是字段table。
再看下Entry,发现它是个HashMap的内部类,并且有key、value、next、hash这么四个字段,其中next的数据结构又是Entry,这是一个典型的单向链表。
那么,我们能确定存储HashMap的数据结构是:存着单向链表的一维数组,如图。
如何获取(get)
HashMap我们用得最多的方法无非就是存和取,把存取规则的源码搞明白了,HashMap的所有问题就迎刃而解。
我们看下get方法的源码:
逻辑主要在getEntry获取节点的子方法中。绿色的下划线,hash和indexFor两个方法虽然不知道内部逻辑但是明白通过indexFor能获得到参数key对应的Entry数组的下标。再细品一下这里面的for循环并不是在遍历数组,而是在遍历Entry节点链表,然后if判断计算的hash值与Entry对象字段中hash值以及入参key与Entry对象的字段key是否相等,如果能获取到对应的Entry对象节点,最后返回Entry节点的value。
这么看来HashMap的取值很简单,思路就是通过indexFor方法计算数组下标找到某个单向链表Entry<K,V>,然后通过遍历链表、key值匹配找到对应的Entry节点。
但是indexFor方法是如何计算下标的呢?
indexFor计算数组下标
indexFor方法其中的参数key值是调用hash方法计算出来的结果,那我们先看下hash方法。
看代码比较懵,但其实就是计算参数key的hash值,我们知道计算hash也有一定几率发生hash碰撞,那如果不同的参数key计算出来的hash值结果一样怎么办?别急,我们先看下indexFor方法用hash值来干嘛。
hash值和数组长度-1做取模(%)计算,结果是受数组长度决定的,得到的数刚好是介于0和数组长度-1的区间。
所以,是否会发生hash碰撞与计算数组下标的结果无关,只要保证相同的key值计算出来的hash值相同就OK。
那么结论就是:indexFor计算数组下标的方法就是通过计算参数key的hash值与数组的长度-1再进行取模运算。
如何存放(put)
同样,我们先看下put方法的源码:
看for循环的那一段代码是不是似曾相识?是的,for循环和get方法中的for循环逻辑是一样的:先通过入参key计算其hash值,在通过indexFor方法计算出数组下标。for循环依然遍历的是链表,存和取的查找计算方法肯定是一致的。
但是在put方法的for循环代码若找到了对应的Entry节点则将入参的value值覆盖到当前Entry节点对象的value字段值,最后返回被覆盖前的旧值。没错,put方法是有返回值的,可能我们在绝大部分日常工作使用中并没有到的,如果存放map中已经存在的key,会返回其旧值。
接下来就是put方法最核心的部分了,如果存放的key是新值当然从map中是找不到的,则继续向下调用addEntry方法:
我们知道HashMap是会动态扩容的,代码中如果符合if判断的条件则会扩容,这里先不讲扩容,跳过if判断直接看创建节点的代码方法:createEntry
我们先看下createEntry方法的入参分别是:用当前key计算的hash值(hash)、当前待存放的key值(key)、当前待存放的value值(value)、当前待存放对象的通过indexFor方法计算出的table数组下标(bucketIndex)。
其中通过Entry<K,V> e = table[bucketIndex];
可以获取到该下标数组中的对应的Entry链表对象。最后创建一个Entry对象的构造参数正好是:hash、key、value、以及其链表下个next节点Entry对象e。
这里存放元素的时候其实有分为两种情况,分别如下图:
- bucketIndex下标等于2,刚好数组的该位置没有元素。所以e等于null,然后创建一个新的Entry对象存放在当前数组位置
table[bucketIndex] = new Entry<>(hash, key, value, e);
。
2. bucketIndex下标等于4,当前位置不为空,已存放一条链表。则将该链表对象作为新创建的Entry对象的next下一个对象,那么新的元素则是存在该数组当前位置链表的表头。 如图所示,红色节点是新put的元素。
至此,我们知道了当put元素到HashMap集合中时在不扩容的情况下是如何存放对象的,但如果是扩容的情况下呢?
扩容
讲扩容之前我们先看下扩容的条件是什么?也就是上面put方法创建节点的addEntry方法中if的判断条件。
看代码,当当前HashMap的size值大于threshold值且当前table数组下标位置不为空时,触发扩容方法:resize。
其中threshold的是HashMap设计的一个“阈值”,这个阈值的计算其实在是创建HashMap的时候。
综上源码我们可以知道HashMap在初始化时,默认threshold阈值是16,而有个loadFactor(通常称为扩展因子)等于0.75,其实真正的阈值是通过threshold乘loadFactor计算得到的,我们可以通过put方法中的inflateTable方法看出:
参数threshold传入,又通过和扩展因子相乘结果赋值回来。所以初始时阈值threshold应该等于12。
以上满足了扩容条件后,接着才是最重要的扩容核心部分:
在resize方法中传入了原来table长度的两倍,即创建了新的Entry数组大小是原来的两倍赋值给table字段,而此时的阈值又变成了扩展两倍后的长度值乘以扩展因子0.75。
综上,那么可以得出HashMap的扩容结论:
- 扩容的条件是当前长度大于当前阈值且该key值对应的数据下标对象不为空。
- 每次扩容的大小是原来的两倍。
- 阈值是随着每次扩容后的大小再乘以固定扩展因子0.75而变化的。
扩容的结论知道了但还是要把扩容的原理搞清楚,数组扩大两倍后又是怎样存储老数据的呢?通过transfer方法可以看出来这里会将老table数组全部遍历然后逐个重新计算hash值,重新通过indexFor方法计算在新数组中的下标,最后存放到新数组中。
看下图助于理解:
可以看出这样必然会消耗性能,所以我们在创建HashMash初始化的时候建议设置容量大小避免发生扩容而造成一定的消耗。