目录
底层数据结构
jdk1.8中,HashMap 使用了 数组 + (链表 or 红黑树)
的结构。
当链表中的数据达到 8 个,且数组长度达到 64 时,链表会自动转化为红黑树。不过这个情况基本上达不到。
- 数组的优势是随机读取和修改效率高。缺点是插入和删除效率低。
- 链表的优势是插入和删除效率高,容易扩展 。缺点是随机读取和修改效率低。
HashMap 结合了数组和链表的优点,查询、修改、插入、删除的效率都很高!
插入原理
-
调用 hash() 得到 key 对应的 hash值,然后将 hash值 转换成数组下标。数组中的元素是一个单向链表
-
不同的 key,最终得到的数组下标可能是相同的,他们会被放到这个下标对应的链表中
查询原理
-
调用 hash() 得到 key 对应的 hash值,然后将 hash值 转换成数组下标。
-
根据此下标获取对应的链表,如果链表存在的话,拿 key 和链表中的每个元素的 key 进行equals,如果有一个匹配的就返回该元素,否则返回 null
hash冲突
hash冲突指的是:不同 key 最终计算出来的数组下标相同。
出现这种情况时,这些 key 会以链表形式存在这个坐标 i 中。
数组坐标 i = (数组长度 - 1) & hash(key)
capacity 容量
capacity 指的是 HashMap 中能存放的条目数量。
容量必须是 2 的幂次方,即使我们传入的容量不是 2 的幂次方,也会自动转成 2 的幂次方。
在设置初始容量时应该考虑hash表中可能需要存放的条目数量,以便最大限度地减少resize次数。
为什么容量一定要是 2 的幂次方
因为 HashMap 是 “数组+链表” 的结构,我们希望元素的存放的更均匀,最理想的状态是每个Entry中只存放一个元素,这样在查询的时候效率最高。
那怎么才能均匀的存放呢?我们首先想到的是取模运算 (hash % capacity),而 HashMap 中使用了位运算来达到相同的效果。
为了使位运算和取模运算结果一样,那低位必须全是 1,如下面这个例子所示:
---------------------------------
e.hash= 1: 0000 0001
capacity 15: 0000 1111
& = 0000 0001
---------------------------------
e.hash= 17: 0001 0001
capacity 15: 0000 1111
& = 0000 0001
----------------------------------
capacity = 16 时,1 % capacity = 1 & (capacity - 1),17 % capacity = 17 & (capacity - 1)
同理,当 capacity 为其他 2 的幂次方数时,效果也是一样。所以容量(Capacity)的大小就必须为 2 的幂次方。
loadFactor 负载因子
负载因子指的是,当Hash表中的条目数量,达到一定比例后,需要进行自动扩容。这个比例就是负载因子。默认值是 0.75,表示条目数量达到容量的 75% 时,会进行自动扩容。
为什么负载因子默认是 0.75
负载因子是表示Hash表中元素的填满的程度。
- 负载因子越大,填满的元素越多,空间利用率越高,但冲突的概率加大了,查找成本变高
- 负载因子越小,填满的元素越少,冲突的机会减小,查找成本小,但空间浪费多了
因此,必须在 “冲突概率” 与 “空间利用率” 之间寻找一种平衡。
而 0.75 这个数值是官方给出的,根据官方的说法,当负载因子为 0.75 时候,Entry单链表的长度几乎不可能达到 8,作用就是让 Entry 单链表的长度尽量小,让HashMap 的查询效率尽可能高,同时也兼顾了空间利用率!
resize 扩容
当哈希表中的条目数超出了(加载因子 * 当前容量)时,哈希表会自动进行扩容,每次扩容后容量会翻倍。后面源码部分会详细说明。
红黑树
使用红黑树的条件和原因
jdk1.8中,HashMap的实现中用到了红黑树。当hash表的单一链表长度超过 8 个,且数组长度达到 64 时,链表结构就会转为红黑树结构。
使用红黑树是为了避免在极端情况下,链表会变得很长。
- 红黑树的时间复杂度为 O(logn)
- 链表的时间复杂度为 O(n)
红黑树近似平衡二叉查找树,其主要的优点就是“平衡“,左右子树高度几乎一致,通过这种方式来保障查找的时间复杂度为 log(n)。
为什么节点数达到8个才转为红黑树
树节点的比普通节点更大,在链表较短时红黑树并未能明显体现性能优势,反而会浪费空间,所以在链表较短时采用链表而不是红黑树。
为什么需要数组长度到64才会转化红黑树
当数组长度较短时,如16,链表长度达到 8 已经是占用了最大限度的 50%,意味着负载已经快要达到上限,此时如果转化成红黑树,之后的扩容又会再一次把红黑树拆分平均到新的数组中,这样非但没有带来性能的好处,反而会降低性能。所以在数组长度低于 64 时,优先进行扩容。
源码解析
关键属性
// 默认容量2的4次方:16。必须是2的幂次方
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
// 最大容量2的30次方
static final int MAXIMUM_CAPACITY = 1 << 30;
// 默认负载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// 当单向链表中元素达到8个时,转红黑树
static final int TREEIFY_THRESHOLD = 8;
// 当红黑树中元素达到6个时,转链表
static final int UNTREEIFY_THRESHOLD = 6;
// 哈希桶,一个数组,里面存放链表。 长度是2的N次方,或者初始化时为0.
transient Node<K,V>[] table;
// 哈希表内元素数量的阈值,超过阈值时,会发生扩容resize()
// threshold = 哈希桶.length * loadFactor;
int threshold;
// 加载因子,用于计算哈希表元素数量的阈值
final float loadFactor;
// hashmap中存放的元素个数
transient int size;
Node内部类
static class Node<K,V> implements Map.Entry<K,V> {
final int hash; // key的hash值
final K key;
V value;
Node<K,V> next; // 单向链表中下一个节点
...
}
数组容量计算
// 获得一个大于等于cap,且确保一定是2的幂次方的数
static final int tableSizeFor(int cap) {
int n = cap - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
在Java中, >>> 是右移高位补 0,我们将原数与移位后得到的数进行或运算,就可以确保在最高位的后面那一位它的值也是1。下一次 n >>> 2 的时候就能够确保最高位后面跟上三个1。在所有的移位、或的操作后,我们得到的就是最高位后面全是1的一个int类型数据。此时 n+1 必定是一个2幂次的数。以 17 为例:
int n = 17 - 1 = 16,再对 16 进行右移,最终得到的是 31。31 再加 1 得到新的数组长度是 32。
--------------------
0001 0000 16
0001 1000
0001 1110
0001 1111 31
--------------------
如果传入的 cap 本身就是 2 的幂次方,比如 16。那 n = 15,二进制就是 00001111,最终右移完成后还是 00001111,值没有改变。得到的长度还是 16。
计算key的hash值
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
put方法
/**
* Implements Map.put and related methods
*
* @param hash key对应的hash值
* @param key 主键
* @param value 需要插入的值
* @param onlyIfAbsent 如果为true,则插入时,发现已经有这个key了,则不更新值
* @param evict if false, the table is in creation mode.
* @return 如果有覆盖情况,则返回原来的值。否则返回null
*/
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; // tab存放当前哈希桶
Node<K,V> p; // key对应的数组坐标中存放的节点,节点通过next属性组成一个链表
int n, i; // n为哈希桶的长度,i为数组坐标
// 如果当前哈希桶是空的,则进行初始化
if ((tab = table) == null || (n = tab.length) == 0)
// 扩容哈希桶,并且将扩容后的哈希桶长度赋值给n
n = (tab = resize()).length;
// 如果当前数组坐标中的节点是空的,表示没有发生hash冲突,则新建节点
// 计算数组坐标:i = (n - 1) & hash
// 为什么要-1?因为n是2的幂次方,-1后转化成2进制,低位全都是1,这样进行&运算可以方便地得到坐标
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
// 如果节点不为空,也就是说发生了hash冲突
else {
// e用来存储需要被覆盖的节点
Node<K,V> e;
// k用于在判断节点是否需要被覆盖时,临时存储节点的key值
K k;
// 如果hash值和key都相等,说明key已经存在了,则覆盖value
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
// 将当前节点引用赋值给e
e = p;
// 红黑树
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
// 不是覆盖操作,则插入一个[普通链表节点]
else {
// 遍历链表
for (int binCount = 0; ; ++binCount) {
// 将p.next赋值给e,如果e为空,则在链表最后新增一个节点,并且跳出循环
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
// 如果链表元素达到阈值,则尝试转红黑树
if (binCount >= TREEIFY_THRESHOLD - 1)
treeifyBin(tab, hash);
break;
}
// e如果不为空,且e是要被覆盖的节点,则跳出循环
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
// 上面两种情况都不满足,则继续循环 p = p.next
p = e;
}
}
// 如果e不等于null,说明有要被覆盖的节点
if (e != null) {
V oldValue = e.value;
// onlyIfAbsent默认是false的,表示有冲突时,值会覆盖
if (!onlyIfAbsent || oldValue == null)
// 覆盖节点值
e.value = value;
// 空函数,HashMap中没有实现
afterNodeAccess(e);
// 返回oldValue
return oldValue;
}
}
++modCount;
// 如果存放的元素个数超过阈值,执行resize
if (++size > threshold)
resize();
// 空函数,HashMap中没有实现
afterNodeInsertion(evict);
return null;
}
resize方法
/**
* 初始化或者将size翻倍
*
* @return 新的数组
*/
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) {
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) // initial capacity was placed in threshold
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"})
// 创建一个新的节点数组,也就是新的桶,长度为newCap
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;
// 如果当前数组坐标中有元素,则赋值给e
if ((e = oldTab[j]) != null) {
// 旧数组中元素清空
oldTab[j] = null;
// 如果e.next没有节点了,表示没有哈希冲突,直接计算新数组中的坐标,并把e放进去
if (e.next == null)
// 计算数组坐标:e.hash & (newCap - 1)
newTab[e.hash & (newCap - 1)] = e;
// 如果发生了哈希冲突,且节点数达到8个,操作红黑树
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
// 如果发生了哈希冲突,且节点数小于8个
else {
// 低位链表头结点和尾节点
Node<K,V> loHead = null, loTail = null;
// 高位链表头结点和尾节点
Node<K,V> hiHead = null, hiTail = null;
// 临时节点,存放e的下一个节点
Node<K,V> next;
do {
next = e.next;
// [e.hash & oldCap]:计算元素的在数组中的位置是否需要改变
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
// (e.hash & oldCap) != 0,表示存在在高位
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
}
// e = e.next
while ((e = next) != null);
// 最终过滤链表中的元素
// 假设某数组坐标中原链表里有4个节点,它们的hash值为 1、5、17、20
// 过滤完之后就应该是 loHead(低位)中有 1和5,hiHead(高位)中有 17和20
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
e.hash & oldCap
此公式在resize过程中比较重要,用于计算元素的在数组中的位置是否需要改变。
既然扩容后容量翻倍了,那之前分配好的元素,有些也需要挪地方。有些元素保留在原位置(低位链表),有些元素保存到扩充出来的数组位置中(高位链表)
high位 = low位 + oldCap
假设 e.hash 分别为 1 和 17,oldCap=16, 此时根据公式 [(oldCap - 1) & hash] 可以算出:未扩容之前,得到的数组坐标都是1。而扩容之后,newCap=32,此时再计算时,显然 hash=17 时,计算出来的坐标会不同,所以 17 这种就需要改变位置
--------------------------------
e.hash= 1: 0000 0001
oldCap=16: 0001 0000
& = 0000 0000 = 0 表示坐标不需要改变
--------------------------------
e.hash=17: 0001 0001
oldCap=16: 0001 0000
& = 0001 0000 != 0 表示坐标需要改变
--------------------------------