文章目录
背景
面试的时候先会喊你说说集合,那些集合线程不安全?当你说了
HashMap
线程不安全,面试官可能会进一步询问你是否了解
ConcurrentHashMap
,以及它是如何实现线程安全的。
常见集合线程安全性
ArrayList、LinkedList、TreeSet、HashSet、
HashMap
、TreeMap等都是线程不安全的。
HashTable
是线程安全的。
HashMap为什么线程不安全?
来看个例子
publicstaticvoidmain(String[] args){HashMap<String,Integer> map =newHashMap<>();// 创建两个线程同时向HashMap中添加1000个元素Thread thread1 =newThread(newRunnable(){@Overridepublicvoidrun(){for(int i =0; i <1000; i++){
map.put(String.valueOf(i), i);}}});Thread thread2 =newThread(newRunnable(){@Overridepublicvoidrun(){for(int i =1000; i <2000; i++){
map.put(String.valueOf(i), i);}}});// 启动线程
thread1.start();
thread2.start();try{// 等待两个线程执行完成
thread1.join();
thread2.join();}catch(InterruptedException e){
e.printStackTrace();}// 输出 HashMap 的大小System.out.println("map集合大小: "+ map.size());}//输出结果:map集合大小:1920(这个数字在变动)
在多线程环境下,如果多个线程同时对
HashMap
进行修改操作(例如添加、删除元素),可能会导致数据结构破坏,进而引发各种问题,比如丢失数据等。
怎么保证HashMap线程安全
第一:
如何保证HashMap的线程安全呢?可能你们想到了
synchronized
,确实,你可以通过在添加元素时使用 synchronized 来确保 HashMap 的线程安全性。这样做可以在多线程环境下保证对 HashMap 的操作是互斥的,从而避免了多个线程同时修改 HashMap 导致的线程不安全问题。
Thread thread1 =newThread(newRunnable(){@Overridepublicvoidrun(){//synchronized (map) 中的 map 是一个对象锁//它指定了在执行同步代码块时使用的锁对象synchronized(map){for(int i =0; i <1000; i++){
map.put(String.valueOf(i), i);}}}});
当一个线程进入同步代码块(即 synchronized (map) 所包围的部分)时,它会尝试获取 map 对象的锁。如果这个锁当前没有被其他线程占用,那么该线程将获得锁,并可以执行同步代码块中的操作;如果该锁已经被其他线程占用,那么该线程将被阻塞,直到锁被释放。被锁住的对象将会在同步代码块执行完毕后自动释放。
第二:
使用
Collections.synchronizedMap()
包装:
Map<Integer, String> synchronizedMap = Collections.synchronizedMap(new HashMap<>());
这样可以获得一个线程安全的 HashMap,但性能可能不如 ConcurrentHashMap。
第三:
还可以用其他锁
ReentrantReadWriteLock
来控制多线程下对集合安全读写。可能有人又疑惑好像自己见过ReentrantLock 。ReentrantLock 是一个可重入的互斥锁,而 ReentrantReadWriteLock 是一个可重入的读写锁。区别:
互斥性:
ReentrantLock 是一种独占锁,同一时刻只允许一个线程获得锁,其他线程必须等待该线程释放锁才能继续执行。
ReentrantReadWriteLock 包含了两种锁:读锁和写锁。多个线程可以同时持有读锁,但是只有一个线程可以持有写锁,并且在持有写锁时,不允许其他线程持有读锁或写锁。
读写分离:
ReentrantReadWriteLock 的设计目的是为了提高读操作的并发性能。在读多写少的情况下,读锁允许多个线程同时访问共享资源,从而提高了系统的并发能力。
ReentrantLock 没有读写分离的特性,它只是简单的互斥锁,适用于那些需要严格互斥的场景。
第四:
使用 ConcurrentHashMap:
ConcurrentHashMap
是专门为高并发环境设计的,JDK 1.8它使用了
CAS + synchronized
来保证线程安全性,而且性能表现优秀。
Map<Integer, String> concurrentHashMap = new ConcurrentHashMap<>();
ConcurrentHashMap为什么线程安全
ConcurrentHashMap
之所以是线程安全的,主要是因为它在内部实现时采用了特殊的机制来确保多个线程同时访问和修改数据时不会发生冲突。
JDK 1.7
版本中的实现:
ConcurrentHashMap 在 JDK 1.7 中使用了分段锁(Segmentation)的结构,将整个哈希表分成了多个段(Segment),每个段有自己的锁。不同段之间的修改操作可以并发进行(提高了并发性能),只有在同一段内的操作才需要竞争锁。
JDK 1.8
版本中的优化:
JDK 1.8 对 ConcurrentHashMap 进行了重大优化,废弃了分段锁的设计,而是采用了更细粒度的锁分离技术。
在 JDK 1.8 中,ConcurrentHashMap 内部使用了基于
CAS(Compare and Swap 是一种原子操作,用于在多线程环境下实现对共享数据的安全更新。CAS 是一种乐观锁机制,可以避免使用传统的互斥锁,提高了并发性能。)
操作的
synchronized
关键字
同时,ConcurrentHashMap 在 JDK 1.8 中引入了
红黑树
作为链表的替代结构,当链表长度达到一定阈值时,会将链表转换为红黑树,以提高查找效率。这种优化又提高了 ConcurrentHashMap 的并发性能和吞吐量。
代码中分析
来看看put方法
publicstaticvoidmain(String[] args){ConcurrentHashMap<String,Integer> map =newConcurrentHashMap<>();
map.put("a",111);}
先了解一下CAS
在 ConcurrentHashMap 中,
CAS
主要用于对节点的插入、更新和删除操作。这些操作涉及到对节点的创建、替换和删除,需要确保线程安全,不能出现多线程操作同一节点的情况。使用 CAS 可以保证节点的创建、替换和删除的原子性,从而保证了线程安全。
在 JDK8 中,ConcurrentHashMap 的实现方式已经改变,不再采用分段锁的方式,而是采用了 CAS+Synchronized 的方式来保证线程安全
我们需要先了解tabAt ,casTabAt(本质是CAS 算法,看下面源码可知)的利用来保障线程安全的操作:
tabAt
方法用于从哈希表数组 tab 中获取指定索引 i 处的节点数组。
casTabAt
方法用于原子性地将哈希表数组 tab 中指定索引 i 处的值从 c 更新为 v。
staticfinal<K,V>booleancasTabAt(Node<K,V>[] tab,int i,Node<K,V> c,Node<K,V> v){returnU.compareAndSwapObject(tab,((long)i <<ASHIFT)+ABASE, c, v);}
put源码如下:
publicVput(K key,V value){returnputVal(key, value,false);}//下面会用到transientvolatileNode<K,V>[] table;/** Implementation for put and putIfAbsent */finalVputVal(K key,V value,boolean onlyIfAbsent){if(key ==null|| value ==null)thrownewNullPointerException();int hash =spread(key.hashCode());int binCount =0;for(Node<K,V>[] tab = table;;){Node<K,V> f;int n, i, fh;if(tab ==null||(n = tab.length)==0)
tab =initTable();elseif((f =tabAt(tab, i =(n -1)& hash))==null){//如果tab为空,则尝试使用 casTabAt 方法原子地将新节点插入到tab中,如果插入成功则退出循环。if(casTabAt(tab, i,null,newNode<K,V>(hash, key, value,null)))break;// no lock when adding to empty bin}elseif((fh = f.hash)==MOVED)
tab =helpTransfer(tab, f);else{V oldVal =null;synchronized(f){if(tabAt(tab, i)== f){if(fh >=0){
binCount =1;for(Node<K,V> e = f;;++binCount){K ek;if(e.hash == hash &&((ek = e.key)== key ||(ek !=null&& key.equals(ek)))){
oldVal = e.val;if(!onlyIfAbsent)
e.val = value;break;}Node<K,V> pred = e;if((e = e.next)==null){
pred.next =newNode<K,V>(hash, key,
value,null);break;}}}elseif(f instanceofTreeBin){Node<K,V> p;
binCount =2;if((p =((TreeBin<K,V>)f).putTreeVal(hash, key,
value))!=null){
oldVal = p.val;if(!onlyIfAbsent)
p.val = value;}}}}if(binCount !=0){// 如果链表长度达到阈值(默认8),则将链表转化为红黑树以提高查找性能if(binCount >=TREEIFY_THRESHOLD)treeifyBin(tab, i);if(oldVal !=null)return oldVal;break;}}}addCount(1L, binCount);returnnull;}
注意其中
table
,
synchronized
,
treeifyBin(tab, i);
table:
源码中赋值表达式
Node<K,V>[] tab = table;
,其中table是什么呢?定义table的源码:
transient volatile Node<K,V>[] table;
,其中使用了 transient 和 volatile 关键字进行修饰。
transient 关键字
:表示该变量不会被序列化。在对象被序列化时,table 数组不会被保存,这意味着在反序列化时需要重新构建 table 数组。
volatile 关键字
:表示该变量是易变的,并且在多线程环境下可能会被多个线程同时修改。当一个线程修改了 table 数组时,volatile 关键字可以确保对其他线程可见,即其他线程能够立即看到 table 数组的更新,而不会使用过期的或者缓存的值。
synchronized :
put方法里面其实调用了putVal。putVal中有
synchronized (f) {...}
在链表中查找并插入新节点时,首先对桶进行加锁,然后遍历链表,查找对应的键值对。如果找到对应的键值对,则更新其值;如果未找到,则在链表末尾插入新节点。
treeifyBin(tab, i):
如果 binCount 大于等于预设的阈值 TREEIFY_THRESHOLD,则将
链表
转换为
红黑树
小结
而在 JDK 1.8 中,ConcurrentHashMap 放弃了分段锁,而是采用了更为精细的桶结构。每个桶可以独立加锁,使得并发修改操作可以更细粒度地进行。此外,当桶中的元素数量达到一定阈值时,链表结构会转变为红黑树,以减少搜索时间。这种锁分离技术提高了并发性能,使得 ConcurrentHashMap 在并发情况下表现更加出色。它是通过
CAS + synchronized
来实现线程安全的,并且它的锁粒度更小,查询性能也更高。( JDK 1.7 中,ConcurrentHashMap 使用了分段锁的结构,即将整个哈希表分成多个段(Segment),每个段有自己的锁。这样,多个修改操作可以并发进行,只要它们发生在不同的段上,相应的段才会加锁。这种设计减少了不同线程之间的竞争,提高了并发性能。)
昨天面试被问到了,当时回答支支吾吾,今天总结了一下,可能有不到位的。欢迎大家的指点
觉得有用点个赞
版权归原作者 hac1322 所有, 如有侵权,请联系我们删除。