这是我参与8月更文挑战的第23天,活动详情查看:8月更文挑战
前言
HashMap:根据 key 关键字,设置对应的值或获取对应的值。key 的算法是 hash。
public class Test {
public static void main(String[] args) {
Map<String, String> map = new HashMap<>();
map.put("1", "yyds");
map.put("2", "tql");
map.put("3", "xswl");
map.get("1");
map.get("2");
map.get("3");
}
}
其数据结构思考过程:
- 数组:只需要根据
key做hash后成hashKey,直接映射到数组中某个位置。 - 链表:哈希冲突之后呢?那就加个链表,把相同的
hashKey的值链起来。查询性能O(n)。 - 红黑树:数据多后,链表性能差?那就换成红黑树,查询性能
O(logn)。
哈希类集合的三个基本存储概念,如图:
数据结构:数组 + 链表 + 红黑树
JDK 1.8 之所以添加红黑树是因为一旦链表过长,会严重影响 HashMap 的性能,而红黑树具有快速增删改查的特点,这样就可以有效的解决链表过长时操作比较慢的问题。
下面分析 HashMap 的关键点,JDK1.8。
1)成员变量解析
// 初始容量,默认容量:16,1 左移 4位,2 ^ 4
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
// 最大容量:2 ^ 30
static final int MAXIMUM_CAPACITY = 1 << 30;
// 负载因子:0.75
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<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
}
参数:
- 默认负载因子:0.75
- 默认容量大小:16
即 12个人的会议,需要:12 / 0.75 = 16把椅子
因为 mod 16 比 mod 12的冲突概率小,且并不浪费资源
但 人数 > (椅子数量 × 负载因子) 时会进行扩容。调用resize,将容量扩充为原来的 2 倍。
2)Hash 算法
哈希碰撞的概率取决与 hashCode 计算方式 和 空间容量。
碰撞后,采用拉链法。
hash 算法,源码解析:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
// 比如说:有一个 key 的 hash 值
// 原值 :1111 1111 1111 1111 1111 1010 0111 1100
// 右移 16 位: 0000 0000 0000 0000 1111 1111 1111 1111
// 异或后 : 1111 1111 1111 1111 0000 0101 1000 0011
先右移16位再异或操作,为什么要这么做呢?
目的就是:尽可能减少
hash冲突,进入数组的同一个位置。 让低16位包含:低16位 和 高16位的特征。
这里又引申出,那为什么不全位数比较呢?
- 大部分情况下,高16位差异不大。
- 比较操作,可能从低位开始,这样就能越快比较出不同。
3)寻址算法
还是直接进入 put(K key, V value) 方法:
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<K,V>[] tab; Node<K,V> p; int n, i;
// 为空,初始化
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 对应的 key 是否存在
if ((p = tab[i = (n - 1) & hash]) == null)
// 不存在,则创建一个
tab[i] = newNode(hash, key, value, null);
else {
// ... ...
}
// ... ...
}
如何计算? 答:(n - 1) & hash。
n:默认 16hash:hash(key),是对key进行hash计算之后
// 举个栗子:
// 比如,hash:
1111 1111 1111 1111 0000 0101 1000 0011
// n - 1 = 15
0000 0000 0000 0000 0000 0000 0000 1111
// 那么 (n-1)&hash, 进行与操作,15 & hash:
// 转为 10进制之后,就是 3
0000 0000 0000 0000 0000 0000 0000 0011
// 那么 i = 3 ,就是最后寻址算法获取到的那个 hash值对应的数组的 index
总结寻址算法:
(n - 1) & hash: 确定数组里的一个位置。
取模运算,它的性能是比较差的,为了优化这个数组寻址的过程。
(n - 1) & hash:效果是跟hash对n取模一样的,但是与运算的性能要比hash对n取模要高很多
为什么呢?
这是一个数学问题:数组的长度会一直是 2 的
n次方,只要它保持数组长度是 2 的n次方。hash对n取模的效果 ->(n - 1) & hash效果是一样的,后者性能更高。
4)hash冲突时的链表处理
如果
hash冲突了呢?那如何处理呢?
先来看下源码:
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<K,V>[] tab; Node<K,V> p; int n, i;
// 为空,初始化
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 对应的 key 是否存在
if ((p = tab[i = (n - 1) & hash]) == null)
// 不存在,则创建一个
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
// hash 相同,key 也相同
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
// ... ...
// 进行旧值覆盖
// 相同的key在进行value的覆盖
if (e != null) { // existing mapping for key
// oldValue 旧值
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
// value 新值,赋值新值
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
// ... ...
}
举个栗子:
// 有两个操作:
map.put("1", "yyds");
map.put("2", "xswl");
// 那么 "yyds" 就是 oldValue
// 那么 "xswl" 就是 value
V oldValue = e.value;
// 说白了,就是新值覆盖旧值
如果出现大量 hash 冲突之后,某个位置挂着一个很长的链表,那遍历起来就特别痛苦。
所以,JDK 1.8 以后优化了这块,在链表长度达到 8 的时候,链表会转换成红黑树:时间复杂度变为 O(logn)。
对应源码如下:
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
// ... ...
else {
// ... ...
// 此时已经是一颗红黑树
else if (p instanceof TreeNode)
// 红黑树是一个平衡的二叉查找树
// 插入节点时候,操作较为复杂
e = ((TreeNode<K,V>)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);
// 如果 binCount >= 15
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
// 转换成红黑树
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
// ... ...
}
// ... ...
}
treeifyBin(tab, hash); 源码如下:
- 单链表 转换成 双向链表
- 双向链表 转换成 红黑树
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
else if ((e = tab[index = (n - 1) & hash]) != null) {
TreeNode<K,V> hd = null, tl = null;
do {
TreeNode<K,V> p = replacementTreeNode(e, null);
if (tl == null)
hd = p;
else {
p.prev = tl;
tl.next = p;
}
tl = p;
} while ((e = e.next) != null);
// do-while 执行完之后,先是将单向链表转换为 TreeNode 类型的双向链表
// 之后再变为红黑树
if ((tab[index] = hd) != null)
hd.treeify(tab);
}
}
小结:先是挂链表,如果链表长度超过了 8,就将链表转换成红黑树
5)基于数组的扩容原理
HashMap 底层数据结构是:数据 + 链表 + 红黑树。
HashMap 扩容:2倍扩容 + rehash
扩容之后,之前的
hash过的key,要重新hash。 每个key-value对,都会基于key的hash值重新寻址找到新数组的位置
扩容是按照 2的倍数来的,源码如下:
static final int tableSizeFor(int cap) {
// 目的是另找到的目标值大于或等于原值
// 例如二进制1000,十进制数值为8。
// 如果不对它减1而直接操作,将得到答案10000,即16。
// 显然不是结果。减1后二进制为111,再进行操作则会得到原来的数值1000,即8。
int n = cap - 1;
// 先来假设n的二进制为01xxx...xxx
// 对n右移1位:001xx...xxx,再位或:011xx...xxx
// 对n右移2为:00011...xxx,再位或:01111...xxx
// 此时前面已经有四个1了,再右移4位且位或可得8个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;
}
原先冲突的 key,在扩容之后,可能会在新的数组上分布在不同的位置:
6)JDK 1.8的高性能 rehash 算法
JDK 1.8以后,为了提升rehash这个过程的性能,不是简单的用key的hash值对新数组的长度进行取模。
而是采用位操作,举个栗子:
案例图解下:
- 数组长度为 16时:
# 案例1
# 数组长度 n = 16,hash1 = hash(key1)
n - 1 0000 0000 0000 0000 0000 0000 0000 1111
hash1 1111 1111 1111 1111 0000 1111 0000 0101
# n - 1 与 hash1 进行与(&) 操作之后:
&结果 0000 0000 0000 0000 0000 0000 0000 0101 = 5(index = 5的位置)
# 数组长度 n = 16,hash2 = hash(key2)
n - 1 0000 0000 0000 0000 0000 0000 0000 1111
hash2 1111 1111 1111 1111 0000 1111 0001 0101
&结果 0000 0000 0000 0000 0000 0000 0000 0101 = 5(index = 5的位置)
可以发现案例1中的两个 hash 出现了 hash 碰撞,这时候可以使用链表或者红黑树来解决。
- 数组扩容之后,数组长度为 32时:
# 案例2
# 数组长度 n = 32,hash1 = hash(key1)
n-1 0000 0000 0000 0000 0000 0000 0001 1111
hash1 1111 1111 1111 1111 0000 1111 0000 0101
&结果 0000 0000 0000 0000 0000 0000 0000 0101 = 5(index = 5的位置)
# 数组长度 n = 32,hash2 = hash(key2)
n-1 0000 0000 0000 0000 0000 0000 0001 1111
hash2 1111 1111 1111 1111 0000 1111 0001 0101
&结果 0000 0000 0000 0000 0000 0000 0001 0101 = 21(index = 21的位置)
通过案例2,发现 hash2 从原来 index = 5 变成 index = 21了。 通过规律发现,每次扩容之后,hash值变化如下:
- 要么保持原来位置不变(
index不变) - 要么
新index = 旧index + 旧数组长度oldCap,举个小栗子:index(21) = index(5) + oldCap(16)
总结扩容机制:
-
数组2倍扩容
-
重新寻址(
rehash):hash & n - 1,判断二进制结果中是否多出一个bit的1-
如果没多,那么就是原来的
index -
如果多了出来,那么就是
index + oldCap
通过这个方式,就避免了
rehash的时候,用每个hash对新数组.length取模,取模性能不高,位运算的性能比较高。 -