纯干货系列(二)---Map学习总结

250 阅读15分钟

在上一篇文章中,对List的基本原理和常见方法进行了总结介绍,这一节则是对Map的一个总结。

本文的主要内容及分布如下图

思维导图

Map也是一种非常常用的数据结构,就我的个人开发来说,在每一次的项目中都少不了Map的应用。而自己之前对Map的理解也只能说是停留在会用这个阶段,知道是一个类似字典的结构,通过哈希计算内存地址可以实现快速存取,其他的还知道一些存取方法而已。现在呢既然决定要写文章,那就肯定不能写的那么简单了,要不然就没有太大的价值,所以就结合源码以及一些博客资料进行了深入一点的学习,并把自己学到的东西总结出来,算是对自己学习成果的检验。

Java中的Map实现类簇如下图所示。

类簇图

结合自己的开发经验,整个Map的相关类中,最经常使用的肯定是HashMap,此外TreeMapLinkedHashMap也或多或少的涉及到,Hashtable则是从来都没有使用过。在这篇总结中,主要是关于HashMap,最后会简要介绍一下TreeMapLinkedHashMapHashtableHashMap基本一样,会在概述中介绍两者的异同。

HashMap

概述

Map其实就是代表一种映射关系,将key(键)和value(值)通过一种特殊关系进行关联,然后可以方便的通过key找到对应的valueHashMap就是通过哈希算法来表示键值对之间的映射关系,通过哈希算法计算key对应的内存地址,直接找到对应的value,因此速度非常快,如果哈希算法设计的比较好的话,对基本操作(get(), put())可以达到常数时间性能

This implementation provides constant-time performance for the basic operations (get and put), assuming the hash function disperses the elements properly among the buckets.

与HashTable的区别

HashTableHashMap相比应该是有两个方面的差异点

  • HashTable对大部分方法做了同步,而HashMap没有,因此HashTable是线程安全的
  • HashTable不允许Null作为键或者值

The HashMap class is roughly equivalent to Hashtable, except that it is unsynchronized and permits nulls

至于为什么HashTable不允许Null作为键和值存储,根据HashTable作者本人的回答,如果允许Null作为值存储,那么很有可能就会歧义:是因为没有存入对应的key值因此取出Null还是存储的key对应的值就是Null

基本结构

上面提到HashMap就是将key进行哈希运算,找到内存地址。之前其实不知道它是怎么找到对应的内存地址的,经过这次源码的学习后才发现,它居然底层又是通过数组实现的,所谓的内存地址其实就是数组对应的下标索引,真是万万没想到,数组居然这么重要,看来接下来得好好的研究一下数组到底是怎么回事了。

在JDK1.8之前,Map的基本结构就是数组+链表,但是在1.8之后,为了进一步提高性能,其基本结构就变成了数组+链表+红黑树了。其基本结构如下所示

HashMap基本结构

上图应该非常清楚的表示了HashMap的结构,需要注意的一点是数组中存储的每个元素都是一个HashMap.Node对象它是HashMap的一个内部类,是Map.Entry的子类。其中主要包含四个字段信息:

  • key: 存储的键
  • value: 存储的值
  • hash: key经过哈希算法之后得到的结果
  • next: 指向下一个对象

在这里就不在展示这个类的具体代码,如果后面涉及到具体方法的话在详细介绍,这里只需要知道这四个字段的含义就可以了。关于转换红黑树以及哈希冲突会在后面讲到

基本属性介绍

HashMap的源码中,有一些属性我觉得还是比较重要的,而且会在后面方法中使用到,因此在这里先做一个简单介绍

  • DEFAULT_INITIAL_CAPACITY: 数值为16,默认的HashMap的初始化容量,必须为2的指数次幂
  • DEFAULT_LOAD_FACTOR:数值为0.75,默认的负载因子,可以在构造函数中自定义修改,但是不推荐修改
  • TREEIFY_THRESHOLD:数值为8,下接的链表元素长度如果超过这个值就转换为红黑树,如果哈希算法设计的好,这种可能性小于百万分之一
  • UNTREEIFY_THRESHOLD: 数值为6,如果下接链表元素长度少于这个值之后就从红黑树转为链表形式
  • MIN_TREEIFY_CAPACITY:数值64,如果少于这个值,应该首先选择扩容而不是转换为红黑树,为了避免属性化和扩容冲突,它应该至少为4*TREEIFY_THRESHOLD
  • MAXIMUM_CAPACITY:数值为2^{30},初始化时所能设置的最大容量
  • threshold: 所能容纳的key-value对极限,是table.size和load factor的乘积,如果超出这个限制之后就会进行扩容
  • table:类型为Node[],具体存放元素的数组,长度必定是2的N次幂

以上这些就是HashMap内的一些基本属性

构造方法

构造函数

在创建HashMap时,有三个构造函数可以选择,具体构造函数代码如下

HashMap构造函数

其实还是比较简单的,我经常使用的其实就是默认构造函数,初始容量和负载因子都是使用默认值,但是在《阿里巴巴Java开发手册》中给出的建议是最好能够在构造HashMap时根据自己的实际情况传入容量,这样可以避免后续扩容产生的开销。

所有的构造函数最终都会调用到HashMap(int initialCapacity, float loadFactor)在这个函数中会对传入的容量和负载因子进行判断。容量最大只能为MAXIMUM_CAPACITY

上面提到过table数组的长度必定是2的N次幂,可是用户可以自定义传入初始容量,如果随便传的话怎么保证的?可以看最后一个函数tableSizeFor(),就是通过这个函数实现的。

tableSizeFor

下面来看一下这个函数的实现,还是非常巧妙的,全部都是位运算,实现功能的同时还保证了效率

tableSizeFor()

还是用图来描述一下方法的过程吧,这样会更清楚一点

tableSizeFor()图解

因为n的第一位必定是1,因此不断无符号右移,依次实现两位,四位,八位,十六位,三十二位全部为1,但是最大还是只能为MAXIMUM_CAPACITY,最后n+1保证容量必然是2^n。这是比传入容量cap大的最接近的2^n

第一行n = cap - 1是为了防止传入的cap就是2^n这种情况导致经过算法之后容量变为传入的2倍。

为什么必须保证容量为2的次幂,这肯定是有原因的,接着往下看就能知道了

构造函数基本到这里就介绍完了,下面开始讲解HashMap中的重头戏,就是添加元素的put()方法

添加元素

HashMap作为常用的数据结构,我们进行最多的操作就是存取数据了,在HashMap中存数据的操作还是比较复杂的,中间涉及到的流程非常的多,不信的话就看一下面的这个流程图

put操作流程

下面就结合流程图从头到尾进行讲解吧

哈希算法

我们在向HashMap中添加元素时使用的是put()方法,该方法的实现为

put()

可以看到put()方法首先调用hash()计算key的哈希值,然后将hash(key)keyvalue作为参数再传入putVal()中。这一小节我们先来说一说哈希算法的过程

如果输入的keynull在哈希值直接为0,如果不是,则将keyhashcode()方法结果的高低16位进行异或。这样做可以保证即使在table数组较小的情况下,也可以保证高低位的bit都参与到运算中,同时还能保证不会有太大的开销

hashcode()是从Object类中继承下来的方法

putVal

接下来介绍插入的核心过程putVal(),这一部分的代码稍微有一些复杂,因此结合上面的流程图在每一个关键步骤处添加了注释

putVal

接下来按照执行顺序来讲解一下这一过程

  1. 判断table数组是否为空,如果是则调用resize()执行扩容,这个方法后面也会介绍到
  2. 计算对应的索引,判断数组对应的索引是否为空,如果为空则创建Node节点并插入
    • 在构造函数部分曾经提到过数组的长度都必须为2^n这里就来解释为什么:注意i = (n - 1) & hash计算得到对应的索引,其中n是数组长度,hash是计算得到的哈希值。在经过与运算之后,得到的结果肯定小于n,作为数组的一个索引位置正好合适,并且这个位置之和哈希值有关
  3. 判断数组对应索引元素与待插入元素是否相同,一致的条件是哈希值相同并且equals()返回值为true,如果相同就直接覆盖
  4. 判断待插入位置的元素类型是否为TreeNode,如果是则意味着这已经有超过8个元素插入该位置了,已经从链表转化为了红黑树。这时候就也需要将新元素执行插入树方法。(这个putTreeVal()方法有点复杂,所以我现在也还没有完全理解,等到后面有时间的话会再去好好分析一下)
  5. 否则就说明节点下连接的还是一个链表,这时候就逐个链表进行遍历比较
  6. 判断是否到达链表结尾,如果是则插入,然后判断链表长度是否已经达到8,如果是就需要转换为红黑树了
  7. 如果在链表中找到相同元素,就覆盖

其实整个插入过程就是这么几个步骤,这样整理下来也还是比较清晰的,但这其中也有两点值得注意:

  1. 哈希冲突:上面说到过在得到元素索引后,会判断该位置是否有元素。其实也就是经过哈希算法之后多个元素映射到同一个位置了。这种情况本来是不应该发生的,但却是不可避免的,一个好的哈希算法应该使得这种情况发生的概率非常低。
  2. 当一个位置已经有多个元素时(少于8个)会形成链表,这时候再插入一个新的元素,如果也在该位置,那么在插入的时候会插入到链表尾。这个和JDK1.7还是有所不同的,在JDK1.7中是头插法。为什么会有这种改变会在后面多线程操作部分进行说明

扩容(resize)

上面已经将插入元素的整体步骤说完了,这里会介绍其中的一个比较重要的细节就是扩容。在List中也有扩容操作,每次扩容之后容量都变为之前的1.5倍,但是在Map扩容之后,容量都变为之前的2倍。并且HashMap的扩容其实可以分为两步

  1. 创建新数组,容量为之前的2倍
  2. 将旧数组中的元素重新执行哈希算法映射到新的数组中

这一部分的代码有点复杂,我会尽量解析的清楚一点

整个代码也是分为两个部分的,第一部分是计算扩容之后的数组的容量,这一部分可以结合代码中的注释。第一次扩容时初始化为默认容量或者自定义容量,后续每次扩容都变为之前的2倍,容量限制最大为Integer.MAX_VALUE,如果旧数组容量已经超过MAXIMUM_CAPACITY则直接返回,并将阈值设置为Integer.MAX_VALUE

在计算出扩容后的容量之后,就会创建一个新的数组,然后将原始数组中的元素重新计算hash值,然后映射到新的大容量数组中。但是在寻找新位置时还是有一点需要注意的。

在JDK1.8中,每次扩容都是将数组容量变为之前的2倍,因此在计算对应数组索引时,计算得到的索引值只有两种情况,不变或者在原位置移动2次幂的位置。可以看下图示:

这样我们在计算扩容后的索引的时候就只需要看原来hash值新增的那一位(e.hash & oldCap)是0还是1,是0的话索引就不变,是1的话新的索引就变成old index + old cap

这个设计确实非常的巧妙,既省去了重新计算hash值的时间,而且同时,由于新增的1bit是0还是1可以认为是随机的,因此resize的过程,均匀的把之前的冲突的节点分散了。

线程安全性

我们已经知道HashMapHashTable的一个区别就是HashMap不是线程安全的,所以在多线程的场景下不能使用。其实在JDK1.7和JDK1.8中还是有所区别的,在1.7中HashMap头插法可能导致出现死循环的情况。虽然1.8中采用了尾插法解决了这个问题,但是在多线程时仍然可能会有安全问题,下面先讲解1.7为什么会有死循环现象,然后说明1.8中仍然存在不安全问题的原因

假设初始化一个map长度为2,loadFactor为0.75,那么threshold为1,也就是说当插入第二个元素的时候,map就需要进行resize

通过在resize之前设置断点让线程1和线程2都成功插入元素但是还没有进行resize

如下图左边所示,我们可以看到所有的数据顺序为A->B->C

但是在经过resize之后,由于使用了单链表的头插入方式,也就是新元素会被放置在链表头,导致在新数组中AB元素位置发生颠倒,出现了B.next=A同时A.next=B依然存在,因此多个线程调整完成后就有可能出现环形链表

在升级到JDK1.8之后,就不会出现死循环现象了,但是多线程时候仍然会有的一个问题就是数据错乱:上一秒插入的值在下一次取的时候不能保证没有发生变化,因此还是存在线程安全隐患

取元素

取元素是通过调用get()方法实现的,相较于put()方法来说简单了很多,感觉也不用怎么讲,直接看代码就很容易能够理解了

如果对应的key值不存在的话就返回null

TreeMap

HashMap通过哈希算法可以实现对元素的快速存取,而TreeMap则提供了完全不同的实现,TreeMap由于实现了SortedMap接口,因此里面的元素都是有序的。这种有序不是按照元素的插入顺序,而是基于Key元素的属性(由Comparator或者Comparable确定)

在使用TreeMap时,为了使得插入的元素按照我们想要的顺序,可以有两种方式

  1. TreeMap的构造函数中传入一个Comparator
  2. 使用实现了Comparable接口的Key

对于TreeMap而言,排序是必须进行的过程,因此如果要使用TreeMap就必须通过以上中的一种方式将排序规则传递给TreeMap。如果既不指定Comparator也不去实现Comparable接口,那么在put()时就会抛出ClassCastException

另外,如果使用默认排序方式,或者自定义的Comparator中不允许Key值为Null,那么在调用put()方法时传入null键就会抛出NullPointerException

TreeMap的实现也是基于红黑树🤦‍♂️,这个只能等我后面好好学习一下红黑树的原理之后在详细说了

总结

上面就是对Map的一些学习总结,主要是关于HashMap,而其中重点是说明了在执行插入元素时的过程和调用的方法。下面说几点算是对以上内容的总结吧

  • HashMap的底层结构为数组,在JDK1.8中,同一个索引下元素较少时为链表结构,元素较多时自动转换为红黑树
  • HashMap在多线程环境下存在风险,虽然HashTable有同步操作但是不推荐使用,建议使用的是ConcurrentHashMap
  • JDK1.7中由于采用头插法,所以多线程情况下可能出现环的情况;JDK1.8虽然采用尾插法虽然不会出现死循环问题,但是仍然可能出现数据错乱的情况,因此仍然不是线程安全的
  • 扩容操作的耗时还是比较明显的,因此尽量在定义时就确定容量的范围
  • 负载因子可以修改,但是不推荐
  • HashMap的初始容量为16,而且每次扩容都变为之前的2倍,构造函数传入自定义容量也会被规整2的次幂,使用2^N的原因是为了计算在数组中的索引位置
  • HashMap每次扩容后都必须重新计算数据的位置

参考内容: Java 8 系列之重新认识 HashMap