先前介绍了 List 集合的三种子类实现原理,今天我们来讲讲另一种数据结构——Map 集合,Map 是一种用来存储 key-value 的数据结构,每一个 key 对应了一个 value,并且同一个集合里面不允许存在相同的 key。
在 Java 中,常见的 Map 实现是 HashMap、HashTable、LinkedHashMap、TreeMap 和 ConcurentHashMap,下面从源码的层面上,给大家讲解 HashMap 的实现原理。
从 HashMap 名字中我们可以看出这是一个用 hash 表这种数据结构实现 Map 集合,涉及到 hash 表这种数据结构就需要考虑两个问题:
1. 使用什么算法来计算 key 的 hash 值
2. 出现冲突时,使用什么机制去解决冲突
这两个是 hash 表要考虑也是最核心的两个问题,只要搞懂了这两个问题,其实你就能够理解 HashMap 的实现原理了。
首先先来看看 HashMap 类的定义:
public class HashMap<K,V> extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable {
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
static final int MAXIMUM_CAPACITY = 1 << 30;
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;
static class Node implements Map.Entry {
final int hash;
final K key;
V value;
Node next;
Node(int hash, K key, V value, Node next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
}
transient Node[] table;
transient int size;
transient int modCount;
}
上面列举了 HashMap 定义的一些静态常量,这些静态常量的具体用途在下面讲解具体操作的时候会再介绍,首先需先理解 Node 的定义和其他变量的用途。
Node 是集合中结点的抽象,它内部封装了该结点的 key-value,同时还保存了 hash 值,key 和 hash 都是 final 域,这代表加入集合中的 key 是不可变的,要求其 hashcode 值不会发生改变,否则将可能会出现内存泄露问题。另外该结点定义中还有一个 next 域,用于指向下一个结点,这是用于解决冲突所定义的结构(其实从这个定义就可以猜想到 HashMap 是采用拉链法来解决冲突的)。
table 是 HashMap 的用于存储结点元素的数据结构,其实就是 hash 表,通过 size 来记录当前 hash 表存放了多少个元素。其实从这里定义可知,HashMap 的底层数据结构是数组 + 链表,所有操作都是通过操作这两个数据结构来进行的。
map 是一种集合的数据结构,当然就需要实现集合的基本操作,那么现在就从最最基本的添加操作来讲解,通过结合源码和例子来让大家更加清晰地了解它的设计思想
put 操作用于在集合添加一个 key-value 的键值对,在 HashMap 中采用数组+链表的形式来存储数据,每一个数组元素都是一个链表,该链表存储相同 hash 值的 key 结点(拉链法)。
它的主要工作流程:
1. 先通过 hash 算法计算 key 的 hash 值,再通过 hash & (n-1) 计算该 key 应该存放在数组哪一个位置
2. 若当前 index 位置上不存在元素,则代表未发生冲突,直接创建新结点存放,并作为该位置链表的头结点,结束;
3. 若当前 index 位置上存在元素,则代表发生冲突,则采用拉链法解决冲突,遍历链表查看是否存在相同的 key 元素(比较方法采用 equals 方法,null 元素特别处理),存在,则替换旧值;若不存在,则插入到链表的末尾
4. 更新当前的元素个数,若超过负载因子,则进行扩容操作
JDK1.8 的源码实现:
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,boolean evict) {
Node[] tab;
Node p;
int n, i;
/*
如果当前 table 为null,则当前 hash 表未初始化,则进行初
始化操作
*/
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
/*
如果当前 hash 后的数组位置不存在结点,即代表还未没有发生冲突,
则创建新结点保存
*/
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
/*
当前位置已经有元素,则代表出现冲突,采用拉链法解决冲突,遍历
链表查看是否存在相同的 key ,若存在,则将新值替换旧值,返回
旧值;若不存在,则在链表的末尾插入元素。
注:比较 key 是否相同,是采用 equals 方法来进行的,除 key
为 null 的情况,HashMap 是可以存放 null 元素的
*/
Node e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null &&
key.equals(k))))
e = p;
else if (p instanceof TreeNode)
e = ((TreeNode)p).putTreeVal(this, tab,
hash, key, value);
else {
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1)
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
// 上面的过程查询相同的 key 的结点,若找到,则执行值替换
if (e != null) {
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
// 插入元素成功后,容量增加,若当前超过负载因子,则进行扩容操作
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
从上述的过程和源码,相信大家对 put 操作有一个大致的了解,那么现在再具体地来说一下一些内部的细节是如何实现,增加了哪些优化操作,以及在 jdk 1.8 为什么要这样实现。
从源码可以看出,hash 值是通过公式 hashcode ^ (hashcode >>> 16) 计算所得,在 JDK 1.8 前都是直接采用 hashcode,并未经过计算,该种方式保证了高 16 位全为 1,具体的优化目的其实我也不太清楚,可能可增大命中高位位置的概率。
要计算 index 位置,先通过 hash 算法计算 key 的 hash 值,再通过公式 hash & (n-1) 来算的,这也是 1.8 后引入的优化操作。在之前,都是通过 hash % n 来计算 index,但在 1.8 时,HashMap 的容量都要求是 2 的整数倍,并且根据公式 hash & (n-1) = hash % n,当 n = 2^i 时,& 运算比 % 运算的效率要高,因此采用 & 运算代替 % 运算计算 index 。
从源码部分其实可以看到增加了一些 TreeNode 的操作,这部分也是 JDK 1.8 新引入的红黑树机制。考虑当未引入红黑树时,出现冲突时采用拉链法解决冲突,那么当查找元素时则需要遍历链表找到匹配的 key,最优时查找次数为 1,最差时查找次数为 n,那么总得平均查找次数为 n/2,因此当出现较多碰撞时,HashMap 的查询性能则会下降,时间复杂度可能会去到 O(n)。
因为为解决决该种情况时的性能下降,JDK 1.8 引入了红黑树,红黑树也是一种较平衡的二叉树,它的插入、查找和删除的时间复杂度都为 O(logn),具体的红黑树结构可以自行去 google 了解。因此,在 JDK 1.8 中,当添加元素时,链表元素个数大于 8 个时,则会触发将链表转换成红黑树,从而提高查询性能。
前面也提到,当当前负载大于负载因子,则会触发扩容操作。
那么问题就来了:为什么需要扩容?负载因子是什么?
首先负载因子 = 当前元素个数 / 总容量,即当前元素所占的比例。我们知道 HashMap 底层采用的数组这种结构,在有限的存储空间存放元素,当存放 n + 1 个元素时,那么至少有两个元素会存放在同一个位置,那就是说当存放的元素个数越多,发生冲突的概率就会越高,那么就会影响查询性能。因此为了降低的冲突的概率,当负载超过 75% 时,则将数组扩容,增大容量。
具体的扩容工作流程:
1. 先将当前容量增大一倍(因为都是 2 的倍数,采用 << 1 操作),创建一个新容量的新数组
2. 遍历旧数组每一个元素,对链表上的元素或红黑树上的元素进行重定位,存放到新数组中(这里面会涉及到红黑树的退化以及链表的拆分)
重定位操作是通过公式 hash & n 来进行,由于重定位的结果只有两种,要么放在原 index 位置,要么放在 index + n 位置,那么现在假设当前 n 为 2,在 1 位置上 存放了 key 为 1 和 key 为 3 的元素,即如下图:
经过扩容后,则转化为:
从图上可以看出 3 去到新数组的 3 位置,而 1 仍在 1 位置。那么现在来看 1 的二进制表示为 0001,3 的二进制表示为 0011 ,扩容后容量为 4(0100), 即计算位置的公式为 hash & 0011,那么只要当 hash 的最高位为 1,则新位置为 index + n,否则为 index。(与旧容量的对齐的最高位,当前为第二位)
扩容源码实现:
final Node[] resize() {
Node[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
if (oldCap > 0) {
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
else if (oldThr > 0)
newCap = oldThr;
else {
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
// 创建新数组,容量为原来的两倍
@SuppressWarnings({"rawtypes","unchecked"})
Node[] newTab = (Node[])new Node[newCap];
table = newTab;
// 遍历旧数组元素,将元素重定位
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
Node e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
// 这里进行红黑树的退化
else if (e instanceof TreeNode)
((TreeNode)e).split(this, newTab, j, oldCap);
// 这里进行链表的分裂,将链表根据最高位是否为1,来分配位置
else {
Node loHead = null, loTail = null;
Node hiHead = null, hiTail = null;
Node next;
do {
next = e.next;
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
通过 put 操作的分析其实已经能够了解 HashMap 的具体实现原理,删除和获取操作实际上也是对数组+链表+红黑树这个数据结构进行操作而已,只要理解了添加操作,对于其他操作应该也不难理解。另外的话,HashMap 的迭代器也是属于 fast-fail 类型,在遍历时,若其他线程修改了 map(导致 modCOunt 被修改),会抛出 ConcurentModifyException 。
在 Java 中,Map 的实现子类有HashMap、HashTable、LinkedHashMap、TreeMap 和 ConcurentHashMap,HashMap 在该文章已经讲解过,LinkedHashMap 则是在 HashMap 的基础上,增加了一个链表机制来维护元素添加的顺序;TreeMap 则是可排序的 Map 集合,内部是采用红黑树来实现的。
而 HashTable 是一个比较古老的容器,它与 HashMap 的实现机制类似,都是采用 数组+链表的形式来实现的,不过 HashMap 增加了红黑树的实现。两者还有一个重要的区别是 HashMap 是线程不安全的,而 HashTable 是线程安全的,因为后者每一个方法都使用了 synchronize 关键字来定义。另外的话,HashTable 不支持 key 为 null 的元素,且扩容容量为原来的 1.5 倍。虽然 HashTable 是线程安全的,但同样不适用于并发环境编程,因为 synchronize 会导致性能下降,限制了并发量。因此,若要用于多线程开发,可使用 ConcurentHashMap ,它采用了分段锁和 volatile 变量来提高并发性能,关于 ConcurrentHasnMap 的实现原理可看我的这篇文章Java 集合——ConcurrentHashMap