一篇文章深入理解 JDK7 HashMap
在日常开发中,集合作为存储数据的容器,被广泛使用在程序代码中,本文将从 JDK 集合类代表 HashMap 出发,着重理解 HashMap 底层实现。
一、Map 家族关系图
在正式讨论 HashMap 之前,我们有必要把 Map 家族的继承实现关系展示出来,方便理解后续的内容。
上图很详细地展示了 Map 家族中各个成员之间的继承或者实现关系。Map 接口是双列集合的顶层父接口,该集合中每个元素都是键值对 key-value,成对出现。双列集合中,一个键一定只找到对应的一个值,键不能重复,但是值可以重复。Map 集合常用的实现类有 HashMap、LinkedHashMap、HashTable,TreeMap。
HashMap
HashMap 的底层是哈希表+链表结构,与 HashSet 相似,只是数据的存储形式不同,HashMap 可以使用 null 作为 key 或 value,是线程不安全的,但是效率相对较高。当给 HashMap 中存放自定义对象时,如果自定义对象作为 key 存在,这时要保证对象唯一,必须重写对象的类的 hashCode 和 equals 方法。
HashTable
HashTable 与 HashMap 之间的关系完全类似于 Vector 和 Arraylist 的关系。HashTable 是线程安全的,但是效率相对较低,Hashtable 不允许使用 null 作为 key 和 value。
LinkedHashMap
LinkedHashMap 是 HashMap 的子类,其底层是哈希表+链表结构,其关系与 HashSet 和 LinkedHashSet 类似,使用链表来维护 key-value 的次序,可以记住键值对的插入顺序。
TreeMap
TreeMap 存储 key-value 键值对时,需要根据 key 对节点进行排序。TreeMap 可以保证所有的 key-value 对处于有序状态。也有两种排序方式:
自然排序:TreeMap 的所有 key 必须实现 Comparable 接口,而且所有的 key 应该是同一个类的对象,否则抛出 ClassCastException 异常。
定制排序:创建 TreeMap 时,传入一个 Comparator 对象,该对象负责对 TreeMap 中的所有 key 进行排序。不需要 Map 的 key 实现 Comparable 接口。
本文主要介绍 Java7 中 HashMap 的底层实现原理,其他的 Map 集合,读者可以自行翻阅其他资料进行学习。
二、JDK7 中 HashMap 底层原理
2.1 HashMap 在 JDK7 中的结构
HashMap 在 JDK7 或者 JDK8 中采用的基本存储结构都是数组+链表形式。本节主要是研究 HashMap 在 JDK7 中的底层实现,其基本结构图如下所示:
上图中左边橙色区域是哈希表,右边蓝色区域为链表,链表中的元素类型为 Entry<K, V>,它包含四个属性分别是:
K key
V value
int hash
Entry<K, V> next
那么为什么会出现数组+链表形式的存储结构呢?这里简单地阐述一下,后续将以源码的形式详细介绍。
我们在使用 HashMap.put("Key", "Value")方法存储数据的时候,底层实际是将 key 和 value 以 Entry<Key, Value>的形式存储到哈希表中,哈希表是一个数组,那么它是如何将一个 Entry 对象存储到数组中呢?是如何确定当前 key 和 value 组成的 Entry 该存到数组的哪个位置上,换句话说是如何确定 Entry 对象在数组中的索引的呢?通常情况下,我们在确定数组的时候,都是在数组中挨个存储数据,直到数组全满,然后考虑数组的扩容,而 HashMap 并不是这么操作的。在 Java 及大多数面向对象的编程语言中,每个对象都有一个整型变量 hashcode,这个 hashcode 是一个很重要的标识,它标识着不同的对象,有了这个 hashcode,那么就很容易确定 Entry 对象的下标索引了,在 Java 语言中,可以理解 hashcode 转化为数组下标是按照数组长度取模运算的,基本公式如下所示:
实际上,在 JDK 中哈希函数并没有直接采取取模运算,而是利用了位运算的方式来提高性能,在这里我们理解为简单的取模运算。
我们知道了对 Key 进行哈希运算然后对数组长度进行取模就可以得到当前 Entry 对象在数组中的下标,那么我们可以一直调用 HashMap 的 put 方法持续存储数据到数组中。但是存在一种现象,那就是根据不同的 Key 计算出来的结果有可能会完全相同,这种现象叫作“哈希冲突”。既然出现了哈希冲突,那么发生冲突的这个数据该如何存储呢?哈希冲突其实是无法避免的一个事实,既然无法避免,那么就应该想办法来解决这个问题,目前常用的方法主要是两种,一种是开放寻址法,另外一种是链表法。
开放寻址法是原理比较简单,就是在数组里面“另谋高就”,尝试寻找下一个空档位置,JDK 中 ThreadLocal 发生哈希冲突以后就是采用的开放寻址法。而链表法则不是寻找下一个空档位置,而是继续在当前冲突的地方存储,与现有的数据组成链表,以链表的形式进行存储。HashMap 的存储形式是数组+链表就是采用的链表法来解决哈希冲突问题的。具体的详细说明请继续往下看。
在日常开发中,开发者对于 HashMap 使用的最多的就是它的构造方法、put 方法以及 get 方法了,下面就开始详细地从这三个方法出发,深入理解 HashMap 的实现原理。
2.2 深入理解 HashMap 的构造方法
我们打开 IDE 查看 HashMap 的源码,首先得了解一下 HashMap 的一些成员属性,它的主要属性如下图所示:
这里对上面的属性进行简单地介绍:
1. 第一个属性是默认的初始化容量,表示哈希表的默认长度,当我们在创建 HashMap 对象的时候未指定容量时,默认的容量就是 16。这里多说一句,当我们知道我们的 Key-Value 对的个数的时候(一般是个数大于 16),我们在创建 HashMap 对象的时候最好就指定容量大小,很多人都认为,我们要存入多少 K-V 对就指定多少初始容量,其实这样是不对的,你指定的初始化容量不一定是最后真正的初始化容量,因为你设置的初始化容量是需要经过转换的,它会被转换成大于它本身且接近它的 2 的次幂,比如,我们需要在 HashMap 中存储 27 个 K-V 对,那么大于 27 且最靠近 27 的 2 的次幂就是 32,那么如果你在创建 HashMap 对象的时候指定的容量为 27,那么其实创建出来的 HashMap 容量为是 32,且当你连续存储到满了 24 个以后,当存入第 25 个 K-V 对的时候,有可能就会触发扩容,这样不仅没有减少扩容次数,反而有大概率地发生扩容。那么我们该如何确定传入的值呢?这里提供一个公式:(int)((float) expectSize / 0.75f + 1.0f)
,这个公式来自于 HashMap 的一个构造方法,比如你的expectSize = 27
,那么计算的结果就是 37,那么就可以传入 37 作为初始化容量,经过内部计算以后,大于 37 且最接近 37 的 2 的次幂是 64,最终初始化出来的结果就是数组长度为 64,反向计算扩容阈值就是 48,那么存入 27 个 K-V 对是不会发生扩容的,如果仅仅是传入的 27,那么有很大可能会发生扩容,影响性能。
2. 第二个属性是哈希表的最大容量。
3. 第三个属性默认的负载因子,也就是我们在创建 HashMap 对象的时候没有指定负载因子,那么就使用这个负载因子,它是哈希表扩容的一个重要参数,当 HashMap 中存储的 K-V 对的个数大于等于哈希表容量乘以这个负载因子(size >= capacity * DEFAULT_LOAD_FACTOR
)的时候,将触发扩容,且扩容为原容量的两倍。举个例子,当我们创建 HashMap 对象未指定哈希表容量的时候,也就是使用默认的 16,当调用 HashMap 对象的 put 方法存储数据的时候,当存储的数量为 16*0.75f=12 的时候,将触发扩容机制,此时哈希表容量将扩充为 32,这也就是在上面第一个属性描述的时候为什么多说一句的原因了。
4. 第四个属性是空的哈希表。
5. 第五个属性是后续操作的哈希表。
6. 第六个属性是 HashMap 中存储的 K-V 组合的个数。
7. 第七个属性是扩容阈值,当size >= threshold
的时候(实际还需满足有链表的条件),将触发哈希表的扩容机制,threshold = capacity * loadFactor
。
8. 第八个属性是是自定义负载因子,当没有指定负载因子的时候,loadFactor = 0.75f
。
9. 第九个属性是记录了 map 新增/删除 K-V 对,或者内部结构做了调整的次数。其主要作用,是对 Map 的遍历操作做一致性校验,如果在 iterator 操作的过程中,map 的数值有修改,直接抛出 ConcurrentModificationException 异常。
10. 第十个属性表示对字符串键的 HashMap 应用代替哈希函数时,Map 内元素个数的默认阈值,目的是为了减少哈希碰撞的概率。
在 JDK7 中,HashMap 有四个构造方法,分别是:
我们一起看看前三个构造方法,第二个第三个构造方法都是调用的第一个构造方法,这三个构造方法很简单,但是第一个构造方法有一点需要注意,那就是构造方法将初始化容量 initialCapacity 的值赋值给了扩容阈值 threshold,这就给我们带来一个疑问,一开始我们都知道一个等式关系:threshold=capacity*loadFactor
,难道默认情况下,我们使用 HashMap 的无参构造方法,扩容阈值和默认容量一样,都是 16?难道第一次扩容是 size=16 才开始吗?这个疑问我们先放一放,稍后在 put 方法中你就知道到底是怎么回事了。我们先研究一下第四个构造方法,第四个构造方法是将一个 Map 转换成 HashMap,一共三行代码:
第一行代码创建了一个 HashMap 对象,设置了 threshold 值和 loadFactor 值。
第二行代码是初始化 HashMap,第三行是将 Map 转换成 HashMap 的具体实现。
一起来看看第二行代码内部实现:
从第四个构造方法可知,转化过程中,暂时将确定的一个数组容量值赋值给 threshold 暂存,在初始化数组之前,计算出最合适的 capacity(大于 threshold 且最接近 threshold 的 2 的 N 次幂),然后再计算出真正的扩容阈值 threshold(threshold = capacity * loadFactor
),然后在创建 Entry 数组(桶数组)。这里其实也对上面疑问进行了一个解答,其实我们在使用 HashMap 的无参构造创建 HashMap 对象的时候,并没有初始化 Entry 数组,那么是何时初始化 Entry 数组的呢?那是在第一次调用 put 方法的时候,后续,我们会一起深入理解 HashMap 的 put 方法。接下来,我们继续看 putAllForCreate 方法。
上面的代码分析很重要,对后续的 put 方法研究很有帮助,从上面的代码分析可知,我们从源码级别了解到 JDK7 中的链表新增的新成员是插入头节点的。
2.3 深入理解 HashMap 的 put 方法
对于 HashMap,我们平常使用最多的就是 put 方法了,从上面的源码分析可知,在创建 HashMap 对象后,并没有初始化哈希表,也就是 HashMap 的成员属性Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE = {}
,那这个哈希表是何时开辟空间的?答案很明显,那就是第一次使用 put 方法的时候。
上面对 put 方法进行了解析,接下来我们深入理解一下 putForNullKey 方法和 addEntry 方法,首先我们一起理解一下 putForNullKey 方法,代码如下:
该方法是对 key 为 null 的特殊处理的方法,在 HashMap 设计的时候,就规定了哈希表第一个位置,也就是下标为 0 的位置,只存放 key=null 的 Entry 对象,这也就合理解释了 HashMap 可以存储 key 为 null 的 K-V 对,且最多只存储一个 key 为 null 的 Entry 对象。
接下来一起看看 addEntry 方法,该方法不仅担任了添加 Entry 对象任务,还担任了扩容的任务。
上面的两处代码分析可以得出一个结论:
扩容的条件必须满足size >= threshold
和null != table[bucketIndex]
,看到很多资料说只要size >= threshold
就会触发扩容机制,这是不对的。看条件就知道,size >= threshold
中的等于号一定会满足,只要一直执行 put 方法,那么一定就会有等于的时候,那么为啥还需要大于号呢?按照错误的想法,岂不是大于号是多此一举?其实不然,扩容要满足两个条件,如果 put 方法在 put 第 threshold 个 K-V 对的时候,但是存放 Entry 对象的数组 bucketIndex 处并没有链表,那么也不会扩容,也就是说,put 第 threshold 个 K-V 对且发生哈希冲突才会扩容。引申一点,当 put 键为 null 的 K-V 对的时候,永远不会发生扩容,因为它要么发生 Value 的替换,那么是一个 Entry 对象的插入,不会涉及到哈希冲突。
进一步总结如下:
扩容条件必须同时满足
size >= threshold
和null != table[bucketIndex]
put 键为 null 的 K-V 对的时候永远不会发生扩容
2.4 深入理解 HashMap 的扩容机制
从上面小节的分析可知,扩容的条件为:
扩容条件必须同时满足
size >= threshold
和null != table[bucketIndex]
put 键为 null 的 K-V 对的时候永远不会发生扩容
我们查看 resize 方法的具体实现来理解 HashMap 的扩容机制,代码如下:
扩容很简单,就是将哈希表的长度变成原来的两倍,但是这仅仅是第一步,后面还需要对原有的数据进行迁移,使原有数据更加均匀地散列在哈希表中。数据迁移的具体操作由方法 transfer 来提供。
这就基本完成了对 HashMap 的 put 方法的研究,其实研究起来并不是很难理解,耐下心来阅读一下源码,肯定会有很大收获。
2.5 深入理解 HashMap 的 get 方法
接下来我们继续来阅读 get 方法,相对于 put 方法,get 方法实现起来就简单很多,理解起来也不是很困难,基本原理就是计算 key 的 hash 值,然后计算当前 key 在哈希表中的索引位置,然后在遍历链表,逐个比对,最后返回结果。
getEntry 方法是遍历链表来获取与当前 key 相同的 Entry 对象,判断 key 是否存在的标准是:
e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))
节点的 key 和目标 key 的 hash 值肯定是相等的,&&
右边的条件,即节点的 key 与目标 key 的相等,要么内存地址相等,要么逻辑上相等,两者有一个满足即可。
假设 get 方法传入的 key 值计算的下标为 1,HashMap 的 get 方法的基本示意图如下所示:
2.6 HashMap put、get 方法流程图
这里提供一个 HashMap 的 put 方法存储数据的流程图供读者参考:
这里提供一个 HashMap 的 get 方法获取数据的流程图供读者参考:
上面中 get 流程图画得稍微比正常的要复杂一些,只是为了描述流程更加清晰。
2.7 常见的 HashMap 的迭代方式
在实际开发过程中,我们对于 HashMap 的迭代遍历也是常见的操作,HashMap 的迭代遍历常用方式有如下几种:
方式一:迭代器模式
方式二:遍历 Set<Map.Entry<K, V>>方式
方式三:forEach 方式(JDK8 特性,lambda)
方式四:keySet 方式
把这四种方式进行比较,前三种其实属于同一种,都是迭代器遍历方式,如果要同时使用到 key 和 value,推荐使用前三种方式,如果仅仅使用到 key,那么推荐使用第四种。
三、总结
本文着重讲解了 JDK7 中 HashMap 的具体实现原理,包括 put、get、扩容等内部实现机制,相信读者仔细品读以后,对 JDK7 中的 HashMap 的实现会有一个清晰地认识,JDK7 中的 HashMap 的实现原理属于经典实现,不管 JDK7 是否已经再被使用,但是其基本原理还是值得学习!后续将继续讲解 JDK8 中的 HashMap 实现原理,届时将对比 JDK7,帮助读者掌握两者之间的共性和差异!
了解更多干货,欢迎关注我的微信公众号:爪哇论剑(微信号:itlemon)
版权声明: 本文为 InfoQ 作者【itlemon】的原创文章。
原文链接:【http://xie.infoq.cn/article/847bb1b5853b62b94bafef9b5】。文章转载请联系作者。
评论