学了一些新的东西,总得留下点什么。
引言
在Java编程中,HashMap 是一种非常重要的数据结构。无论是日常开发还是面试环节,HashMap 都是我们无法绕开的一个话题。那么,HashMap 到底是什么?它是如何工作的?又有哪些常见的面试题呢?今天我们就来聊聊这些问题。
一、什么是 HashMap?
HashMap 是一种基于哈希表的数据结构,它用来存储键值对(Key-Value pairs)。你可以把 HashMap 想象成一本《中华字典》,字典的每一页都有一个页码(键),我们通过这个字典页码就能快速找到我们想要的解释(值)。
- 键(Key):用来唯一标识一个值
- 值(Value):实际存储的数据
这种通过键查找值的方式,使得 HashMap 的查找、插入和删除操作非常高效,一般来说,时间复杂度都是 O(1)。
二、HashMap 是如何工作的?
Java 8的HashMap底层实现:数组 + 单向链表 + 红黑树
理解 HashMap 的关键在于两个词:哈希(Hash)和表(Table)。当我们往 HashMap 里存一个键值对时,HashMap 会先计算这个键的哈希值,通过这个哈希值可以获得这个键值对在表中存放的位置,然后再把这个值存到对应的表格位置里。
- 哈希函数:哈希函数是 HashMap 的核心部分。它通过计算键的哈希值来决定键值对应该放在哈希表的哪个位置。理想的哈希函数能够将键均匀分布到哈希表的各个位置,减少冲突。
- 处理冲突:即使有了哈希函数,有时候不同的键还是会被映射到同一个位置,这就叫哈希冲突。Java 中的 HashMap 采用链地址法来解决冲突——简单来说,就是在每个位置存一个链表,如果多个键值对被映射到同一个位置,就把它们放到同一个链表里。如果链表过长(默认超过 8 且数组长度大于等于 64),HashMap 会把链表转为红黑树,这样能更快地查找到元素。
- 负载因子和扩容:HashMap 还有一个“负载因子”的概念,它是一个衡量 HashMap 装满程度的指标。默认情况下,当 HashMap 中的元素数量超过容量的 75% 时(负载因子为 0.75),HashMap 就会扩容,把原有的数据重新哈希到一个新的更大的表中。
三、HashMap核心源码解析
核心静态变量
// 默认初始容量:16(必须为2的幂次方)
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
// 默认负载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// 把链表转为红黑树的阈值
static final int TREEIFY_THRESHOLD = 8;
// 将红黑树转化为链表的阈值
static final int UNTREEIFY_THRESHOLD = 6;
// 将链表转化为红黑树时,数组的大小必须大于等于这个值。否则如果 TREEIFY_THRESHOLD 大于8,将扩容,而不是转为红黑树
static final int MIN_TREEIFY_CAPACITY = 64;
Node类
static class Node<K,V> implements Map.Entry<K,V> {
// 节点的hash值
final int hash;
// 节点的key
final K key;
// 节点的值
V value;
// hash冲突时,指向下一节点(Java 8是尾插)
Node<K,V> next;
Node(int hash, K key, V value, Node<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
}
核心静态方法
// 计算key的hash值
static final int hash(Object key) {
int h;
// 为什么要异或上 无符号右移16位的值?
// 因为hash值是int类型的,总共32位长,让高16位的值参与计算,是为了更好的减少hash碰撞(下面会举例说明)
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
// 根据给定的容量,返回一个大于等于该值的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;
}
成员变量
// 哈希表,Node类型的数组,用来存储数据
transient Node<K,V>[] table;
// map中所包含键值对的个数
transient int size;
// 数组扩容阈值
// 当map中所包含的键值对大小size大于当前值时,就会扩容
// 一般情况下,threshold = 数组容量 * 负载因子(在第一次通过有参构造方法创建map时,会赋值为:tableSizeFor(n)的值;但是后面会恢复为:数组容量 * 负载因子)
int threshold;
构造方法
public HashMap(int initialCapacity, float loadFactor) {
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
// 最大容量为2^30
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
this.loadFactor = loadFactor;
// 这里的赋值并不是用:tableSizeFor(initialCapacity) * loadFactor的结果
// 而是直接把tableSizeFor(initialCapacity)的值给threshold
// 这是因为在扩容的resize()方法中,有一行代码“newCap = oldThr;”,会把threshold赋值为数组的容量。而数组容量又必须是2的幂次,所以这里直接这么赋值
// “newCap = oldThr;”这行代码,只有在通过有参构造方法创建map时,并且在第一次进入resize()方法时才会执行到!!!
// 这个时候你可能会有个疑问,那这样扩容阈值不就跟数组容量大小一样了吗?
// 放心,在第一次扩容时,执行完“newCap = oldThr;”之后,就会对threshold重新赋值“threshold = newThr;”
this.threshold = tableSizeFor(initialCapacity);
}
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
public HashMap() {
// 其它所有都赋默认值
this.loadFactor = DEFAULT_LOAD_FACTOR;
}
核心方法
获取值:get
// 如果map中存在该key,则返回该key对应的值,否则返回null
// 思考一个问题:这里为什么不直接判断hash对应的桶是否为空,而是要调用getNode()方法来判断是否有值?
// 因为判断一个key是否存在,除了要hash对应的桶有值外,还要比较key的值是否相等(有可能出现hash冲突)
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
// 根据传入的hash值、key,返回对应的Node
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
// 计算桶的位置
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
// 检查第一个节点
if (first.hash == hash &&
((k = first.key) == key || (key != null && key.equals(k))))
return first;
// 如果不是第一个节点,检查下一节点
if ((e = first.next) != null) {
// 走红黑树分支查找
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
// 普通节点,走单向链表分支查找
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
添加键值对:put
// 添加键值对
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;
// 如果数组为空或者长度为0,则初始化
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 当前桶处于null状态,则直接创建一个node
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
// 当前桶上已经有node存在了,这个存在的node为:p
// 接下来就是顺着指针依次查找,如果能找到key相等的,就更新;否则就在尾部新建出待添加的节点(尾插)
else {
// e:为查找到的节点,也就是e的key与待添加节点的key是相等的(key已经存在了)
// 在底下的if ()...else if ()... else ()分支里,如果找到与待添加节点的key相等的,会把节点赋值给e(注意:这里只是赋值,并不会直接更新旧值,是否更新的逻辑在代码66行处)
Node<K,V> 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<K,V>)p).putTreeVal(this, tab, hash, key, value);
// 普通节点,走单向链表查找分支
else {
// 这个binCount为统计单向链表上节点个数的参数
// 注意:这里从0开始计算,并没有把p算进去,所以底下在判断链表节点个数是否超过转化为红黑树的阈值时,是用“TREEIFY_THRESHOLD - 1”来判断的
for (int binCount = 0; ; ++binCount) {
// 把p的next赋值给e
// 如果e为null,说明在链表上找不到key与待插入节点的key相等的
if ((e = p.next) == null) {
// 在p节点的后面插入一个新的节点(尾插)
p.next = newNode(hash, key, value, null);
// 判断节点个数是否大于等于转化为红黑树的阈值;如果是则执行转化为红黑树的逻辑;否则退出循环,直接到代码79行处
if (binCount >= TREEIFY_THRESHOLD - 1)
treeifyBin(tab, hash);
break;
}
// 检查当前的e节点,满足条件则退出循环,到代码66行处
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
// p继续往下查找
p = e;
}
}
// 待插入节点的key已经存在了
if (e != null) {
V oldValue = e.value;
// onlyIfAbsent:false:更新旧值,true:不更新旧值;可以看put()方法调用的地方,默认为false
// 或者当旧值为null时,也会更新
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
// 返回旧值(注意:这里直接返回了,并不会修改modCount的值,size也不会增加)
return oldValue;
}
}
++modCount;
// 如果键值对大小超过扩容阈值,则触发扩容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
扩容方法:resize()
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
// 旧的扩容阈值
// 当且仅当通过无参构造方法创建map时,且第一次进入该方法,threshold为:0
int oldThr = threshold;
int newCap, newThr = 0;
// 旧的数组容量大于0,说明不是第一次扩容
if (oldCap > 0) {
// 如果旧容量已经大于限制的最大容量时,直接把threshold赋Integer的最大值返回(无法再触发扩容)
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
// 赋值新的数组容量为旧的2倍
// 当这个条件不满足时,newThr = 0
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
// 当新的容量小于MAXIMUM_CAPACITY 且 旧的容量大于等于DEFAULT_INITIAL_CAPACITY时,才会将容量阈值翻倍
newThr = oldThr << 1;
}
// 这个分支,只有在通过有参构造方法创建map时,并且在第一次进入resize()方法时才会执行到
// 这时oldThr的值为tableSizeFor(n)的值,也就是确保了newCap的值为2的幂次
else if (oldThr > 0)
newCap = oldThr;
// 通过无参构造方法创建map,且第一次进入resize()方式时,使用默认值
else {
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
// 如果newThr为0,则赋值
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<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
// 新的table已经扩容好了,准备开始从旧数组迁移数据到新的数组
if (oldTab != null) {
// 从旧数组的第一个桶开始迁移
for (int j = 0; j < oldCap; ++j) {
// e为待迁移节点
Node<K,V> e;
// 如果当前桶上有节点存在,将该节点赋值给e
if ((e = oldTab[j]) != null) {
// 将旧数组这个位置上的桶赋值为null
oldTab[j] = null;
// e的下一节点为空,说明只有一个节点,直接在新的数组上存放
if (e.next == null)
// 扩容时为什么可以直接这么赋值?不用考虑这个桶上原本有没有值呢?
// 这跟扩容机制有关,在扩容的时候,旧桶上的所有节点,会按规律分别放在新数组中的两个桶中:一个下标与旧桶所在的下标一样;一个下标为旧桶所在的下标 + oldCap
newTab[e.hash & (newCap - 1)] = e;
// 走红黑树的分支
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
// 单向链表中的节点,按照一定规则,分成两个链表,分别挂到新数组中下标为:j、j + oldCap的桶中
// 新的两个链表中节点的顺序,会维持旧链表中的相对顺序
else {
// 下标为j的桶的头结点
Node<K,V> loHead = null, loTail = null;
// 下标为j + oldCap的桶的头结点
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> 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;
}
将链表转化为红黑树:treeifyBin
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
// 如果链表的长度大于等于TREEIFY_THRESHOLD,但是数组的长度小于MIN_TREEIFY_CAPACITY,则不会将链表转化为红黑树,而是触发扩容操作
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
// 将链表上的节点转化为红黑树节点,生成一条以hd为头的双向链表
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);
if ((tab[index] = hd) != null)
// 将双向链表转化为红黑树
hd.treeify(tab);
}
}
TreeNode<K,V> replacementTreeNode(Node<K,V> p, Node<K,V> next) {
return new TreeNode<>(p.hash, p.key, p.value, next);
}
删除key:remove
public V remove(Object key) {
Node<K,V> e;
return (e = removeNode(hash(key), key, null, false, true)) == null ?
null : e.value;
}
final Node<K,V> removeNode(int hash, Object key, Object value,
boolean matchValue, boolean movable) {
Node<K,V>[] tab; Node<K,V> p; int n, index;
if ((tab = table) != null && (n = tab.length) > 0 &&
(p = tab[index = (n - 1) & hash]) != null) {
Node<K,V> node = null, e; K k; V v;
// 检查待删除节点是否是第一个节点
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
node = p;
// 除了头结点还有其它节点
else if ((e = p.next) != null) {
// 走树的分支
if (p instanceof TreeNode)
node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
// 走普通节点的分支
else {
do {
if (e.hash == hash &&
((k = e.key) == key ||
(key != null && key.equals(k)))) {
node = e;
break;
}
p = e;
} while ((e = e.next) != null);
}
}
if (node != null && (!matchValue || (v = node.value) == value ||
(value != null && value.equals(v)))) {
if (node instanceof TreeNode)
((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
else if (node == p)
tab[index] = node.next;
else
p.next = node.next;
++modCount;
--size;
afterNodeRemoval(node);
return node;
}
}
return null;
}
四、案例演示
我们通过演示一个简单的常用案例,来深入学习HashMap的原理。
案例:使用HashMap存储用户信息
假设我们现在有一些用户的信息需要存储,分别为:
| 用户ID | 用户名 |
|---|---|
| 101 | 张伟 |
| 102 | 李娜 |
| 103 | 王磊 |
| 104 | 刘洋 |
| 1 | 陈芳 |
| 17 | 赵强 |
| 33 | 黄婷 |
| 49 | 周杰 |
| 65 | 吴丽 |
| 81 | 孙浩 |
| 97 | 杨静 |
| 113 | 马俊 |
演示代码如下:
public static void main(String[] args) {
// 默认初始容量大小:16
// 默认负载因子:0.75
Map<Integer, String> userInfoMap = new HashMap<>();
userInfoMap.put(101, "张伟");
userInfoMap.put(102, "李娜");
userInfoMap.put(103, "王磊");
userInfoMap.put(104, "刘洋");
userInfoMap.put(1, "陈芳");
userInfoMap.put(17, "赵强");
userInfoMap.put(33, "黄婷");
userInfoMap.put(49, "周杰");
userInfoMap.put(65, "吴丽");
userInfoMap.put(81, "孙浩");
userInfoMap.put(97, "杨静");
userInfoMap.put(113, "马俊");
System.out.println(userInfoMap);
}
1.创建一个新的 HashMap
代码第4行Map<Integer, String> userInfoMap = new HashMap<>();,通过无参构造方法创建了一个新的HashMap。
2.将(101,"张伟")放入map中
代码第5行userInfoMap.put(101, "张伟");,调用 put 方法,将“张伟”的相关信息存入map。
(1)计算101的hash值
查看HashMap的hash()方法:
由代码可见,
HashMap 在计算hash值时,是先调用 key 的hashCode()方法,获取到对应的hashCode,再将该hashCode无符号右移16位后,与hashCode参与异或计算。
查看Integer类型的hashCode方法:
可以看到,
Integer.hashCode()方法返回的就是对象本身的int值。
所以,计算
101的hash值结果为:0000 0000 0000 0000 0000 0000 0110 0101。
(2)初始化哈希表
因为创建完userInfoMap后,这个对象还没有使用过,哈希表结构还是null的,所以要先执行扩容方法:
执行完resize()方法后,此时的哈希表状态为:
(3)计算101所在桶的下标
上面的步骤算出了101的hash值,接下来就是计算当前hash值,应该对应到数组的哪个桶上。
计算hash对应的桶的下标代码为:(n - 1) & hash(n为数组的大小,也就是16)
计算结果为:0000 0000 0000 0000 0000 0000 0000 0101,也就是101应该在数组下标为5的桶上
(4)将(101,"张伟")放到数组中下标为5的桶上
此时哈希表状态为:
3.按照第2步,依次将(102,"李娜")、(103,"王磊")、(104,"刘洋")、(1,"陈芳")放入map中
此时哈希表状态为:
4.将(17,"赵强")放入map中
通过计算,可以得到17的hash值为0000 0000 0000 0000 0000 0000 0001 0001
计算下标:
也就是(17,"赵强")应该存在下标为
1的桶上,而这个时候下标为1的桶上已经存在节点了,但是这个时候链表上的节点只有1个,所以(17,"赵强")应该存在(1,"陈芳")的下一个节点
此时哈希表状态为:
我们也可以通过查看断点验证这一个现象:
5.依次将剩下的6个人的信息放入map中
此时哈希表状态为:
再次通过断点验证下当前的哈希表结构:
案例总结
上面演示了HashMap如何计算hash值,如何计算桶的下标,展示了常规的插入,以及如何解决hash碰撞等常见操作。
数组扩容、链表转化为红黑树、红黑树退化为链表这些操作,读者有兴趣可以自己通过实验去验证下。在下面的章节我也会通过以面试题的形式来回答这些问题。
五、HashMap 常见面试题
1、哈希表的大小为什么必须是2的幂次?
这个问题需要从 HashMap 是如何计算hash值对应桶的方法来看。
假设有一个大小为10的数组,现在有几个数据要放到数组中,分别为10/15/31/42,这个时候你会怎么做呢?
最简单的方式就是:用待存放的数据模上数组的大小,比如10 % 10 = 0,所以应该把10放到数组中下标为0的位置上。同理15 % 10 = 5,应该把15放到数组下标为5的位置上。
现在回头来看下 HashMap 是怎么做的:(n - 1) & hash,也就是用数组的大小减1之后再与上hash值。假设n的大小为16,减1之后为15,二进制形式为:1111。现在把10放到这个数组中,10的二进制形式为:1010,下标就为:1111 & 1010,结果是10,所以应该把10放到数组下标为10的位置上。用模的形式计算一下:10 % 16 = 10,结果是一样的。
为什么 HashMap 要选择这种方式来实现呢?因为位运算比%运算符速度快多了,要求性能更好。所以就要求哈希表的大小必须是2的幂次,为了方便下标计算。
2、HashMap的MAXIMUM_CAPACITY为什么是 1 << 30?
这个问题也就是HashMap的最大容量为什么是1073741824(1 << 30实际的值)?
首先,可以看到这个变量是
int类型的,而int类型是32位的。Java中int是有符号数,所以最高位为符号位,如果符号位为1,就表示这个数是负数。
可以看到,如果再左移1位的话,这个值就变成负数-2147483648了。并且数组的容量必须是2的幂次,也就是说int的32位中,只能有一个位置是1的。在数组扩容的时候,新容量为旧容量的2倍,代码为:newCap = oldCap << 1。
综上,因为数组容量是int类型的,并且这个值必须是2的幂次,所以HashMap的最大容量为1 << 30。
3、默认负载因子为什么是0.75?
先说说负载因子的作用:假设现在有一个哈希表,数组长度为16,负载因子为0.75,那么当哈希表内包含的键值对大于12(16 * 0.75 = 12)时,就会发生扩容操作。
注意:12是指包含的键值对个数,而不是数组实际上有12个位置被使用了,有可能因为hash冲突,实际上被使用到的桶并没有那么多。
知道了负载因子的作用后,那为什么默认值是0.75呢?
如果负载因子为1.0的话,会使哈希表中的桶填充的更多,增加内存利用率,但是增加了哈希冲突的可能性,从而降低查询和插入的性能。
如果负载因子为0.5的话,那么会有更多的空闲桶,但是会减少哈希冲突,提高查询性能。
总的来说,默认负载因子0.75在大多数情况下能够提供较好的性能和内存使用效率,是一种通用的、合理的默认选择。
4、为什么链表改为红黑树的阈值为8?
先说说红黑树节点与常规节点的区别:
- 内存:红黑树节点的大小一般是常规节点大小的2倍。
- 查找效率:链表复杂度O(n),红黑树复杂度O(log n)。
出于性能和空间效率上的考量,所以只有当某个桶上的节点足够多的时候,才会将链表转化为红黑树,这个值就是要讨论的阈值。另外,由于删除节点或者扩容操作,当节点数量减少到UNTREEIFY_THRESHOLD的时候,红黑树会转化为链表。
从 HashMap 的注释中可以看到,“在使用分布良好的用户哈希码时,很少使用树容器。理想情况下,在随机哈希码下,容器中节点的频率遵循泊松分布”:
- 0: 0.60653066
- 1: 0.30326533
- 2: 0.07581633
- 3: 0.01263606
- 4: 0.00157952
- 5: 0.00015795
- 6: 0.00001316
- 7: 0.00000094
- 8: 0.00000006
- 更多:小于千万分之一
所以,当链表中的长度达到8个及以上时,概率已经足够小了,这个时候将常规节点转化为红黑树节点,既能保证查询效率好,又能提高空间利用率。
5、计算hash时,为什么要异或上hash值的高16位?
通过一个例子来看一下:
假设现在要将(101,"张三")、(196709,"李四")放到一个大小为16的哈希表中。分别看下有右移16位与没有右移16位的情况:
可以看到,有右移情况的下标为:
- 101:
0000 0000 0000 0000 0000 0000 0000 0101,也就是下标为5 - 196709:
0000 0000 0000 0000 0000 0000 0000 0110,也就是下标为6
没有右移情况的下标为:
- 101:
0000 0000 0000 0000 0000 0000 0000 0101,下标为5 - 196709:
0000 0000 0000 0000 0000 0000 0000 0101,下标为5
两种计算方式,得到的下标不一样。我们在实际使用场景中,大部分情况下的哈希表容量都是比较小的,当数组长度很短时,只有低16位的hashCode可以参与下标运算。而int类型是4个字节的,在计算hash时,让低16位与高16位一起参与运算,能够在一定程度上减少hash碰撞。
6、tableSizeFor()方法的实现原理
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;
}
这个方法的作用就是返回一个大于或者等于给定目标容量的2的幂次方。
同样,通过一个例子来说明。假设在构造方法中,我们传入的参数为65538,这个时候计算过程就是:
代码第7行执行完以后,
n的值就为0000 0000 0000 0001 1111 1111 1111 1111,也就是十进制的131071。在结果返回之前,判断n是否小于0,如果小于0,则返回1;否则就判断n是否超过最大值,如果没有超过最大值,就返回n + 1的值,也就是131071 + 1为131072(二进制为0000 0000 0000 0010 0000 0000 0000 0000)。
这个方法的实现原理就是:目标容量cap二进制中最高位的1,通过移位,再与原本的值相或,重复执行几次,将最高的1扩散到低位中,通过位运算快速获得2的幂次。
最开始的时候为什么要减1呢?这是为了避免一开始传入的参数就刚好是2的幂次,比如目标容量cap传入的是16,一开始不减1的话,就会返回32。所以在一开始减1,在返回结果的时候再把1加上就能避免这个问题。
7、为什么getNode方法在判断key是否相等时,总要先比较一下hash值?
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
// 这里总是会先比较hash是否相等
if (first.hash == hash &&
((k = first.key) == key || (key != null && key.equals(k))))
return first;
if ((e = first.next) != null) {
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
因为同一个桶上,有可能因为哈希碰撞,不同的hash值会落到同一个桶上(可以看看上面的案例演示)。在查找的时候,如果hash值不同,就没有必要再判断当前节点的key是否相等了,直接继续往下查询就好了。先判断hash值而不是直接比较key,是因为hash是int类型的,在比较时速度更快,性能更好,不需要调用equals方法去判断。
其实这一个判断的代码,在 HashMap 中查找节点的地方都有用到,并不是只在getNode这个方法里有。
8、数组是怎么扩容的?
数组扩容可以分两个大步骤来说:
- 新数组的创建
- 旧数组数据迁移到新数组
新数组创建
先说下数组扩容触发的时刻:
- 当元素个数超过扩容阈值的时候
- 当使用哈希表时,哈希表还未初始化过
- 当产生哈希冲突,单个桶上链表长度大于8,且数组容量小于64的时候(
treeifyBin方法中)
第1种情况
数组之前已经初始化过了,这个时候元素个数达到了扩容阈值。
- 当旧数组容量大于等于哈希表限制的数组最大容量时,不再扩容,但是会把扩容阈值设置为
Integer的最大值,直接返回,让哈希表下次不会再达到扩容阈值。 - 当数组还能继续扩容时,新数组的长度为原来旧数组的2倍;当新数组长度小于
MAXIMUM_CAPACITY且旧的数组长度大于等于DEFAULT_INITIAL_CAPACITY时,会将新的扩容阈值翻倍;否则,新的扩容阈值为新的容量 * 负载因子(如果新的容量大于等于MAXIMUM_CAPACITY,则扩容阈值为Integer的最大值)。
第2种情况
当前数组还未初始化时,这个时候会初始化数组。
- 通过有参构造方法创建的map,此时扩容阈值是有值的,代码为
this.threshold = tableSizeFor(initialCapacity)。新数组的长度就为该值,扩容阈值会被重新赋值为新的容量 * 负载因子。 - 通过无参构造方法创建map时,新的数组容量为
DEFAULT_INITIAL_CAPACITY,扩容阈值为(int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY)。
第3种情况
这种情况也是属于第1种情况的,只是触发点不一样。扩容流程跟第1种情况是一样的。
到这,我们明白了新数组是怎么创建的,接下来看看,旧数据怎么迁移。
数据迁移
数据迁移的流程是:
- 从数组下标0开始,遍历旧数组的每一个桶
- 遍历到一个桶的时候,会从第一个节点开始,从头到尾遍历往下的每一个节点
通过一个例子来看一下。用上面的案例演示那个例子:
此时哈希表大小为16,当前已经有12个键值对了,继续添加下一个节点的话,就会触发扩容(
12 + 1 = 13 > 12(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY))。
现在,把(109,"张三")加入 map 中,此时状态为:
可以看到,此时(109,"张三")被放在了下标为
13的桶上,数组并还未扩容。通过断点验证下:
当节点插入后,在方法返回前,才会判断是否应该扩容(可以看看上面关于
put的代码)。扩容后状态为:
这个扩容的下标计算挺有意思的,读者有没有发现,除了
1号桶、17号桶之外,其它桶的位置、数据都不变。
这也跟下标的计算方式有关。旧数组的容量为16,计算下标时n - 1的二进制为:0000 0000 0000 0000 0000 0000 0000 1111,而新数组的容量为32,计算下标时n - 1的二进制为:0000 0000 0000 0000 0000 0000 0001 1111,差别只是第4位上的那个1。
所以扩容以后,新的下标要么跟旧数组的一样,要么就是旧数组的下标 + 旧数组的长度。
以上,就是关于数组扩容的大致流程,这里只是简单举了一个不包含红黑树的例子。其实包含红黑树的话,道理也是一样的,也是通过将红黑树节点分成两个链表,只不过这个链表的节点类型是树节点。然后判断链表的节点是否小于等于6(UNTREEIFY_THRESHOLD),如果小于6,则将红黑树结果转化为普通的链表结构。
9、说说put方法的流程
- 判断哈希表是否初始化过,如果没有初始化过(
null或者长度为0),则先初始化,调用扩容方法 - 计算
hash值对应的下标位置- 如果当前桶上没有节点,则直接创建一个节点出来
- 当前桶上有节点
- 判断当前
key是否是第一个节点 - 如果是红黑树节点,走红黑树的插入分支
- 普通节点,遍历当前链表,如果
key不存在,新增节点,并且判断是否需要转成红黑树;否则继续往下查找。 - 如果
key之前存在,则判断是否更新旧值
- 判断当前
- 判断是否需要扩容,如果需要,则扩容
- 结束
10、链表什么时候会转化为红黑树?
当往map中不断插入键值对时,此时如果某个桶上的链表长度超过了8(TREEIFY_THRESHOLD),就会调用转化为红黑树的方法treeifyBin。
但是调用了该方法后,不一定会将链表马上转化为红黑树,只有当数组长度大于等于MIN_TREEIFY_CAPACITY(64)时,才会转化为红黑树。否则只是会触发扩容。
通过代码验证下:
public static void main(String[] args) {
// 默认初始容量大小:16
// 默认负载因子:0.75
Map<Integer, String> userInfoMap = new HashMap<>();
userInfoMap.put(101, "张伟");
userInfoMap.put(102, "李娜");
userInfoMap.put(1, "陈芳");
userInfoMap.put(65, "赵强");
userInfoMap.put(129, "黄婷");
userInfoMap.put(193, "周杰");
userInfoMap.put(257, "吴丽");
userInfoMap.put(321, "孙浩");
userInfoMap.put(385, "杨静");
userInfoMap.put(449, "马俊");
userInfoMap.put(513, "张三");
System.out.println(userInfoMap);
}
当执行完代码第14行时,也就是插入(449, "马俊")后,状态为:
此时下标为1的桶上,链表长度已经为8了,这个时候继续插入(513, "张三"),状态为:
可以看到,链表确实没有变成红黑树,仅仅只是扩容了而已。而且在这个例子中,我特意把
key设置成比较特殊的,让这些冲突的节点还是保持在同一个桶上。这让我验证了另外一个现象:HashMap 中的链表的长度有可能大于8。
现在,仅仅修改一行代码,把无参构造方法换成有参的,指定初始容量大小为64,其余都不变:
Map<Integer, String> userInfoMap = new HashMap<>(64, 0.75F);
执行完代码后,状态为:
综上,只有当哈希表长度大于等于64时,且单个桶的链表长度大于8时,才会把链表改为红黑树。
11、红黑树什么时候会变回链表?
红黑树变回链表的时机:
- 删除节点的时候
- 哈希表扩容的时候
第一种情况:在删除键值对的时候,如果节点类型为树节点,且满足一定条件的时候(头结点为空或者头结点的左右节点有一个为空等),就会把红黑树转为链表。
第二种情况,在数组扩容那里有提到过,当扩容后,如果桶上原本是红黑树的话,会分成两个节点为树的链表,然后判断链表长度是否小于等于6。如果小于等于6的话,就会转为链表。
六、总结
这一次算是很完整的看完了 HashMap 的源码,并且也通过代码调试,亲自验证了相关的逻辑。针对核心代码,每一行都去试着理解。读完以后,收获很大。
本文版权归 [小杰森] 所有,未经授权禁止转载、修改及商业使用。