Java基础之Map接口

262 阅读20分钟

说一下 HashMap 的实现原理?

HashMap 概述:HashMap 是基于哈希表的 Map 接口的非同步实现。此实现提供所有可选的映射操作,并允许使用 null 值和 null 键。此类不保证映射的顺序,特别是它不保证该顺序恒久不变。

HashMap 的数据结构:在 Java 编程语言中,基本的结构就是两种,一个是数组,另外一个是模拟指针(引用 )所有的数据结构都可以用这两个基本结构来构造的,HashMap 也不例外。HashMap 实际上是一个"链表散列"的数据结构,即数组和链表的结合体。

HashMap 基于 Hash 算法实现的

  1. 当我们往 Hashmap 中 put 元素时,利用 key 的 hashCode 重新 hash 计算当前对象的元素在数组中的下标
  2. 存储时,如果出现 hash 值相同的 key,此时有两种情况。
    • 如果 key 相同,则覆盖原始值
    • 如果 key 不同(出现冲突),则将当前的 key-value 放入链表中
  3. 获取时,直接找到 hash 值对应的下标,再进一步判断 key 是否相同,从而找到对应值。
  4. 理解了以上过程就不难明白 HashMap 时如何解决 hash 冲突的问题,核心就是使用了数组的存储方式,然后将冲突的 key 的对象放入链表中,一旦发现冲突就在链表中做进一步的对比。

需要注意 jdk 1.8 中对 HashMap 的实现做了优化,当链表中的节点数据超过八个之后,该链表就会转为红黑树来提高查询效率,从原来的O(n)到O(log n)

HashMap 在 JDK 1.7 和 JDK 1.8 中有哪些不同? HashMap 的底层实现?

在 Java 中,保存数据有两种比较简单的数据结构:数组和链表。

数组的特点是寻址容易,插入和删除困难。

链表的特点是寻址困难,但插入删除容易。

所以我们将数组和链表结合在一起,发挥两者各自的优势,使用一种叫做拉链发的方式可以解决哈希冲突。

JDK 1.8 之前

JDK 1.8 之前采用的是拉链法。

拉链法:将链表和数组向结合,也就是说创建一个链表数组,数组中每一格就是一个链表。若遇到哈希冲突,则将冲突的值加到链表中即可。

JDK 1.8 之后

相比于之前的版本,JDK 1.8 在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为8)时,将链表转化为红黑树,以减少搜索时间。

JDK 1.7 VS JDK 1.8 比较

JDK 1.8 主要解决或优化了以下问题:

  1. resize 扩容优化
  2. 引入了红黑树,目的时避免单条链表过长而影响查询效率
  3. 解决了多线程死循环问题,但仍是非线程安全的,多线程时可能会造成丢失问题。
public V put(K key V value){
return putVal(hash(key),key,value,false,true);
}

static final int hash(Object key){
int h;
return (key == null)? 0:(h = key.hashCode()) ^ (h >>> 16);
}

// 实现 Map.put 和相关方法
final V putVal(int hash,K key,V value,boolean onlyIfAbsent,boolean evict){
Node<K,V>[] tab;
Node<K,V> p;
int n,i;
// 步骤1:tab 为空则创建
// table 未初始化或者传唱度为0,进行扩容
if((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 步骤2: 计算 index,并对 null 做处理
// (n-1)& hash 确定元素存放在哪个桶中,桶为空,新生成节点放入桶中(此时,这个节点是放在数组中)
if((p = tab[i = (n-1) & hash]) == null)
tab[i] = newNode(hash,key,value,null);
// 桶中已经存在元素
else{
Node<K,V> e;
K k;
// 步骤3:节点 key 存在,直接覆盖 value
// 比较桶中第一个元素(数组中的节点)的 hash 值相等,key 相等
if(p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
// 将第一个元素赋值给 e,用 e 来记录
e = p;
// 步骤4:判断该链为红黑树
// hash 值不相等,即 key 不相等:红黑树节点
// 如果当前元素类型为 TreeNode,表示为红黑树,putTreeVal 返回存放的 node,e 可能为 null
else if(p instanceof TreeNode)
// 放入树中
e = ((TreeNode<K,V>)p).putTreeVal(this.tab.hash,key,value);
// 步骤5:该链为链表
// 为链表节点
else{
// 在链表最末插入节点
for(int binCount = 0;;++binCount){
// 到达链表的尾部
// 判断该链表尾部指针是不是空的
if((e = p.next) == null){
// 在尾部插入新结点
p.next = newNode(hash,key,value,null);
// 判断链表的长度是否达到转化红黑树的临界值,临界值为8
if(binCount >= TREEIFY_THRESHOLD -1)// -1 for 1st
// 链表结构转树形结构
treeifyBin(tab,hash);
// 跳出循环
break;
}
// 判断链表中的节点的 key 值与插入的元素的key值是否相等
if(e.hash == hash && (k = e.key) == key || (key != null && key.equals(k))))
// 相等,跳出循环
break// 用于遍历桶中的链表,与前面的 e = p.next 组合,可以遍历链表
p = e;
}
}
// 判断当前的 key 已经存在的情况下,再来一个相同的 hash 值、key 值时,返回新来的 value 这个值
if(e != null){
// 记录 e 的 value
V oldValue = e.value;
// onlyIfAbsent 为 false 或者旧值为 null
if(!onlyIfAbsent || oldValue == null)
// 用新值来替换旧值
e.value = value;
// 访问后回调
afterNodeAccess(e);
// 返回旧值
return oldValue;
}
}
// 结构性修改
++ modCount;
// 步骤5:超过最大容量就扩容
// 实际大小大于阈值则扩容
if(++size > threshold)
resize();
// 插入后回调
afterNodeInsertion(evict);
return null;
}
  1. 判断键值对数组 table[i] 是否为空或为 null,否则执行 resize() 进行扩容;
  2. 根据键值 key 计算 hash 值得到插入数组索引 i,如果 table[i] == null,直接新建节点添加,转向 6,如果 table[i] 不为空,转向 3
  3. 判断 table[i] 的首个元素是否和 key 一样,如果相同直接覆盖 value,否则转向4,这里的相同指的是 hashCode 以及 equals;
  4. 判断 table[i] 是否为 treeNode,即 table[i] 是否是红黑树,如果是红黑树,则直接在树中插入键值对,否则转向5;
  5. 遍历 table[i] ,判断链表长度是否大于 8,大于 8 的化把链表转换为红黑树,在红黑树中执行插入操作,否则进行链表的插入操作;遍历过程中若发现 key 已经存在直接覆盖 value 即可;
  6. 插入成功后,判断实际存在的键值对数量 size 是否超多了大容量 threshold,如果超过,进行扩容。

HashMap 的扩容操作是怎么实现的?

  1. 在 JDK 1.8 中,resize 方法实在 hashMap 中的键值对大于阈值时或者初始化时,就调用 resize 方法进行扩容;
  2. 每次扩展的时候,都是扩展 2 倍
  3. 扩展后 Node 对象的位置要么在原位置,要么移动到原偏移量两倍的位置。在 putVal() 中,我们看到在这个函数里面使用到了 2 次 resize()方法,resize() 方法表示的在进行第一次初始化时会对其进行扩容,或者当该数组的实际大小大于临界值(第一次为12),这个时候在扩容的同时也会伴随的在桶上面的元素进行重新分发,这也是 JDK 1.8 版本的一个优化的地方,在 JDK 1.7 中,扩容之后需要重新去计算其 Hash 值,根据 Hash 值对其进行分发,但在 1.8 版本中,则是根据在同一个桶的位置中进行判断(e.hash & oldCap)是否为 0,重新进行 hahs 分配后,该元素的位置要么停留在原始位置,要么移动到原始位置+增加的数组大小这个位置上
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,hiTail 代表扩容后变换下标
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;
}
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;
}

HashMap 是怎么解决哈希冲突的?

答:在解决这个问题之前,我们首选需要知道什么是哈希冲突,而在了解哈希冲突之前我们还需要知道什么是哈希才行,什么是哈希?Hash,一般翻译为“散列”,也有直接音译为“哈希”的,这就是把任意长度的输入通过散列算法,变换成固定长度的输出,该输出就是散列值(哈希值);这种转换是一种压缩映射,也就是,散列值的空间通常远小于输入空间,不同的输入可能会散列形成相同的输出,所以不可能从散列值来唯一的确定输入值。简单的说就是一种将任意长度的消息压缩到某一固定长度的消息摘要的函数。

所有散列函数都有如下一个基本特性:根据同一散列函数计算出的散列值如果不同,那么输入肯定也不同。但是,根据同一散列函数计算出的散列值如果相同,输入值不一定相同。

什么是哈希冲突?

当两个不同的输入值,根据统一散列函数计算出相同的散列值的现象,我们就把他叫做碰撞(哈希碰撞)。

HashMap的数据结构

在 Java 中,保存数据有两种比较简单的数据结构:数组和链表。

数组的特点是: 寻址容易,插入和删除困难。

链表的特点是: 寻址困难,但插入和删除容易。

所以我们将数组和链表结合在一起,发挥两者各自的优势,使用一种叫做链地址法的方式可以解决哈希冲突。这样我们就可以将拥有相同哈希值的对象(img)组织成一个链表放在 hash 值所对应的 bucket 下,但相比于 hashCode 返回的 int 类型,我们 HashMap 初始的容量大小 DEFAULT_INITIAL_CAPACITY = 1 << 4 (即 2 的四次方 16)要远小于 int 类型的范围,所以我们如果只是单纯的用 hashCode 取余来获取对应的 bucket 这将会大大增加哈希碰撞的概率,并且最坏情况下还会将 HashMap 变成一个单链表,所以我们还需要对 hashCode 作一定的优化 hash()函数

上面提到的问题,主要是因为使用 hashCode 取余,那么相当于参与运算的只有 hashCode 的低位,高位是没有任何作用的,所以我们的思路就是让 hashCode 取值出的高位也参与原酸,进一步降低 hash 碰撞的概率,使得数据分布更平均,我们把这样的操作称为扰动,在 JDK 1.8 中的 hash()函数如下:

static final int hash(Object key){
int h;
return (key == null)? 0: (h = key.hashCode()) ^ (h >>> 16); //与自己右移 16 位进行异或运算(高低为异或)
}

这比在 JDK 1.7 中,更为简洁,相比在 1.7 中的4次位运算,5次异或运算(9次扰动),在 1.8 中,只进行了 1 次位运算和 1次异或运算(2次扰动)

通过上面的连地址法(使用散列表)和扰动函数我们成功让数据分布更平均,哈希碰撞减少,但是当我们的 HashMap 中存在大连数据时,加入我们某个 bucket 下对应的链表有 n 个元素,那么遍历实践复杂度就为O(n),为了针对这个问题,JDK 1.8 在 HashMap 中新增了红黑树的数据结构,进一步使的遍历复杂度降低至O(log n)

总结:

  1. 使用链地址法(使用散列表)来链接拥有相同 hash 值的数据
  2. 使用 2 次扰动函数(hash 函数)来降低哈希冲突的概率,是的数据分布更平均
  3. 引入红黑树来进一步降低遍历的时间复杂度,使得遍历更快

能否使用任何类作为 Map 的key?

可以使用任何类作为 Map 的 key,然而在使用之前,需要考虑以下几点:

  1. 如果类重写了 equals() 方法,也应该重写hashCode() 方法。
  2. 类的所有实例需要遵循与 equals() 和 hashCode() 相关的规则。
  3. 如果一个类没有使用 equals(),不应该在 hashCode() 中使用它。
  4. 用户自定义 Key 类最佳实践是使之为不可变的,这样 hashCode() 值可以被缓存起来,拥有更好的性能。不可变的类也可以确保 hashCode() 和 equals() 在未来不会改变,这样就会解决与可变相关的问题了。

为什么 HashMap 中的 String、Integer 这样的包装类适合作为 K?

答: String、Integer 等包装类的特性能够保证 Hash 值的不可更改性和计算准确性,能够有效的减少 Hash 碰撞的几率。都是 final 类型,即不可变性,保证 key 的不可更改性,不会存在获取 hash 值不同的情况。内部已重写 equals()、hashCode()等方法,遵守了 HashMap 内部的规范,不容易出现 Hash 值计算错误的情况。

如果使用 Object 作为 HashMap 的 Key,应该怎么办呢?

答: 重写 hashCode() 和 equals() 方法。重写 hashCode() 是因为需要计算存储数据的存储位置,需要注意不要试图从散列码计算中排除掉一个对象的关键部分来提高性能,这样虽然能更快但可能会导致更多的 Hash 碰撞;重写 equals(),需要遵守自反性、对称性、传递性、一致性以及对于任何非 null 的引用值 x, x.equals(null)必须返回 false 的这几个特性,目的是为了保证 key 在哈希表中的唯一性。

HashMap 为什么不直接使用 hashCode() 处理后的哈希值直接作为 table 的下表?

答: hashCode()方法返回的是 int 整数类型,其范围为 -(2^31)(2^31 -1),约有 40 亿个映射空间,而 HashMap 的容量范围是在 16(默认初始值) 2^30,HashMap 通常情况下是娶不到大值的,并且设备上也难以提供这么多的存储空间,从而导致通过 hashCode() 计算出的哈希值可能不在数组大小范围内,进而二u发匹配存储位置。

那么怎么解决呢?

  1. HashMap 自己实现自己的 hash() 方法,通过两次扰动是的它自己的哈希值高低位自行进行异或运算,降低哈希碰撞概率,也使得数据分布更平均。
  2. 在保证数组长度为 2 的幂次方的时候,使用 hahs() 运算之后的值与运算(&)(数组长度 - 1)来获取数组下表的方式进行存储,这样一来是比取余擦偶哦更加有效率,而来也是因为只有当数组长度为 2 的幂次方时, h & (length -1)才等价于 h % length,三来解决了“哈希值与数组大小范围不匹配”的问题

HahsMap 的长度为什么是 2 的幂次方

为了能让HashMap 存取高效,尽量较少碰撞,也就是要尽量把数据分配均匀,每个链表/红黑树长度大致相同。这个实现就是把数据存到哪个链表/红黑树中的算法。这个算法应该如何设计呢?我们首先可能会想到采用 % 取余的操作来实现。但是,重点来了:“取余(%)操作中如果除数是 2 的幂次则等价于与其除数减一的与(&)操作(也就是说 hash % length == hash & (length -1)的前提是 length 是 2 的 n 次方”)。并且采用二进制位操作 &,相对于 % 能够提高运算效率,这就解释了 HahsMap 的长度为什么是 2 的幂次方。

那为什么是两次扰动呢?

答:这样就是加大哈希值地位的随机性,是的分布更均匀,从而提高对应数组存储下标位置的随机性和均匀性,最终减少 Hash 冲突,两次就够了,已经达到了高位低位同时参与运算的目的。

HashMap 与 HashTable 有什么区别?

区别类型HashMapHashTable
线程安全非线程安全的线程安全的,HashTable 内部的方法基本上都经过 synchronized 修饰。(如果要保证线程安全的化就使用ConcurrentHshMap吧!)
效率因为线程安全问题,HashMap 要比 HashTable效率高一点HashTable 基本被淘汰,不要在代码中使用它
对 Null Key 和 Null Value 的支持HashMap中,null 可以作为键,这样的键只有一个,可以有一个或多个键所对应的值为 nullHashTable 中 put 进的键值只要有一个 null,直接抛 NullPoniterException
初始容量大小和每次扩充容量大小创建时如果不指定容器初始值,HashTable默认初始大小为 11,之后每次扩充,容量变为原来的 2n+1。创建时如果给定了容量初始值,那么 HashTable 会直接使用给定的大小。创建时如果不指定容器初始值,HashMap默认初始大小为 16,之后每次扩充,容量变为原来的 2 倍。创建时如果给定了容量初始值,那么 HashMap 会将其扩充为 2 的幂次方大小。也就是说 HashMap 总是使用 2 的幂做为哈希标的大小
底层数据结构JDK 1.8 以后的 HashMap 在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)时,将链表转化为红黑树,以减少搜索时间。HashTable 没有这样的机制。
推荐使用推荐在单线程环境下使用,多线程则用 ConcurentHashMap在 HashTable 的类注释可以看到,HashTable 是保留类,不建议使用

如何决定使用 HashMap 还是 TreeMap?

对于在 Map 中插入、删除和定位元素这类操作,HashMap 是好的选择。然而,加入你需要对一个有序的 key 集合进行遍历,TreeMap 是更好的选择。基于你的 collection 的大小,也许向 HashMap 中添加元素会更快,将 map 换位 TreeMap 进行有序 key 的遍历。

HashMap 和 ConcurrentHashMap 的区别

  1. ConcurrentHashMap 对整个桶数组进行了分割分段(Segment),然后在每一个分段上都用 lock 锁进行保护,相对于 HashTable 的 synchronized 锁的粒度更精细了一些,并发性能更好,而 HashMap 没有所机制,不是线程安全的。(JDK 1.8 之后 ConcurrentHashMap 开启了一种全新的方式实现,利用 CAS 算法)
  2. HashMap 的键值对允许有 null,但是 ConCurrentHashMap 都不允许。

ConcurrentHashMap 和 HashTable 的区别?

ConcurrentHashMap 和 HashTable 的区别主要体现在实现线程安全的方式上不同。

底层数据结构:JDK 1.7 的 ConcurrentHashMap 底层采用分段的数组+链表实现,JDK 1.8采用的数据结构跟 HashMap1.8 的结构一样,数组+链表/红黑二叉树。HashTable 和 JDK 1.8 之前的 HashMao 的底层数据结构类似都是采用数组+链表的形式,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的。

实现线程安全的方式(重要):

1.在 JDk 1.7 的时候,ConcurrentHashMap(分段锁)对整个桶数组进行了分割分段(Segment),每一把锁只锁容器其中一部分数据,多线程访问容器里不同的数据段的数据,就不会存在锁竞争,提高并发访问率。(默认分配 16 个 Segment,比 HashTable 效率提高 16 倍)到了 JDK 1.8 的收已经摒弃了 Segment 的概念,而是直接用 Node 数组 + 链表 + 红黑树的数据结构来实现,并发控制使用 synchronized 和 CAS 来操作。(JDK 1.6 以后对 synchronized 锁做了很多优化)整个看卡里就像是优化过且线程安全的 HashMap,虽然在 JDK 1.8 中还能看到 Segement 的数据结构,但是已经简化了属性,只是为了兼容旧版本。 2. HashTable(同一把锁):使用 synchronized 来保证线程安全,效率非常低下。当一个线程访问同步方法时,其他线程也访问同步方法,可能会进入阻塞或轮询状态,如使用 put 添加元素,另一个线程不能使用 put 添加元素,也不能使用 get,竞争会越来越激烈,效率越低。

ConcurrentHashMap 结合了 HashMap 和 HashTable 二者的优势。HashMap 没有考虑同步,HashTable考虑了同步的问题。但是 HashTable 在每次同步执行时都要锁住整个结构,ConcurrentHashMap 锁的方式是扫尾细粒度的。

ConcurrentHashMap 底层具体实现知道吗?实现原理是什么?

JDK 1.7

首先将数据分为一段一段的存储,然后给每一段数据分配一把锁,当一个线程占用锁访问其中一个段数据时,其他段的数据也能被其他线程访问。在 JDK 1.7 中,ConcurrentHashMap 采用 Segment + HashEntry 的方式进行实现,结构如下:一个 ConcurrentHashMap 里面包含一个 Segement数组。Segment 的结构和HashMap 类似,是一种数组和链表结构,一个 Segment 包含一个 HashEntry 数组,每个 HashEntry 是一个链表结构的元素,每个 Segment 守护着一个 HashEntry 数组里的元素,当对 HashEntry 数组的数据进行修改时,必须首先获得对应的 Segment 的锁。

  1. 该类包含两个静态内部类 HashEntry 和 Segment;前者用来封装映射标的键值对,后来者用来充当锁的角色
  2. Segment 时一种可重入的锁 ReentrantLock,每个 Segment 守护一个 HashEntry 数组里的元素,当对 HashEntry 数组的数据进行修改时,必须首先获得对应的 Segment 锁。

JDK 1.8

在 JDK 1.8 中,放弃了 Segment 臃肿的设计,取而代之的是采用Node + CAS +synchronized 来保证并发安全进行实现,synchronized 只锁定当前链表或红黑二叉树的首节点,这样只要 hash 不冲突,就不会产生并发,效率又提升 N 倍。

看插入元素过程(建议取看看源码): 如果相应位置的 Node 还没有初始化,则调用 CAS 插入相应的数据

else if((f = tabAt(tab,i = (n -1) & hash)) == null){
if(casTabAt(tab,i,null,new Node<K,V>(hash,key,value,null)))
break; // no lock when adding to empty bin
}

如果相应位置的 Node 不为空,且当前该节点不处于移动状态,则对该节点加 synchronized 锁,如果该节点的 hash 不小于 0,则遍历i岸标更新节点或插入新节点。

if(fh >= 0){
binCount = 1;
if(e.hash == hash && ((ek = e.key) == key || (ek != null && key.equals(ek)))){
oldVal = e.val;
if(!onlyIfAbsent)
break;
}
Node<K,V> pred = e;
if((e = e.next) == null){
pred.next = new Node<K,V>(hash,key,value,null);
break;
}
}
}
  1. 如果该节点是 TreeBin 类型的节点,说明是红黑树结构,则通过 putTreeVal 方法往红黑树中插入节点;如果 binCount 不为 0,说明 put 操作对数据产生了影响,如果当前链表的个数达到 8 个,则通过 treeifyBin 方法转化为红黑树,如果 oldVal 不为空,则说明是一次更新操作,没有对元素个数残生影响,则直接返回旧值。 2.如果插入的是一个新节点,则执行 addCount() 方法尝试更新元素个数 baseConut;