上一篇文章中介绍了集合类的框架及相关的类区别。这一节我们来学习一下 HashMap 到底是怎么实现的,散列表的实现原理,扩容及树化的过程(基于JDK 1.8)。
HashMap 底层存储结构
JAVA语言中,最基本的存储结构只有两种:数组 和 引用(模拟指针)。所有的数据结构都是用这两个基本结构来构造的,HashMap 当然也是。
那先用一张图来展示一下 HashMap 的存储结构:
显而易见,HashMap 实际是一个 “数组加链表”的存储结构,链表其实就是引用实现的。这种结构,我们又称之为“哈希表”或“散列表”。
散列表中,元素的存储并不是顺序的,而是根据散列算法实现。
散列表原理
数组:采用一段连续的存储单元来存储数据。对于指定下标的查找,时间复杂度为O(1);通过给定值进行查找,需要遍历数组,逐一比对给定关键字和数组元素,时间复杂度为O(n),当然,对于有序数组,则可采用二分查找,插值查找,斐波那契查找等方式,可将查找复杂度提高为O(logn);对于一般的插入删除操作,涉及到数组元素的移动,其平均复杂度也为O(n)
线性链表:对于链表的新增,删除等操作(在找到指定操作位置后),仅需处理结点间的引用即可,时间复杂度为O(1),而查找操作需要遍历链表逐一进行比对,复杂度为O(n)
二叉树:对一棵相对平衡的有序二叉树,对其进行插入,查找,删除等操作,平均复杂度均为O(logn)。
哈希表:相比上述几种数据结构,在哈希表中进行添加,删除,查找等操作,性能十分之高,不考虑哈希冲突的情况下,仅需一次定位即可完成,时间复杂度为O(1),接下来我们就来看看哈希表是如何实现达到惊艳的常数阶O(1)的。
我们知道,数据结构的物理存储结构只有两种:顺序存储结构和链式存储结构(像栈,队列,树,图等是从逻辑结构去抽象的,映射到内存中,也这两种物理组织形式),而在上面我们提到过,在数组中根据下标查找某个元素,一次定位就可以达到,哈希表利用了这种特性,哈希表的主干就是数组。
比如我们要新增或查找某个元素,我们通过把当前元素的关键字 通过某个函数映射到数组中的某个位置,通过数组下标一次定位就可完成操作。
存储位置 = f(关键字)
其中,这个函数f一般称为哈希函数,这个函数的设计好坏会直接影响到哈希表的优劣。举个例子,比如我们要在哈希表中执行插入操作:
查找操作同理,先通过哈希函数计算出实际存储地址,然后从数组中对应地址取出即可。
哈希冲突
然而万事无完美,如果两个不同的元素,通过哈希函数得出的实际存储地址相同怎么办?也就是说,当我们对某个元素进行哈希运算,得到一个存储地址,然后要进行插入的时候,发现已经被其他元素占用了,其实这就是所谓的哈希冲突,也叫哈希碰撞。前面我们提到过,哈希函数的设计至关重要,好的哈希函数会尽可能地保证 计算简单和散列地址分布均匀,但是,我们需要清楚的是,数组是一块连续的固定长度的内存空间,再好的哈希函数也不能保证得到的存储地址绝对不发生冲突。那么哈希冲突如何解决呢?哈希冲突的解决方案有多种:开放定址法(发生冲突,继续寻找下一块未被占用的存储地址),再散列函数法,链地址法,而HashMap即是采用了链地址法,也就是数组+链表的方式。
HashMap JAVA实现原理
在JAVA中,将数组中的每个位置称之为 桶, 而 桶 后面的每个数据称之为 bin (来自JDK1.8)。
先看一些比较重要的变量(摘自 JDK 1.8):
transient Node<K,V>[] table;
transient Set<Map.Entry<K,V>> entrySet;
transient int size;
transient int modCount;
int threshold;
final float loadFactor;
table
保存元素的散列表,是一个数组,数组的每一个元素称之为桶,桶中可以使用线性链表或二叉树,桶中的元素称之为bin。
entrySet
size
map中元素的个数。
capacity
容量,散列表中桶的个数,即 table 数组的大小。
默认值为16,每次都是2倍扩容。容量都是2的幂次。最大值为 1<<30。
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
static final int MAXIMUM_CAPACITY = 1 << 30;
loadFactor
装载因子,用来衡量hashmap 满的程度,影响扩容时机,默认值为0.75。
计算实时装载因子的方法:size / capacity 。
static final float DEFAULT_LOAD_FACTOR = 0.75f;
threshold
threshold = capacity * loadfactor
下面还有3个重要的常量:
TREEIFY_THRESHOLD
由线性链表转化为树的阈值,默认值为 8。桶中bin的数量超过该阈值,就由树来代替链表。
static final int TREEIFY_THRESHOLD = 8;
UNTREEIFY_THRESHOLD
由树转化为链表的阈值,默认值为 6。当桶中bin的数量小于该阈值,就将树转化为链表。
static final int UNTREEIFY_THRESHOLD = 6;
MIN_TREEIFY_CAPACITY
桶中bin 被树化时,最小的hash表容量,默认为 64 。当散列表容量小于该阈值,即使桶中bin的数量超过了 treeify_threshold ,也不会进行树化,只会进行扩容操作。
min_treeify_capacity 至少是 treeify_threshold 的4倍。
static final int MIN_TREEIFY_CAPACITY = 64;
接下来,看一下 HashMap 的主干:
HashMap 的主干是一个 Node(Entry) 数组。 Node 是 HashMap 的基本组成单元,也就是一个元素,包含 key-value 对。
Node 是 HashMap 的一个静态内部类,实现了 Entry 接口(JDK1.8):
static class Node<K,V> implements Map.Entry<K,V> {
final int hash; // key的hash值进行hash运算的结果
final K key;
V value;
Node<K,V> next; // 下一个Node 的引用,单链表结构
Node(int hash, K key, V value, Node<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
}
HashMap 的元素结构如下(图片摘自):
可以看出,HashMap 就是由“数组 + 链表”实现的。数组是主体,链表是为了解决 hash 冲突而存在的。
如果定位到的数组不含有链表,那么对于元素的查找,添加等操作很快,只需要一次寻址就可以;如果含有链表,对于添加操作依然是 O(1),但是对于查询来说,此时还需要遍历链表,通过 key 对象的 equals 方法依次对比。因此,根据性能来说,hashmap中的链表越少越好。
元素的Hash计算方法
hashCode() 方法是 Object 对象的 native 方法,所有类都继承自 Object 对象,默认都是该方法生成的。
HashMap的扩容树化过程
创建一个HashMap,在里面不停地增加 key hash值相同的 bin<key, value>,也就是这些数据都会被添加到同一个桶中。然后再debug模式下,查看HashMap的内存结构:
初始化HashMap capacity = 16 threshold = 12
treeify_threshold = 8 min_treeify_threshold = 64 untreeify_threshold = 6
执行PUT操作后,HashMap中元素的数量为 q,由于都在同一个桶中,也为桶中元素的数量。
q = 8: capacity = 16, 不扩容。
由于 q<threshold,不进行扩容;q<= treeify_threshold, 桶中元素仍为链表结构。
q = 9 : capacity = 32, threshold = 24 进行扩容,不树化。
由于 q<threshold , 不扩容;
但 q>treeify_threshold,需要进行树化,又因为 capacity(16) < min_treeify_threshold (64), 不允许树化, 强制扩容。
q = 10 : capacity = 64, threshold = 48 进行扩容,不树化。
由于 q<threshold , 不扩容;
但 q>treeify_threshold,需要进行树化,又因为 capacity(16) < min_treeify_threshold (64), 不允许树化, 强制扩容。
q = 11 : capacity = 64, threshold = 48 进行树化。
由于 q<threshold , 不扩容;
但 q>treeify_threshold,且capacity(64) >= min_treeify_threshold (64) ,进行树化。
q = 49: capacity = 128, threshold = 96 进行扩容 。
由于 q<threshold , 不扩容;
但 q>treeify_threshold,且capacity(64) >= min_treeify_threshold (64) ,进行树化
HashMap的扩容树化原理
根据扩容源码来分析:
final Node<K, V>[] resize() {
Node<K, V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
if (oldCap > 0) {
if (oldCap >= MAXIMUM_CAPACITY) {
// 如果capacity已经扩容到最大(2^31-1),则不进行扩容
threshold = Integer.MAX_VALUE;
return oldTab;
} else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY)
// capacity > 16 且 capacity*2 < MAXIMUM_CAPACITY, 则进行扩容2倍
newThr = oldThr << 1;
} else if (oldThr > 0)
// 如果capacity < 0 且 threshold > 0, 则 capacity = threshold
newCap = oldThr;
else {
// 如果capacity < 0 且 threshold < 0, 初始化table(都使用默认值)
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int) (DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
// 计算新的threshold
if (newThr == 0) {
float ft = (float) newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float) MAXIMUM_CAPACITY ? (int) ft : Integer.MAX_VALUE);
}
threshold = newThr;
// 将旧table中的数据移到扩容后的table中
@SuppressWarnings({ "rawtypes", "unchecked" })
Node<K, V>[] newTab = (Node<K, V>[]) new Node[newCap];
table = newTab;
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
Node<K, V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
if (e.next == null)
// 如果旧table的桶中只有一个bin, 将bin直接剪切到新table中
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
// 如果旧table的桶是树形bin, 使用树复制方式
((TreeNode<K, V>) e).split(this, newTab, j, oldCap);
else {
// 如果旧table的桶是线性链表bin, 使用链表复制方式
Node<K, V> loHead = null, loTail = null;
Node<K, V> hiHead = null, hiTail = null;
Node<K, V> next;
do {
next = e.next;
// 由于 存储位置 = Key.hashCode ^ (capacity-1), capacity扩大2倍后,key的hash值也会向左多取1位
// 若多取的最高为0, hash值保持不变; 若为1, hash值则扩大2倍
// 下面的代码就是将原来的链表, 根据扩大后的新hash值,拆分为两个链表,分别存储在新table中的不同桶中。
// lo 代表高位为0, hi 代表高位为1, tail 为链尾, head 为链头
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);
// 将 lo 链表放到新table的 j 位置, 将 hi链表放置到新链表的 j+oldCapacity 位置
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
上面源码中可以很清楚的看出HashMap的扩容机制。 在扩容中会将旧table中的元素复制到新的table中,但是元素的位置可能会发生改变。单bin 和 链表bin 的复制原理很简单,在代码中注释中已经表述的很清楚了。树形Bin扩容方法有点区别,如下:
final void split(HashMap<K,V> map, Node<K,V>[] tab, int index, int bit) {
TreeNode<K,V> b = this;
// Relink into lo and hi lists, preserving order
TreeNode<K,V> loHead = null, loTail = null;
TreeNode<K,V> hiHead = null, hiTail = null;
int lc = 0, hc = 0;
// 树形Bin的复制其实与线性链表bin很相似, 也是根据扩容后hash值的最高位, 分解成两个链表
// 区别在于分解后的两个链表, 如果 元素个数 < UNTREEIFY_THRESHOLD ,会将树转化为线性链表bin; 否则就会进行树化
for (TreeNode<K,V> e = b, next; e != null; e = next) {
next = (TreeNode<K,V>)e.next;
e.next = null;
if ((e.hash & bit) == 0) {
if ((e.prev = loTail) == null)
loHead = e;
else
loTail.next = e;
loTail = e;
++lc;
}
else {
if ((e.prev = hiTail) == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
++hc;
}
}
if (loHead != null) {
if (lc <= UNTREEIFY_THRESHOLD)
tab[index] = loHead.untreeify(map); // 转化为线性链表Bin
else {
tab[index] = loHead;
if (hiHead != null)
loHead.treeify(tab); // 树化
}
}
if (hiHead != null) {
if (hc <= UNTREEIFY_THRESHOLD)
tab[index + bit] = hiHead.untreeify(map);
else {
tab[index + bit] = hiHead;
if (loHead != null)
hiHead.treeify(tab);
}
}
}
HashMap中的树是红黑树(R-B Tree), 关于红黑树的理论请参考: