前言:
在进行HashMap的线程安全测试前,我们先来思考几个问题,HashMap的正确使用方法是什么?你平时习惯的使用方式是否正确?如果不正确,那么正确的使用方式到底是什么呢?如果你对HashMap的使用存有疑虑,相信你在看完本篇博客后将会有所收获!
1.HashMap线程安全测试
1.1 HashMap的使用方式
1.1.1 习惯的使用方式
我们平常在使用HashMap时,通常的使用习惯可能如下:
//获取一个HashMap对象
Map<String,String> map = new HashMap<>();
但你认为这样是正确且合理的使用方法吗?
答案:并不是,因为在《阿里巴巴Java开发手册》中曾提到过,我们在使用HashMap时,应当在创建时指定其初始容量大小
那么,正确的使用方式是什么呢?请继续往下看
1.1.2 正确的使用方式
在使用HashMap之前,我们先简单查看一下HashMap构造函数的相关源码:
- HashMap(int, float)型构造函数源码
/**
* 构造一个指定初始容量和负载因子的空的HashMap
*
* @param initialCapacity 初始容量(int型)
* @param loadFactor 负载因子(float型)
* @throws 如果初始容量是负数或负载因子是负数, 抛出IllegalArgumentException(非法参数异常)
*/
//HashMap的有参构造函数(包含两个参数:初始容量和负载因子)
public HashMap(int initialCapacity, float loadFactor) {
//判断初始容量是为负数
if (initialCapacity < 0)
//如果为负数, 抛出非法参数异常
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
//判断初始容量是否大于最大容量
if (initialCapacity > MAXIMUM_CAPACITY)
//如果大于,初始容量值设置为最大容量值
initialCapacity = MAXIMUM_CAPACITY;
//判断负载因子是否小于等于0 或者 负载因子是否为float类型
if (loadFactor <= 0 || Float.isNaN(loadFactor))
//如果负载因子为负数或负载因子不是float类型的,将会抛出非法参数异常
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
//初始化负载因子
this.loadFactor = loadFactor;
//初始化容量
this.threshold = tableSizeFor(initialCapacity);
}
在看到 Float.isNaN(loadFactor) 这个判断条件时,感到有些疑惑,然后去看了下其对应的源码:
- Float类的isNaN方法源码:
package java.lang;
//Float类
public final class Float extends Number implements Comparable<Float> {
//...省略前面部分代码
/**
* 如果指定的数是一个Not-a-Number(NaN), 即不是一个数字,返回布尔值true, 否则返回false
*
* @param v 测试值
* @return 如果参数为NaN(非数字类型), 则返回布尔值true, 否则返回false
*/
public static boolean isNaN(float v) {
return (v != v);
}
//...省略后面部分代码
}
最后在看到 this.threshold = tableSizeFor(initialCapaity) 对于这个tableSizeFor又有些疑惑,查看其对应方法源码:
- HashMap的tableSizeFor方法源码:
/**
* 返回大于初始容量的最小的2次幂数值
* @param cap 设置的初始容量
*/
//初始化哈希表的大小
static final int tableSizeFor(int cap) {
//n应该是值哈希表中的元素个数, 其初始值为初始容量值减1
int n = cap - 1;
//执行无符号右移运算, 忽略符号位, 空位都以0补齐
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
/**
* 这里的返回值是进行了两个三元运算操作比较:
* 判断n(元素个数)是否小于0, 若元素个数小于0, 则返回值为1, 否则判断n(元素个数)是否大于等于最大容量, 若大于HashMap的最大容量, 则返回值为最大容量值, 否则返回值为n+1(元素个数加1)
*/
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
看完了tableSizeFor源码后,我们得知:
在我们自定义HashMap的初始容量大小时, HashMap(int,float)构造函数并非直接使用我们所设置的容量值,而是调用了tableSizeFor方法, 把设置的容量值做为参数, 然后把该函数的返回值作为最后的初始容量大小!
在HashMap(int,float)构造函数中,我们了解到,在创建HashMap时,应该设置一下初始容量和负载因子大小,接下来看一下正确的使用方式
- 正确的使用方式如下所示:
Map<String,String> map = new HashMap<>(16, 0.75F);
其中第一个参数是初始容量 (initialCapacity), 其默认值为16;而第二个参数是负载因子 (loadFactor), 其默认值为0.75F (F表示其为双精度浮点数,即为数据类型为float型)
那么请你再思考一个问题,在HashMap中的源码中,它的初始容量是直接用16表示吗?
答案:并不是,在HashMap源码中,关于默认初始容量大小表示如下:
//默认的初始容量 - 必须是2的幂数
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
这里初始容量大小使用了一个 1 << 4 表示形式,很多基础薄弱的小伙伴看到后就会心生疑惑,这个 << 到底是什么呢? 其实这是使用了位运算符来进行表示,这里简单介绍一下常用的三种位运算符:
<<:左移运算符 >>:右移运算符 >>>:无符号右移
这里以左移运算符为例,左移的运算规则是: 按照二进制形式把所有数字做移动对应的位数, 高位移出(舍弃),低位的空位补零
知道了原理后,我们来计算一下 1 << 4 转换为 十进制数后是不是16呢?
根据左移运算的规则,那么1 << 4 表示的就是向左移动4位,使用二进制数表示就是 0001 0000 ,再转换为十进制数后,你会发现,这个值刚好等于16
那么,为什么JDK开发人员不直接使用16,而偏偏要使用看上去难懂一些的 1 << 4 呢?
我的理解是:
这样做的目的是,在使用HashMap进行插入数据(即putValue操作)时,首先需要判断数组是否为空,若数组不为空,需要通过 (n-1) & hash 来计算插入元素应当存放在数组中的下标i,而使用1<<4 恰好可以将节点进行平均分配。
上面提到了HashMap中的putValue操作,很多小伙伴可能还不太明白,所以我们来看一下它的源码,顺便验证一下我上面的结论
- HashMap的putValue方法源码
putValue方法的相关字段:
//相关字段
/**
* 链表转化成红黑树的树化阈值
* 当添加元素时, 链表中的节点数至少有阈值大小, 会将链表转换为红黑树
* 为符合红黑树转化为链表的条件, 该阈值必须大于2且至少为8
*/
static final int TREEIFY_THRESHOLD = 8;
/**
* 红黑树转化为链表的链化阈值
* 在转化时进行收缩检测, 节点数应小于该计数阈值, 且最多6个节点
*/
static final int UNTREEIFY_THRESHOLD = 6;
/**
* 转化成红黑树的最小数组容量
* (否则, 若数组中节点太多, 则会调整表的大小(进行数组扩容))
* 为避免调整大小和树化阈值之间的冲突, 该值至少是树化阈值的4倍
*/
static final int MIN_TREEIFY_CAPACITY = 64;
//...(中间省略部分代码)...
/**
* 存储哈希桶的数组
* 第一次使用该数组时,会进行初始化, 并根据需要调整大小, 且当分配时, 数组长度总是2的幂数
* (在某些操作中, 我们也允许长度为零, 以允许当前不需要的引导机制)
*/
transient Node<K,V>[] table;
/**
* Map集合中包含键值对映射的数量
*/
transient int size;
/**
* HashMap发生结构性修改的次数
* 结构性修改是指那些HashMap中的映射数量改变, 或者修改其内部结构(例如rehash操作).
* 该字段用于让HashMap的集合视图中迭代器(fast-fail)快速失效
* (抛出ConcurrentModificationException(并发修改异常)).
*/
transient int modCount;
/**
* 扩容阈值 (容量 * 负载因子).
*
* @serial
*/
//如果哈希表的数组没有进行分配, 这个字段用于保存数组的初始容量, 或者0来表示DEFAULT_INITIAL_CAPACITY(默认初始容量)
int threshold;
putValue方法源码:
//putValue方法
/**
* 实现Map集合的put操作和相关方法
*
* @param hash key的hash值
* @param key 键值
* @param value 存入的value值
* @param onlyIfAbsent 如果值为true, 不改变已存在的值
* @param evict 如果为false, 哈希表处于创建模式
* @return 如果没有, 返回先前的值或者null值
*/
//HashMap的put值操作的方法
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
//tab是指table(即存储hash桶的数组), p是已存在Node节点, 而n是该数组的长度, i是数组下标
Node<K,V>[] tab; Node<K,V> p; int n, i;
//步骤一: 判断数组是否为空, 为空则初始化数组
//1.判断数组是否为空 或者 数组长度是否为0
if ((tab = table) == null || (n = tab.length) == 0)
//如果满足数组为空或者其长度为0, 则初始化数组
n = (tab = resize()).length;
//步骤二: 判断数组是否为空, 不为空则计算存放下标位置
//2.若数组不为空
//2.1 判断数组下标是否为空(使用n-1 & hash 进行计算)
if ((p = tab[i = (n - 1) & hash]) == null)
//2.2 数组下标(i)为空(即当前下标中不存在数据), 则创建一个Node节点, 将其存放在数组中下标为i的位置
tab[i] = newNode(hash, key, value, null);
else {
//步骤三: 判断数组下标是否为空, 不为空则下标位置存在数据, 发生哈希冲突
//3. 若数组下标不为空(即当前下标存在数据)
//3.1 创建一个Node节点e, k是e的key值
Node<K,V> e; K k;
//3.2 判断是否发生哈希冲突(即存在两个hash值相同的节点)
//3.2.1 判断p(已存在节点)的hash值和新建节点的hash值是否相等 并且 两者key值是否相等 或者 key值是否为空并且key值是否相等
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
//若p(已存在节点)和新建节点的hash值相等并且两者key值相同(即存在hash冲突), 则, 将p(已存在节点)的值赋给新建节点e
e = p;
//步骤五: 判断当前节点是否为树形节点, 若是则将新建节点加入红黑树中
//前提: 若两者的hash值和key值不相同
// 判断p(已存在节点)是否为树形节点
else if (p instanceof TreeNode)
//若p(已存在节点)是树形节点, 则将e(新建节点)加入到红黑树中
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
//步骤六: 当前节点不为树形节点, 则新建Node节点到插入链表中去
//前提: 若p(已存在节点)不是树形节点(即为链表节点)
else {
//6.使用尾插法在链表最末插入新节点
for (int binCount = 0; ; ++binCount) {
//6.1 判断p(已存在节点)的下一个节点(即e新建节点)是否为空
if ((e = p.next) == null) {
//6.1.1 若p(已存在节点)的下一个节点为空, 则新建一个节点
p.next = newNode(hash, key, value, null);
//6.1.2 判断binCount(节点数)是否大于TREEIFY_THRESHOLD(树化阈值)减1(减1是第一个节点不计算在内)
if (binCount >= TREEIFY_THRESHOLD - 1)
//#1 若节点数大于树化阈值, 则转化为红黑树
treeifyBin(tab, hash);
//#2 跳出循环
break;
}
//前提: 若p(已存在节点)的下一个节点不为空
//6.2 判断e(新建节点)和p(已存在节点)的key值和hash值是否相等
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
//6.2.1 若两个节点的key值和hash值相等, 则跳出循环
break;
//6.2.2 若两个节点不同, 则将e(新建节点)赋值给p(已存在节点)
p = e;
}
}
//步骤四: 两个节点的hash值和key值相同, 进行旧值覆盖
//4. 前提: 若p(已存在节点)和e(新建节点)的hash值和key值相同
//4.1 判断e(新建节点)是否为空
if (e != null) {
//4.1.1 覆盖旧值成功
//#1 若e为不空, 则覆盖已存在节点p的旧值为新建节点e的新值
V oldValue = e.value;
//#2 若e(新建节点)为空
//判断onlyIfAbsent是否为false 或者 旧值是否为空
if (!onlyIfAbsent || oldValue == null)
//4.1.2 覆盖旧值失败
//若onlyIfAbsent为false或者旧值为空, 则新建节点e的值还为新值value
e.value = value;
//前提: 若e(新建节点不为空)
//#3 e访问后调用
afterNodeAccess(e);
//#4 最后返回旧值
return oldValue;
}
}
//进行结构性修改
++modCount;
//步骤七: 判断节点数是否大于阈值,大于就进行数组扩容
//判断size(节点总数)是否大于threshold(扩容阈值)
if (++size > threshold)
//初始化数组(扩容为原来的两倍)
resize();
//节点插入后回调
afterNodeInsertion(evict);
return null;
}
- putValue操作流程图和步骤解释
流程图:
步骤解释:
- 步骤一:首先判断数组是否为空,若数组为空,则将数组进行初始化
- 步骤二:若数组不为空,通过 (n-1) & hash 计算存放在数组中的下标 i 位置
- 步骤三:判断 tab [ i ] (数组中下标为 i 的位置) 是否存在数据,若不存在数据,则新建一个Node节点,将其存放在数组中的下标 i 位置中
- 步骤四:若数组的下标 i 位置存在数据 ,说明发生了哈希冲突 (即已存在节点和插入节点的key值和hash值相同),判断两个节点的key是否相等 (使用equals方法进行比较),若key值相同,则用插入节点的value值覆盖掉已存在节点的value值 (前提是onlyAbsent方法的值为false)
- 步骤五:若两个节点的key值不同,判断已存在节点是否为树形节点,若已存在节点为树形节点,则将新建节点插入到红黑树中
- 步骤六:若已存在节点不是树形节点,则创建一个普通Node节点,将新建节点插入到链表尾部;然后判断链表中的节点数是否达到树化阈值 (即链表节点数大于8并且数组长度大于64),若大于等于该值,则将链表转换成红黑树
- 步骤七:判断总节点数是否大于扩容阈值,若大于该阈值(threshold),则将当前数组进行扩容(扩容为原来的两倍)
上面我们提到,在使用HashMap时,需要指定它的初始容量大小,但很多小伙伴表示为什么要指定初始容量大小有些疑惑,那么接下来就来解释这个问题
1.1.3 为什么需要指定初始容量大小?
如果没有设置HashMap的初始容量大小,随着元素数量不断增加,HashMap会进行多次扩容,而HashMap中每次扩容都要执行rehash操作 (即重新格式化哈希表),频繁的扩容是非常消耗性能的
上面提到了HashMap会随着元素数量增多,而进行多次扩容,那么元素数量究竟达到多少,HashMap才会进行扩容呢?
HashMap扩容的触发条件如下:
当哈希表中的元素数目超过扩容阈值, 哈希表将执行rehash操作 (即重建内部的数据结构),以至于哈希表的存储桶数量会变为大约原来的两倍。
/**
* 扩容阈值 (容量 * 负载因子).
* @serial
*/
//如果哈希表的数组没有进行分配, 这个字段用于保存数组的初始容量, 或者0来表示DEFAULT_INITIAL_CAPACITY(默认初始容量)
int threshold;
注意:临界值的计算公式为:threshold = initialCapacity * loadFactor (其中threshold表示扩容阈值,initialCapacity表示初始容量大小,loadFactor表示装载因子大小)
1.3.4 如何合理设置HashMap的初始容量大小?
关于HashMap初始容量大小的设置,在《阿里巴巴Java开发手册》中也有说明:
initiaCapacity(初始容量) = ( size (需要存储的元素个数) / loadFactor(负载因子) ) + 1
注意:loadFactor (即负载因子) 默认值为0.75,如果暂时无法确定初始值的大小,请设置其为16(即默认值)
按照阿里巴巴开发手册中的公式,我们可以来做个计算:
假设我们需要存储的元素个数为10个,负载因子使用默认的0.75,套入公式就是 (10 / 0.75) + 1,结果约等于 14,也就是说,如果我们想要存储10个元素,只需要将初始容量大小设置为14即可,但是JDK文档中说明初始容量必须是2的幂数,14显然不是2的幂数,我们发现14刚好在2的3次幂和2的4次幂之间,按照只大不小原则,那初始容量的大小就为16
讨论了如何合理设置HashMap的初始容量后,我们再来思考一个问题,为什么说初始容量不易设置太高(或负载因子设置太低)?
下面是在JDK文档中的相关解释:
HashMap为基础的操作(例如get和put)提供了持续时间的性能,假设哈希(散列) 函数将元素正确的分散存储在桶中,集合视图的迭代所需要的时间应该和HashMap实例的容量(桶的数量)加上它的大小(key-value(键值对)映射的数量)成正比。
因此, 如果重视迭代性能,不能设置初始容量太高(或者负载因子太低)。
你可能还注意到了,在上面提到,负载因子的默认值为0.75,那么为什么负载因子默认值要设置为0.75呢?让我们来继续看一下JDK文档中的相关解释:
1.3.5 为什么负载因子默认为0.75?
在解释负载因子的默认值为什么是0.75前,我们首先需要知道什么是负载因子?
JDK文档中负载因子的定义:
负载因子是在HashMap容量自动增加之前,用于衡量哈希表容量允许达到的满度。
如果你觉得这样的解释还是不够直观,我们可以结合哈希表的负载因子计算公式来理解
α = N / M (其中α是指装载因子,N表示哈希表中已存入的元素数,M表示哈希地址空间大小)
注意:α越小,冲突可能性就越小;α越大,冲突的可能性就越大
假设元素数N固定,如果α值越小,空间大小M就越大,即哈希表中的空闲单元比例就越大,因此待插入的元素和已插入的元素发生冲突的可能性就越小,但是其空间利用率就越低
反之,α值越大,空间大小M值就越小,哈希表中的空闲单元比例就越小,因此待插入的元素和已插入的元素发生冲突的可能性就越大,但其空间利用率就越高
JDK文档中关于负载因子默认值设置为0.75的相关解释:
作为一般规则, 默认的负载因子(0.75)在时间和空间开销上提供一个好的权衡,更高的值减少了空间开销,但会增加查找成本 (体现在HashMap类的大多数操作中, 包括get和put) 。
1.2 HashMap线程安全测试
1.2.1 测试代码
package com.kuang.unsafe;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
/**
* @ClassName MapTest
* @Description HashMap线程安全测试类
* @Author 狂奔の蜗牛rz
* @Date 2021/8/2
*/
public class MapTest {
public static void main(String[] args) {
//创建一个HashMap对象
Map<String,String> map = new HashMap<>();
//用循环模拟多线程环境
for (int i = 0; i < 30; i++) {
//创建线程
new Thread(()->{
//向map中放入值: key键对应当前线程的名字, value值对应从数组中从0到5的随机字符串
map.put(Thread.currentThread().getName(), UUID.randomUUID().toString().substring(0,5));
//打印map集合
System.out.println(map);
//获取数组中下标i位置的字符串, 并且启动线程
},String.valueOf(i)).start();
}
}
}
1.2.2 测试结果
结果:抛出ConcurrentModificationException(并发修改异常)!
1.2.3 结果分析
由此可知:HashMap是线程不安全的,只能在单线程下使用它
那么如果在多线程情况下,我们仍然想使用HashMap,该怎么办呢?
2. 解决HashMap线程不安全
2.1 HashMap线程安全解决方案
JDK官方给出的解决方案是,如果想保证HashMap的线程安全:
- 使用Collections集合工具类的synchronizedMap方法
Map m = Collections.synchronizedMap(new HashMap(...));
- 使用ConcurrentHashMap类
Map m = new ConcurrentHashMap();
2.2 使用Collections集合工具类的synchronizedMap方法
2.2.1 测试代码
package com.kuang.unsafe;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
/**
* @ClassName MapTest
* @Description HashMap线程安全测试类
* @Author 狂奔の蜗牛rz
* @Date 2021/8/2
*/
public class MapTest {
public static void main(String[] args) {
//创建一个HashMap对象
// Map<String,String> map = new HashMap<>();
//方案一: 使用Collections集合工具类中的synchronizedMap方法
Map<String,String> map = Collections.synchronizedMap(new HashMap<>());
//用循环模拟多线程环境
for (int i = 0; i < 30; i++) {
//创建线程
new Thread(()->{
//向map中放入值: key键对应当前线程的名字, value值对应从数组中从0到5的随机字符串
map.put(Thread.currentThread().getName(), UUID.randomUUID().toString().substring(0,5));
//打印map集合
System.out.println(map);
//获取数组中下标i位置的字符串, 并且启动线程
},String.valueOf(i)).start();
}
}
}
2.2.2 测试结果
结果:执行成功, 没有抛出并发修改异常!
虽然在使用了使用Collections集合工具类的synchronizedMap方法后保证了HashMap线程安全,但很多小伙伴对它为什么就能保证线程安全感到疑惑,接下来就我们一起来查看一下它的底层源码!
2.2.3 源码解析
- Collections集合工具类的synchronizedMap方法源码
/**
* 返回一个依靠指定Map集合同步的(线程安全)的Map
* 如果指定Map集合被序列化, 返回的Map也将被序列化
* @param <K> Map集合中键(keys)的类
* @param <V> Map集合中值(values)的类
* @param m 在一个同步Map集合中被包装的map
* @return 一个指定map集合的同步视图
*/
public static <K,V> Map<K,V> synchronizedMap(Map<K,V> m) {
//返回值为创建一个SynchronizeMap同步Map集合类
return new SynchronizedMap<>(m);
}
/**
* @serial include
*/
//SynchronizeMap: 同步Map集合类
private static class SynchronizedMap<K,V>
//实现Map<K,V>集合接口和序列化接口
implements Map<K,V>, Serializable {
//持续版本UID
private static final long serialVersionUID = 1978198479659022715L;
private final Map<K,V> m; // 备份的map集合
final Object mutex; // 同步资源对象
/**
* SynchronizedMap的有参构造函数
* @param m Map集合
*/
SynchronizedMap(Map<K,V> m) {
//备份map集合要求不能为空
this.m = Objects.requireNonNull(m);
//同步资源对象
mutex = this;
}
/**
* SynchronizedMap的有参构造函数
* @param m Map集合
* @param mutex 同步资源对象
*/
SynchronizedMap(Map<K,V> m, Object mutex) {
//初始化Map集合
this.m = m;
//初始化同步资源对象
this.mutex = mutex;
}
//...中间省略部分代码
/**
* 获取key(键)的方法
* @param key 键
*/
public V get(Object key) {
//使用synchronized(同步锁)修饰共享资源mutex
synchronized (mutex) {
//将Map集合中获取的的key键返回
return m.get(key);
}
}
/**
* 存放key-value(键值对)的方法
* @param key 键
* @param value 值
*/
public V put(K key, V value) {
//使用synchronized(同步锁)修饰共享资源mutex
synchronized (mutex) {
//将Map集合中存放的的键值对返回
return m.put(key, value);
}
}
//...中间省略部分代码
/**
* 若键值对已存在存放key-value(键值对)的方法
* @param key 键
* @param value 值
*/
@Override
public V putIfAbsent(K key, V value) {
//使用synchronized(同步锁)修饰共享资源mutex
synchronized (mutex) {
//将Map集合中存放的的键值对返回
return m.putIfAbsent(key, value);
}
}
//...后面省略部分代码
- 为了保证串行访问, 通过返回的map完成对备份map的所有访问是至关重要的;当用户迭代任何集合视图时, 必须手动同步返回的map集合:
使用方式:
Map m = Collections.synchronizedMap(new HashMap());
...
Set s = m.keySet(); //不需要在同步代码块中
...
synchronized (m) { // 同步共享资源m, 不是Set集合s!
//获取迭代器
Iterator i = s.iterator(); // 必须在同步代码块中
//进行迭代
while (i.hasNext())
foo(i.next());
}
注意:不遵循此建议可能会导致不确定性行为!
2.3 使用ConcurrentHashMap类
2.3.1测试代码
package com.kuang.unsafe;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
/**
* @ClassName MapTest
* @Description HashMap线程安全测试类
* @Author 狂奔の蜗牛rz
* @Date 2021/8/2
*/
public class MapTest {
public static void main(String[] args) {
//创建一个HashMap对象
// Map<String,String> map = new HashMap<>();
//方案二: 使用ConcurrentHashMap
Map<String,String> map = new ConcurrentHashMap<>();
//用循环模拟多线程环境
for (int i = 0; i < 30; i++) {
//创建线程
new Thread(()->{
//向map中放入值: key键对应当前线程的名字, value值对应从数组中从0到5的随机字符串
map.put(Thread.currentThread().getName(), UUID.randomUUID().toString().substring(0,5));
//打印map集合
System.out.println(map);
//获取数组中下标i位置的字符串, 并且启动线程
},String.valueOf(i)).start();
}
}
}
2.3.2 测试结果
结果:执行成功, 没有抛出并发修改异常!
限于篇幅长度,对HashMap和ConcurrentHashMap源码的解析将会在后续博客中继续更新!那么今天的有关HashMap线程安全问题的学习到这里就结束了,欢迎大家学习和讨论,点赞和收藏!
参考视频链接:https://www.bilibili.com/video/BV1B7411L7tE (B站UP主遇见狂神说的JUC并发编程基础)