该系列主要介绍java.util包下的各种容器。
传送门:
Java底层源码解析·容器篇——List
Java底层源码解析·容器篇——Set
先来一张java.util包下容器的分类图:
Map有下面几种实现类:HashMap、LinkedHashMap、TreeMap、HashTable等。它主要定义了下列这些方法:
int
和Collection相比,Map引入了键值对的概念,因此方法大多针对键值对进行操作。Map的每一个键值对都是用Entry对象来存储的,所以我们先来看看Entry是什么:
Entry
Entry是一个接口,定义了以下的方法:
K
我们可以将Entry理解为一个二元组,一般至少会有两个成员变量,key和value。不同的Map实现类使用了不同的Entry实现类。我们在介绍具体Map的时候再看各自的Entry实现类有什么特点。
HashMap
我们知道HashMap的底层实现是使用数组+链表+红黑树的方式。
transient Node<K,V>[] table;
我们先来看看Node,HashMap有两个内部类Node和TreeNode,分别实现了Entry接口,Node拥有下列四个成员变量:
static
next变量很好理解,指向下一个Node对象。hash变量则存储了key的hash值。
TreeNode间接继承了Node,相比Node多了下面这些成员变量。
static
显然,基础的Node类是用来构建链表的,而TreeNode是用来构建红黑树的。
下面我们来逐一分析HashMap各个方法的底层源码:
查询get
public
根据key获取元素的流程根据以上源码大致可以归纳为以下几步:
- 根据key计算一个hash值。
- 根据hash值计算数组下标。
- 判断当前数组位置是否有Node,如果没有则直接返回null。
- 如果有Node,判断该Node是否满足要求,如果满足则直接返回该Node的value
- 如果不满足,判断当前的Node是否是TreeNode,如果是,说明该位置存储的是红黑树,用树获取节点的方式去获取指定节点。
- 如果不是TreeNode,说明该位置是链表,遍历链表获取指定节点。
下面我们展开介绍其中几个需要注意的点:
- 计算hash值,计算方法如下:
static
简单来说如果key是null,则统一返回0。不然的话根据key的hashCode()函数获取一个hash值a,然后将a无符号右移获得新的hash值b,最后将a和b做异或运算得到最终的hash值。
为什么要这样做可以参考文章《HashMap中的hash算法总结》。简单来说就是将hash值a的高区特征与低区特征混合起来,使得在计算数组下标的时候能够减少Hash冲突。
- 计算数组下标,计算方式如下:
(n - 1) & hash
其中n是HashMap底层数组的长度。这里的计算非常有趣,HashMap的数组长度初始值为16,每次进行扩容时,长度翻倍:
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
...
final Node<K,V>[] resize() {
...
newCap = oldCap << 1
...
}
因此HashMap的数组长度始终为2的幂次,这样子n-1的二进制就能保证低位全部为1,计算出的数组下标就能覆盖0~n-1。试想一下如果n不为2的幂次,例如17,则n-1的二进制位000...10000,那只能产生0或16的数组下标,其他1~15的下标就都浪费了!
- 判断节点是否满足要求
if(first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
这里需要注意的一点是:同一个数组下标里的节点它们的hash值不一定相等!因为数组下标实际上只是hash值的低位而已,这里首先用hash值判断能够提高比较效率。
添加put
/**
根据上述源码,插入元素的流程大致可以归纳为以下几步:
- 根据键值对的key计算hash值。
- 判断底层数组是否为null,如果为null,先初始化底层数组。
- 根据hash值和数组长度计算数组下标。
- 判断当前位置是否为空,为空则直接生成新节点放入该位置。
- 如果该位置不为空,则查看头结点是否满足key的要求,满足的话修改头结点的value值。
- 如果不满足,判断该节点是否为TreeNode,如果是说明为红黑树,用树的方式添加或修改节点。
- 如果不为TreeNode,则说明为链表,遍历链表判断是否存在指定key的节点,如果存在则修改value,不存在则新生成节点,并挂在链表的尾部。并判断插入新节点后的链表长度是否大于阈值(默认为8),如果是的话,将链表转换为红黑树。
- 判断插入新元素后的HashMap大小是否大于阈值,如果大于阈值对HashMap进行扩容。
下面我们展开介绍其中几个需要注意的点:
- 判断底层数组是否为null,如果为null,先初始化底层数组。从这里我们可以看出,HashMap是用懒汉模式进行底层数组的初始化的。直到插入第一个元素,才对底层数组进行初始化,而不是在构造函数里进行初始化。
- 判断插入新节点后的链表长度是否大于阈值(默认为8),如果是的话,将链表转换为红黑树。我们从前面对查询的分析也可以看出来,对于发生Hash冲突的元素,如果存储的数据结构为链表,则需要从头到尾遍历。而如果存储结构为红黑树,则可以用类似二分的方式找到指定位置,因此发生Hash冲突的元素过多时,将链表转化为红黑树能够提高后续查找的效率。
- 判断插入新元素后的HashMap大小是否大于阈值。阈值默认为负载因子与当前数组大小的乘积。
我们注意到上述源码中有下列两个方法:
afterNodeAccess
这两个方法在HashMap中是空方法,不做任何操作,而在LinkedHashMap中有具体的实现。
扩容resize
扩容是指HashMap里面的元素超过了阈值、较多元素会发生Hash冲突导致HashMap效率下降时,进行底层数组扩容的操作。扩容的代码比较复杂,归纳起来主要做的事情有两件:
- 计算新的数组大小,并更新阈值,生成新的数组。
- 遍历旧数组,将旧的数组里的节点移动到新的数组中。
- 计算新的数组大小,并更新阈值
数组大小大于等于MAXIMUM_CAPACITY时,数组不继续扩容弄。其他情况,都将扩容为原先的两倍。阈值也会同步扩容为原先的两倍,边界情况会进行特殊处理,阈值最大为Integer.MAX_VALUE。
- 遍历旧数组,将旧的数组里的节点移动到新的数组中
这里涉及到两个问题:1. 怎么计算节点在新数组里的位置。2. 怎么移动。
首先我们用节点的hash值与旧数组的长度例如16进行&操作,有两个结果:0或者16。对应新数组的位置分别为“旧数组的位置”与“旧数组的位置+旧数组的长度”。
if
根据这两个结果,我们能够将红黑树或者链表分成两个子树或者两个子链表。然后分别将两部分放入对应的位置即可。
其他方法都和get以及put原理类似,这里不再多做赘述。
LinkedHashMap
LinkedHashMap继承了HashMap。我们知道用迭代器遍历HashMap的时候是无序的,而LinkedHashMap遍历是有序的,它维护了一个“访问链表”,且有两种顺序:插入顺序、访问顺序,可以在调用构造函数时进行指定,默认为插入顺序。
插入顺序很好理解,访问顺序是指每次访问某个节点,都将该节点移动到末尾,最后一个遍历。
LinkedHashMap实现了自己的Entry:
/**
添加了before和after两个成员变量,来维护“访问链表”。
上面HashMap的put操作,我们讲到HashMap调用了两个空方法,这两个空方法在LinkedHashMap里进行了重写,以维护“访问链表”。
void
实际上LinkedHashMap与HashMap非常类似,多出来的操作都是在对节点进行增删改查操作后对“访问链表”的对应维护,没有什么特别复杂的地方,所以不做别的展开。
TreeMap
TreeMap的底层使用红黑树实现。在遍历的时候能够按照传入的Comparator进行排序。和普通红黑树主要的区别在于TreeMap中的节点存储了key和value两个值,且节点按照key进行排序。这里不做继续展开。
HashTable
Hashtable底层是用数组+链表的方式实现的,现在已经弃用,和HashMap主要存在以下几点区别:
- 继承的父类不同
Hashtable继承自Dictionary类,而HashMap继承自AbstractMap类。但二者都实现了Map接口。
- 线程安全性不同
Hashtable是线程安全的,而HashMap是非线程安全的。Hashtable的方法基于synchronize实现线程安全。
- key和value是否允许null值
HashMap允许key和value为null,而Hashtable不允许。
public
- 迭代器实现不同
Hashtable和HashMap都实现了Iterator接口,而Hashtable因为历史原因还实现了Enumeration接口。
- hash值不同
Hashtable直接使用key的hashCode,而HashMap会重新计算hash值。下面是Hashtable计算数组下标的方式:
int hash = key.hashCode();
int index = (hash & 0x7FFFFFFF) % tab.length;
- 数组初始化和扩容方式不同
首先Hashtable在调用构造函数时就进行了底层数组的初始化,而HashMap是在第一次插入元素是进行底层数组的初始化的。然后Hashtable的数组初始大小为11,而HashMap为16。最后Hashtable扩容时,大小变为原先的两倍加一,而HashMap扩大为原先的两倍。
public