HashMap源码分析
序号 | 内容 | 链接地址 |
---|---|---|
1 | HashMap的继承体系,HashMap的内部类,成员变量 | https://blog.csdn.net/weixin_44141495/article/details/108327490 |
2 | HashMap的常见方法的实现流程 | https://blog.csdn.net/weixin_44141495/article/details/108329558 |
3 | HashMap的一些特定算法,常量的分析 | https://blog.csdn.net/weixin_44141495/article/details/108305494 |
4 | HashMap的线程安全问题(1.7和1.8) | https://blog.csdn.net/weixin_44141495/article/details/108250160 |
5 | HashMap的线程安全问题解决方案 | https://blog.csdn.net/weixin_44141495/article/details/108420327 |
6 | Map的四种遍历方式,以及删除操作 | https://blog.csdn.net/weixin_44141495/article/details/108329525 |
7 | HashMap1.7和1.8的区别 | https://blog.csdn.net/weixin_44141495/article/details/108402128 |
文章目录
HashMap的常见方法的实现流程
HashMap的常见方法的实现流程
Map接口常见方法
我们先看一下Map接口的方法(常见方法)
增删改相关
返回值 | 方法名 | 描述 |
---|---|---|
V | put(K key, V value) | 将指定的值与该映射中的指定键相关联(可选操作)。 |
void | putAll(Map<? extends K,? extends V> m) | 将指定地图的所有映射复制到此映射(可选操作)。 |
V | remove(Object key) | 如果存在(从可选的操作),从该地图中删除一个键的映射。 |
default boolean | remove(Object key, Object value) | 仅当指定的密钥当前映射到指定的值时删除该条目。 |
default V | replace(K key, V value) | 只有当目标映射到某个值时,才能替换指定键的条目。 |
default boolean | replace(K key, V oldValue, V newValue) | 仅当当前映射到指定的值时,才能替换指定键的条目。 |
default void | replaceAll(BiFunction<? super K,? super V,? extends V> function) | 将每个条目的值替换为对该条目调用给定函数的结果,直到所有条目都被处理或该函数抛出异常。 |
查询相关
返回值 | 方法名 | 描述 |
---|---|---|
int | hashCode() | 返回此地图的哈希码值。 |
int | size() | 返回此地图中键值映射的数量。 |
Collection<V> | values() | 返回此地图中包含的值的Collection视图。 |
boolean | isEmpty() | 如果此地图不包含键值映射,则返回 true 。 |
V | get(Object key) | 返回到指定键所映射的值,或 null 如果此映射包含该键的映射。 |
boolean | containsKey(Object key) | 如果此映射包含指定键的映射,则返回 true 。 |
boolean | containsValue(Object value) | 如果此地图将一个或多个键映射到指定的值,则返回 true 。 |
比较相关
返回值 | 方法名 | 描述 |
---|---|---|
boolean | equals(Object o) | 将指定的对象与此映射进行比较以获得相等性。 |
转换相关
返回值 | 方法名 | 描述 |
---|---|---|
Set<K> | keySet() | 返回此地图中包含的键的Set视图。 |
Set<Map.Entry<K,V>> | entrySet() | 返回此地图中包含的映射的Set 视图。 |
HashMap是AbstractMap的子类,AbstractMap以及帮我们写好了很多的方法。
HashMap的常用方法(面向面试题)
Hash算法
我的这篇博文:链接,有对这个算法的讲解。简单的说是保留了高位的性质,这也是面试常问的。
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
put
我们看到我们在put的时候,用hash算法计算了hash值,将hash,key,value和两个布尔值传入putVal方法
putVal
前三个我们明白的,我们看一下后面两个在HashMap的其他调用场景这两个布尔值分别是什么?
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
这是构造时传入一个Map容器,这里evict传入了false,onlyIfAbsent传入了true;
public HashMap(Map<? extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
putMapEntries(m, false);
}
final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
......
putVal(hash(key), key, value, false, evict);
}
这是putIfAbsent方法,这里都传入了true,我们发现这个参数的名字和这个方法的名字一致,这个方法大致一致是,如果没有这个值,就插入。这个方法应用场景也很广,有时候我们并不是需要覆盖key-value,可以使用这个方法,这样我们就不用去contains判断,再去执行添加操作
@Override
public V putIfAbsent(K key, V value) {
return putVal(hash(key), key, value, true, true);
}
readObject方法,是将流数据读取到Map中。这个方法是HashMap新增的,如果我们用Map<> map=new HashMap<>();的方式创建Map实例,我们就调用不了这个方法了!不过一般情况下,没有特殊需求,我们的接口指向子类的实例化方法没毛病!
private void readObject(java.io.ObjectInputStream s)
throws IOException, ClassNotFoundException {
.......
putVal(hash(key), key, value, false, false);
}
}
}
我们对比一下
方法名 | putIfAbsent | evict |
---|---|---|
putMapEntries | false | false |
put | false | true |
putIfAbsent | true | true |
readObject | false | false |
结论
putIfAbsent表示我们是否替代原值。
evict表示是否是创建过程,因为Map构造和readObject都是put已经写好的元素了!
putVal源码
看看就好,我下面花了一个流程图,方便理解。
简单概括一下:
-
①.判断键值对数组table[i]是否为空或为null,否则执行resize()进行扩容;
-
②.根据键值key计算hash值得到插入的数组索引i,如果table[i]==null,直接新建节点添加,转向⑥,如果 table[i]不为空,转向③;
-
③.判断table[i]的首个元素是否和key一样,如果相同直接覆盖value,否则转向④,这里的相同指的是hashCode以及equals;
-
④.判断table[i] 是否为treeNode,即table[i] 是否是红黑树,如果是红黑树,则直接在树中插入键值对,否则转向⑤;
-
⑤.遍历table[i],判断链表长度是否大于8,大于8的话把链表转换为红黑树,在红黑树中执行插入操作,否则进行链表的插入操作;遍历过程中若发现key已经存在直接覆盖value即可;
-
⑥.插入成功后,判断实际存在的键值对数量size是否超多了最大容量threshold,如果超过,进行扩容。
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;
//如果对应的位置还没有插入元素
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
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 {
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
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;
}
}
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
与Jdk1.7相比有以下特性
- 加入红黑树
- 初始化方法也是resize()扩容
resize方法
代码太长了,看看就好,多结合源码分析,也可以试着看看能不能独立的写出注释。
简单概括:
-
①.在jdk1.8中,resize方法是在hashmap中的键值对大于阀值时或者初始化时,就调用resize方法进行扩容;
-
②.每次扩展的时候,都是扩展2倍;
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;//oldTab指向hash桶数组
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
if (oldCap > 0) {//如果oldCap不为空的话,就是hash桶数组不为空
if (oldCap >= MAXIMUM_CAPACITY) {//如果大于最大容量了,就赋值为整数最大的阀值
threshold = Integer.MAX_VALUE;
return oldTab;//返回
}//如果当前hash桶数组的长度在扩容后仍然小于最大容量 并且oldCap大于默认值16
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold 双倍扩容阀值threshold
}
// 旧的容量为0,但threshold大于零,代表有参构造有cap传入,threshold已经被初始化成最小2的n次幂
// 直接将该值赋给新的容量
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
// 无参构造创建的map,给出默认容量和threshold 16, 16*0.75
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
// 新的threshold = 新的cap * 0.75
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
// 计算出新的数组长度后赋给当前成员变量table
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];//新建hash桶数组
table = newTab;//将新数组的值复制给旧的hash桶数组
// 如果原先的数组没有初始化,那么resize的初始化工作到此结束,否则进入扩容元素重排逻辑,使其均匀的分散
if (oldTab != null) {
// 遍历新数组的所有桶下标
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
// 旧数组的桶下标赋给临时变量e,并且解除旧数组中的引用,否则就数组无法被GC回收
oldTab[j] = null;
// 如果e.next==null,代表桶中就一个元素,不存在链表或者红黑树
if (e.next == null)
// 用同样的hash映射算法把该元素加入新的数组
newTab[e.hash & (newCap - 1)] = e;
// 如果e是TreeNode并且e.next!=null,那么处理树中元素的重排
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
// e是链表的头并且e.next!=null,那么处理链表中元素重排
else { // preserve order
// loHead,loTail 代表扩容后不用变换下标,见注1
Node<K,V> loHead = null, loTail = null;
// hiHead,hiTail 代表扩容后变换下标,见注1
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
// 遍历链表
do {
next = e.next;
if ((e.hash & oldCap) == 0) {
if (loTail == null)
// 初始化head指向链表当前元素e,e不一定是链表的第一个元素,初始化后loHead
// 代表下标保持不变的链表的头元素
loHead = e;
else
// loTail.next指向当前e
loTail.next = e;
// loTail指向当前的元素e
// 初始化后,loTail和loHead指向相同的内存,所以当loTail.next指向下一个元素时,
// 底层数组中的元素的next引用也相应发生变化,造成lowHead.next.next.....
// 跟随loTail同步,使得lowHead可以链接到所有属于该链表的元素。
loTail = e;
}
else {
if (hiTail == null)
// 初始化head指向链表当前元素e, 初始化后hiHead代表下标更改的链表头元素
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
// 遍历结束, 将tail指向null,并把链表头放入新数组的相应下标,形成新的映射。
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
与Jdk1.7相比
-
jdk1.7是扩容完重新计算hash来计算插入位置,而1.8对此进行了优化。扩展后Node对象的位置要么在原位置,要么移动到原偏移量两倍的位置。
-
对比1.7多了一些树化的流程,也减少了方法嵌套,基本上一个方法就搞定了扩容,不过阅读性也比较差。
树化和反树化
大家可以看这一篇,对红黑树树化分析比较全面。https://blog.csdn.net/wildyuhao/article/details/108272239
remove方法
如果删除成功我们返回删除的值value,否则返回null,这个操作非常好用,有时候我们取出来后需要把数据删除,可以应用到转移元素上。
我们看到删除方法也有两个布尔类型的参数,matchValue和movable,我们看看HashMap哪边调用了removeNode方法,和之前putVal的方式一样,以此得到布尔值的含义。
computeIfPresent
-
computeIfAbsent的方法有两个参数 第一个是所选map的key,第二个是需要做的操作。这个方法当key值不存在时才起作用。
-
当key存在返回当前value值,不存在执行函数并保存到map中
public V computeIfPresent(K key,BiFunction<? super K, ? super V, ? extends V> remappingFunction) {
......
removeNode(hash, key, null, false, true);
}
keySet
HashMap有转换为Set的操作,这里也调用了删除方法,布尔值和remove方法一致
final class KeySet extends AbstractSet<K> {
public final boolean remove(Object key) {
......
return removeNode(hash(key), key, null, false, true) != null;
}
}
EntrySet
HashMap有转换为Entry<K,V>集合的操作,这里也调用了删除方法,matchValue为true
final class EntrySet extends AbstractSet<Map.Entry<K,V>> {
public final boolean remove(Object o) {
...
return removeNode(hash(key), key, value, true, true) != null;
}
HashIterator
Hash迭代器,这里的remove方法也调用了removeNode,这里两个布尔值都是false
abstract class HashIterator {
public final void remove() {
……
removeNode(hash(key), key, null, false, false);
}
}
初此以外还有很多地方也调用了removeNode,大部分都是 false,true
对比
方法名 | matchValue | movable |
---|---|---|
remove | false | true |
keySet.remove | false | true |
EntrySet.remove | true | true |
HashIterator.remove | false | false |
区别
matchValue:
- 我们发现只有EntrySet中是传入了value,所以matchValue表示删除时是否关心value的值,即是否 equals(value),其他方法都是按照key删除,所以传入的是false
movable:
- 我们知道,我们循环删除HashMap的元素,可能会出现
java.util.ConcurrentModificationException: null
,这里引入一个优秀的链接[HashMap遍历删除时遇见的坑!!!](java.util.ConcurrentModificationException: null),和循环遍历删除ArrayList是一个道理。 - 如果是movable=false,那么就不要移动节点,所以我们循环遍历,删除元素,Java推荐我们使用迭代器。
removeNode方法
对比前面几个70~80行的代码,这个算短了。
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;
//这个p在上面的if里面赋值了,如果当前值等于我们我们要删除的值
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
//标记我们要删除的值
node = p;
//如果如果当前位置不等于我们我们要删除的值,说明要删除的值可能在这个node的后面
else if ((e = p.next) != null) {
//如果我们要删除的key对应桶的位置是个树节点
if (p instanceof TreeNode)
//调用树的删除方法
node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
else {
//循环判断,直至e等于null
do {
if (e.hash == hash &&
((k = e.key) == key ||
(key != null && key.equals(k)))) {
node = e;
break;
}
p = e;
} while ((e = e.next) != null);
}
}
//如果我们之前的node不为空,说明找到了要删除的元素
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;
}
get方法
相对于remove,我们是拿而不取。实现也比较简单
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
总结
- HashMap中:removeNode,resize,removeTreeNode,putTreeVal只要这几个方法超过60行的,其他的方法都没这么长,吃透这几个方法,也就吃透了HashMap的一半,当然还有putVal。
- HashMap除了树化以外,很多地方都进行了优化,尤其是方法都偏向于集中了,而不是各种套娃,估计换了一个很有实力的的团队。
- HashMap的代码行数是1.7的一倍以上,阅读源码,我们发现HashMap提供了多种转换方式以及内部类,比如keySet,EntrySet,HashIterator等,这里我们要灵活运用,我单独把HashMap的遍历方式整理到这篇博文每日面试题9:Map的四种遍历方式,以及删除操作;