集合的线程安全:CopyOnWriteArrayList、CopyOnWriteArraySet、ConcurrentHashMap
部分文案直接来自下面两篇博客:
这三个实现类都是来自juc并发包下
1、List集合线程不安全
1.1、演示
在讲解线程安全的之前,先讲解线程不安全的实例,以ArrayList为例,都知道ArrayList是一个线程不安全的实现类,尤其是在多线程并发条件下,对ArrayList集合里面存取元素都会带来线程不安全的问题。
演示:
package com.lemon.java;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
/**
* @Author Lemons
* @create 2022-02-25-20:04
*/
public class PropertiesTest {
public static void main(String[] args) {
//创建ArrayList集合
List<String> list = new ArrayList<>();
//创建30个子线程,在集合中添加元素获取元素
for (int i = 0; i <30; i++) {
new Thread(()->{
//向集合添加内容
list.add(UUID.randomUUID().toString().substring(0,8));
//从集合获取内容
System.out.println(list);
},String.valueOf(i)).start();
}
}
}
结果:报异常java.util.ConcurrentModificationException,该异常为并发修改问题
Exception in thread "27" java.util.ConcurrentModificationException
at java.util.ArrayList$Itr.checkForComodification(ArrayList.java:901)
at java.util.ArrayList$Itr.next(ArrayList.java:851)
at java.util.AbstractCollection.toString(AbstractCollection.java:461)
at java.lang.String.valueOf(String.java:2994)
at java.io.PrintStream.println(PrintStream.java:821)
at com.lemon.java.PropertiesTest.lambda$main$0(PropertiesTest.java:21)
at java.lang.Thread.run(Thread.java:748)
原因分析
查看源码,主要牵扯这个ArrayList的add方法是不安全的,没有synchronized声明
public boolean add(E e) {
ensureCapacityInternal(size + 1); // Increments modCount!!
elementData[size++] = e;
return true;
}
1.2、List集合线程不安全解决方案
List集合的解决方案有3种:
- Vector实现类
- Collections工具类
- CopyOnWriteArrayList
1.2.1、Vector实现类
Vector同ArrayList类似,作为List接口的古老实现类。底层使用Object[] elementData数组结构存储,可以存储重复元素,特性即功能与ArrayList类似,也具有增删慢,查询快的特点,不同的是,Vector是同步的,线程安全的,所以在多线程环境下,使用Vector比ArrayList更合适。
add方法源码
//添加元素,是同步方法
public synchronized boolean add(E e) {
modCount++;
ensureCapacityHelper(elementCount + 1);
elementData[elementCount++] = e;
return true;
}
因此修改如下:
package com.lemon.java;
import java.util.List;
import java.util.UUID;
import java.util.Vector;
/**
* @Author Lemons
* @create 2022-02-25-20:04
*/
public class PropertiesTest {
public static void main(String[] args) {
//创建Vector集合,解决线程不安全问题
List<String> list = new Vector<>();
//在集合中添加元素获取元素
for (int i = 0; i <30; i++) {
new Thread(()->{ //创建一个子线程
//向集合添加内容
list.add(UUID.randomUUID().toString().substring(0,8));
//从集合获取内容
System.out.println(list);
},String.valueOf(i)).start();
}
}
}
但此方法用的比较少,因为在jdk 1.0的版本适用
1.2.2、Collections工具类
Collections类中的很多方法都是static静态方法,其中有一个方法是返回指定列表支持的同步(线程安全的)列表为 synchronizedList(List list)
具体代码为
List<String> list = Collections.synchronizedList(new ArrayList<>());
此方法也比较古老,很少使用。
其实该工具类不止有List集合的方法,也有Map和Set集合的方法
1.2.3、CopyOnWriteArrayList
概述
我们都知道,集合框架中的ArrayList是非线程安全的,Vector虽是线程安全的,但由于简单粗暴的synchronized锁同步机制,所以性能较差。而CopyOnWriteArrayList则提供了另一种不同的并发处理策略(当然是针对特定的并发场景)。
CopyOnWriteArrayList是Java并发包中提供的一个并发容器,它是个线程安全且读操作无锁的ArrayList,写操作则通过创建底层数组的新副本来实现,是一种读写分离的并发策略,我们也可以称这种容器为"写时复制器",Java并发包中类似的容器还有CopyOnWriteSet。
将其代码修改为如下即可解决list集合不安全的问题
List<String> list = new CopyOnWriteArrayList<>();
底层原理分析
CopyOnWriteArrayList如何做到线程安全的
很多时候,我们的系统应对的都是读多写少的并发场景。CopyOnWriteArrayList容器允许并发的读,读操作是无锁的,性能较高。至于写操作,比如向容器中添加一个元素,则首先将当前容器复制一份,然后在新副本上执行写操作,结束之后再将原容器的引用指向新容器。这种就叫写时复制技术
图解:
CopyOnWriteArrayList的整个add操作都是在锁的保护下进行的。这样做是为了避免在多线程并发add的时候,复制出多个副本出来,把数据搞乱了,导致最终的数组数据不是我们期望的。我们来看看CopyOnWriteArrayList源码:
写操作
transient final ReentrantLock lock = new ReentrantLock();
/** The array, accessed only via getArray/setArray. */
private volatile transient Object[] array;//保证了线程的可见性
public boolean add(E e) {
final ReentrantLock lock = this.lock;//ReentrantLock 保证了线程的可见性和顺序性,即保证了多线程安全。
//1、先加锁
lock.lock();
try {
Object[] elements = getArray();
int len = elements.length;
//2、拷贝数组,在原先数组基础之上新建长度+1的数组,并将原先数组当中的内容拷贝到新数组当中。
Object[] newElements = Arrays.copyOf(elements, len + 1);
//3、将元素加入到新数组中
newElements[len] = e;
//4、将array引用指向到新数组
setArray(newElements);//对新数组进行赋值
return true;
} finally {
lock.unlock();
}
}
由于所有的写操作都是在新数组进行的,这个时候如果有线程并发的写,则通过Lock锁来控制,而不是synchronized锁,如果有线程并发的读,则分几种情况:
- 如果写操作未完成,那么直接读取原数组的数据;
- 如果写操作完成,但是引用还未指向新数组,那么也是读取原数组数据;
- 如果写操作完成,并且引用已经指向了新的数组,那么直接从新数组中读取数据。
读操作 :可见,CopyOnWriteArrayList的读操作是可以不用加锁的。
//直接读取即可,无需加锁
public E get(int index) {
return get(getArray(), index);
}
private E get(Object[] a, int index) {
return (E) a[index];
}
优点:
读操作性能很高,因为无需任何同步措施,比较适用于读多写少的并发场景。Java的list在遍历时,若中途有别的线程对list容器进行修改,则会抛出ConcurrentModificationException异常。而CopyOnWriteArrayList由于其"读写分离"的思想,遍历和修改操作分别作用在不同的list容器,所以在使用迭代器进行遍历时候,也就不会抛出ConcurrentModificationException异常了
缺点:
缺点也很明显,一是内存占用问题,毕竟每次执行写操作都要将原容器拷贝一份,数据量大时,对内存压力较大,可能会引起频繁GC;二是无法保证实时性,Vector对于读写操作均加锁同步,可以保证读和写的强一致性。而CopyOnWriteArrayList由于其实现策略的原因,写和读分别作用在新老不同容器上,在写操作执行过程中,读不会阻塞但读取到的却是老容器的数据。
Collections.synchronizedList & CopyOnWriteArrayList
CopyOnWriteArrayList和Collections.synchronizedList是实现线程安全的列表的两种方式。两种实现方式分别针对不同情况有不同的性能表现
- 其中
CopyOnWriteArrayList的写操作性能较差,而多线程的读操作性能较好。 - 而
Collections.synchronizedList的写操作性能比CopyOnWriteArrayList在多线程操作的情况下要好很多,而读操作因为是采用了synchronized关键字的方式,其读操作性能并不如CopyOnWriteArrayList。因此在不同的应用场景下,应该选择不同的多线程安全实现类。
2、Set集合线程不安全
2.1、演示
以HashSet为例,都知道HashSet是一个线程不安全的实现类,尤其是在多线程并发条件下,对HashSet集合里面存取元素都会带来线程不安全的问题。
public class PropertiesTest {
public static void main(String[] args) {
//创建HashSet集合
Set<String> set = new HashSet<>();
//创建30个子线程,在集合中添加元素获取元素
for (int i = 0; i <30; i++) {
new Thread(()->{
//向集合添加内容
set.add(UUID.randomUUID().toString().substring(0,8));
//从集合获取内容
System.out.println(set);
},String.valueOf(i)).start();
}
}
}
2.2、解决方案:CopyOnWriteArraySet
需要将上面的代码改为
Set<String> set = new CopyOnWriteArraySet<>();
3、Map集合线程不安全
3.1、演示
以HashMap为例,都知道HashMap是一个线程不安全的实现类,尤其是在多线程并发条件下,对HashMap集合里面存取元素都会带来线程不安全的问题。
public class PropertiesTest {
public static void main(String[] args) {
//创建HashMap集合
Map<String,String> map = new HashMap<>();
//创建30个子线程,在集合中添加元素获取元素
for (int i = 0; i <30; i++) {
new Thread(()->{
//向集合添加内容
map.add(UUID.randomUUID().toString().substring(0,8));
//从集合获取内容
System.out.println(map);
},String.valueOf(i)).start();
}
}
}
3.2、解决方案:ConcurrentHashMap
需要将上面的代码改为
Map<String,String> map = new ConcurrentHashMap<>();
通过这行代码可以编程线程安全,ConcurrentHashMap类是HashMap的实现类。
3.3、ConcurrentHashMap原理分析
哈希表是中非常高效,复杂度为O(1)的数据结构,在Java开发中,我们最常见到最频繁使用的就是HashMap和HashTable,但是在线程竞争激烈的并发场景中使用都不够合理。
HashMap :先说HashMap,HashMap是线程不安全的,在并发环境下,可能会形成环状链表(扩容时可能造成,导致get操作时,cpu空转,put操作时存在丢失数据的情况 。所以,在并发环境中使用HashMap是非常危险的。
HashTable : HashTable和HashMap的实现原理几乎一样,差别无非是
- HashTable不允许key和value为null
- HashTable是线程安全的。
但是HashTable线程安全的策略实现代价却太大了,简单粗暴,get/put所有相关操作都是synchronized的,这相当于给整个哈希表加了一把大锁,多线程访问时候,只要有一个线程访问或操作该对象,那其他线程只能阻塞,相当于将所有的操作串行化,在竞争激烈的并发场景中性能就会非常差。
HashTable性能差主要是由于所有读写操作需要竞争同一把锁,导致效率非常低下。而如果容器中有多把锁,每一把锁锁一段数据,这样在多线程访问时不同段的数据时,就不会存在锁竞争了,这样便可以有效地提高并发效率。这就是ConcurrentHashMap所采用的"分段锁"思想。
ConcurrentHashMap可以做到读取数据不加锁,并且其内部的结构可以让其在进行写操作的时候能够将锁的粒度保持地尽量地小,允许多个线程的修改操作并发进行,其关键在于使用了锁分段技术。它使用了多个锁来控制对hash表的不同部分进行的修改。对于JDK1.7版本的实现, ConcurrentHashMap内部使用段(Segment)来表示hash表这些一段段不同的部分,每个段其实就是一个小的Hashtable,它们有自己的锁。只要多个修改操作发生在不同的段上,它们就可以并发进行。JDK1.8的实现降低锁的粒度,JDK1.7版本锁的粒度是基于Segment的,包含多个HashEntry,而JDK1.8锁的粒度就是HashEntry(首节点)。
ConcurrentHashMap源码分析
ConcurrentHashMap采用了非常精妙的 "分段锁" 策略,即将整个哈希表分成多个子哈希表,ConcurrentHashMap的底层是个Segment数组。
final Segment<K,V>[] segments;
Segment继承了ReentrantLock,所以它就是一种可重入锁(ReentrantLock)。在ConcurrentHashMap中,一个Segment就是一个子哈希表,Segment里维护了一个HashEntry 链表数组,并发环境下,对于不同Segment的数据进行操作是不用考虑锁竞争的。(就按默认的ConcurrentLeve为16来讲,理论上就允许16个线程并发执行)
所以,对于同一个Segment的操作才需考虑线程同步,不同的Segment则无需考虑。
Segment类似于HashMap,一个Segment维护着一个HashEntry链表数组
transient volatile HashEntry<K,V>[] table;
HashEntry是目前我们提到的最小的逻辑处理单元了。一个ConcurrentHashMap维护一个Segment数组,一个Segment维护一个HashEntry数组。
static final class HashEntry<K,V> {
final int hash;
final K key;
volatile V value;
volatile HashEntry<K,V> next;
//其他省略
}
我们说Segment类似哈希表,那么一些属性就跟我们之前提到的HashMap差不多,比如负载因子loadFactor,比如阈值threshold等等,看下Segment的构造方法
Segment(float lf, int threshold, HashEntry<K,V>[] tab) {
this.loadFactor = lf;//负载因子
this.threshold = threshold;//阈值
this.table = tab;//主干数组即HashEntry数组
}
ConcurrentHashMap的构造方法
public ConcurrentHashMap(int initialCapacity,
float loadFactor, int concurrencyLevel) {
if (!(loadFactor > 0) || initialCapacity < 0 || concurrencyLevel <= 0)
throw new IllegalArgumentException();
//MAX_SEGMENTS 为1<<16=65536,也就是最大并发数为65536
if (concurrencyLevel > MAX_SEGMENTS)
concurrencyLevel = MAX_SEGMENTS;
//2的sshif次方等于ssize,例:ssize=16,sshift=4;ssize=32,sshif=5
int sshift = 0;
//ssize 为segments数组长度,根据concurrentLevel计算得出
int ssize = 1;
while (ssize < concurrencyLevel) {
++sshift;
ssize <<= 1;
}
//segmentShift和segmentMask这两个变量在定位segment时会用到,后面会详细讲
this.segmentShift = 32 - sshift;
this.segmentMask = ssize - 1;
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
//计算cap的大小,即Segment中HashEntry的数组长度,cap也一定为2的n次方.
int c = initialCapacity / ssize;
if (c * ssize < initialCapacity)
++c;
int cap = MIN_SEGMENT_TABLE_CAPACITY;
while (cap < c)
cap <<= 1;
//创建segments数组并初始化第一个Segment,其余的Segment延迟初始化
Segment<K,V> s0 =
new Segment<K,V>(loadFactor, (int)(cap * loadFactor),
(HashEntry<K,V>[])new HashEntry[cap]);
Segment<K,V>[] ss = (Segment<K,V>[])new Segment[ssize];
UNSAFE.putOrderedObject(ss, SBASE, s0);
this.segments = ss;
}
该初始化方法有三个参数,如果用户不指定则会使用默认值,initialCapacity为16,loadFactor为0.75(负载因子,扩容时需要参考),concurrentLevel为16。
从上面的代码可以看出来,Segment数组的大小ssize是由concurrentLevel来决定的,但是却不一定等于concurrentLevel,ssize一定是大于或等于concurrentLevel的最小的2的次幂。比如:默认情况下concurrentLevel是16,则ssize为16;若concurrentLevel为14,ssize为16;若concurrentLevel为17,则ssize为32。为什么Segment的数组大小一定是2的次幂?其实主要是便于通过按位与的散列算法来定位Segment的index。
put方法
public V put(K key, V value) {
Segment<K,V> s;
//concurrentHashMap不允许key/value为空
if (value == null)
throw new NullPointerException();
//hash函数对key的hashCode重新散列,避免差劲的不合理的hashcode,保证散列均匀
int hash = hash(key);
//返回的hash值无符号右移segmentShift位与段掩码进行位运算,定位segment
int j = (hash >>> segmentShift) & segmentMask;
if ((s = (Segment<K,V>)UNSAFE.getObject // nonvolatile; recheck
(segments, (j << SSHIFT) + SBASE)) == null) // in ensureSegment
s = ensureSegment(j);
return s.put(key, hash, value, false);
}
从源码看出,put的主要逻辑也就两步:
- 定位segment并确保定位的Segment已初始化
- 调用Segment的put方法。
ConcurrentHashMap 加入一个元素的过程需要进行两次Hash操作,第一次 Hash 定位到 Segment,第二次 Hash 定位到元素所在的链表的头部,因此,这一种结构的带来的副作用是 Hash 的过程要比普通的 HashMap 要长,但是带来的好处是写操作的时候可以只对元素所在的 Segment 进行操作即可,不会影响到其他的 Segment,这样,在最理想的情况下,ConcurrentHashMap 可以最高同时支持 Segment 数量大小的写操作(刚好这些写操作都非常平均地分布在所有的 Segment上),所以,通过这一种结构,ConcurrentHashMap 的并发能力可以大大的提高。
为什么要用二次hash,主要原因是为了构造分离锁,使得对于map的修改不会锁住整个容器,提高并发能力。当然,没有一种东西是绝对完美的,二次hash带来的问题是整个hash的过程比hashmap单次hash要长,所以,如果不是并发情形,不要使concurrentHashmap。
JAVA7之前ConcurrentHashMap主要采用锁机制,在对某个Segment进行操作时,将该Segment锁定,不允许对其进行非查询操作,而在JAVA8之后采用CAS无锁算法,这种乐观操作在完成前进行判断,如果符合预期结果才给予执行,对并发操作提供良好的优化.
get方法
public V get(Object key) {
Segment<K,V> s;
HashEntry<K,V>[] tab;
int h = hash(key);
long u = (((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE;
//先定位Segment,再定位HashEntry
if ((s = (Segment<K,V>)UNSAFE.getObjectVolatile(segments, u)) != null &&
(tab = s.table) != null) {
for (HashEntry<K,V> e = (HashEntry<K,V>) UNSAFE.getObjectVolatile
(tab, ((long)(((tab.length - 1) & h)) << TSHIFT) + TBASE);
e != null; e = e.next) {
K k;
if ((k = e.key) == key || (e.hash == h && key.equals(k)))
return e.value;
}
}
return null;
}
get方法无需加锁,由于其中涉及到的共享变量都使用volatile修饰,volatile可以保证内存可见性,所以不会读取到过期数据。
3.4、面试题
1. ConcurrentHashMap中变量使用final和volatile修饰有什么用呢?
Final域使得确保初始化安全性(initialization safety)成为可能,初始化安全性让不可变形对象不需要同步就能自由地被访问和共享。 使用volatile来保证某个变量内存的改变对其他线程即时可见,在配合CAS可以实现不加锁对并发操作的支持。get操作可以无锁是由于Node的元素val和指针next是用volatile修饰的,在多线程环境下线程A修改结点的val或者新增节点的时候是对线程B可见的。
2.我们可以使用CocurrentHashMap来代替Hashtable吗?
我们知道Hashtable是synchronized的,但是ConcurrentHashMap同步性能更好,因为它仅仅根据同步级别对map的一部分进行上锁。ConcurrentHashMap当然可以代替HashTable,但是HashTable提供更强的线程安全性。它们都可以用于多线程的环境,但是当Hashtable的大小增加到一定的时候,性能会急剧下降,因为迭代时需要被锁定很长的时间。因为ConcurrentHashMap引入了分割(segmentation),不论它变得多么大,仅仅需要锁定map的某个部分,而其它的线程不需要等到迭代完成才能访问map。简而言之,在迭代的过程中,ConcurrentHashMap仅仅锁定map的某个部分,而Hashtable则会锁定整个map。
3. ConcurrentHashMap有什么缺陷吗?
ConcurrentHashMap 是设计为非阻塞的。在更新时会局部锁住某部分数据,但不会把整个表都锁住。同步读取操作则是完全非阻塞的。好处是在保证合理的同步前提下,效率很高。坏处是严格来说读取操作不能保证反映最近的更新。例如线程A调用putAll写入大量数据,期间线程B调用get,则只能get到目前为止已经顺利插入的部分数据。
4. ConcurrentHashMap在JDK 7和8之间的区别
- JDK1.8的实现降低锁的粒度,JDK1.7版本锁的粒度是基于Segment的,包含多个HashEntry,而JDK1.8锁的粒度就是HashEntry(首节点)
- JDK1.8版本的数据结构变得更加简单,使得操作也更加清晰流畅,因为已经使用synchronized来进行同步,所以不需要分段锁的概念,也就不需要Segment这种数据结构了,由于粒度的降低,实现的复杂度也增加了
- JDK1.8使用红黑树来优化链表,基于长度很长的链表的遍历是一个很漫长的过程,而红黑树的遍历效率是很快的,代替一定阈值的链表,这样形成一个最佳拍档
4、补充:UUID
这部分已经在Mysql数据库也讲过,现在简单介绍一下
UUID是1.5中新增的一个类 ,UUID 是 通用唯一识别码(Universally Unique Identifier)的缩写,主要是让让分布式系统中的所有元素,都能有全球唯一的辨识信息,比如生成主键id。为了提高效率,常用的UUID可缩短至16位比特数值,使用UUID的一个好处是可以为新的服务创建新的标识符,UUID的唯一缺陷在于生成的结果串会比较长。关于UUID这个标准使用最普遍的是微软的GUID(Globals Unique Identifiers)。
在Java中,可以这样生成UUID
UUID uuid = UUID.randomUUID();String s = UUID.randomUUID().toString();,这个用来生成数据库的主键id非常不错。。
public class UTest {
public static void main(String[] args) {
UUID uuid = UUID.randomUUID();
System.out.println(uuid);
String s = UUID.randomUUID().toString();//javaJDK提供的一个自动生成主键的方法
System.out.println(s);
}
}
结果:
68ed58f5-e53b-406a-b046-1922f6fcc191
40ad5378-0b8a-4c8f-b637-df0cc7e5a825
生成的UUID标识符16位具体信息如下:
-
当前日期和时间,值第一部分与时间有关(如果你在生成一个UUID之后,过几秒又生成一个UUID,则第一个部分不同,其余相同)
-
时钟序列
-
全局唯一的IEEE机器识别号(如果有网卡,从网卡MAC地址获得,没有网卡以其他方式获得)
randomUUID源码
public static UUID randomUUID() {
SecureRandom ng = Holder.numberGenerator;
byte[] randomBytes = new byte[16];
ng.nextBytes(randomBytes);
randomBytes[6] &= 0x0f; /* clear version */
randomBytes[6] |= 0x40; /* set to version 4 */
randomBytes[8] &= 0x3f; /* clear variant */
randomBytes[8] |= 0x80; /* set to IETF variant */
return new UUID(randomBytes);
}
实战:生成UUID
package com.lemon.java;
import java.util.UUID;
/**
* @Author Lemons
* @create 2022-02-25-20:04
*/
public class UUIDGenerator {
public UUIDGenerator() {
}
public static String getUUID() {
String str = UUID.randomUUID().toString();
// 去掉"-"符号
String temp = str.substring(0, 8) + str.substring(9, 13) + str.substring(14, 18) + str.substring(19, 23) + str.substring(24);
return str+","+temp;
}
//获得指定数量的UUID
public static String[] getUUID(int number) {
if (number < 1) {
return null;
}
String[] ss = new String[number];
for (int i = 0; i < number; i++) {
ss[i] = getUUID();
}
return ss;
}
public static void main(String[] args) {
String[] ss = getUUID(10);
for (int i = 0; i < ss.length; i++) {
System.out.println("ss["+i+"]====="+ss[i]);
}
}
}
结果
ss[0]=====2cb4d4eb-6f41-4271-b763-f36e3129ccd0,2cb4d4eb6f414271b763f36e3129ccd0
ss[1]=====3e6a3a98-8cf2-479c-9397-6da63940d86e,3e6a3a988cf2479c93976da63940d86e
ss[2]=====76f7b9ba-455c-401b-a069-930c1a658351,76f7b9ba455c401ba069930c1a658351
ss[3]=====9a6346c8-865c-40a9-9609-0bb4019300bc,9a6346c8865c40a996090bb4019300bc
ss[4]=====b0398ff0-12f8-42bc-90d5-7a33248ef07d,b0398ff012f842bc90d57a33248ef07d
ss[5]=====7736f1f6-49e0-41b7-ae0f-966a17f0cf7e,7736f1f649e041b7ae0f966a17f0cf7e
ss[6]=====d22f5741-4c01-4f91-997b-408d52a010b0,d22f57414c014f91997b408d52a010b0
ss[7]=====5b9219bd-f8e3-45bb-83cd-7f2914fee833,5b9219bdf8e345bb83cd7f2914fee833
ss[8]=====72116b88-e421-434f-b394-30d550ed0884,72116b88e421434fb39430d550ed0884
ss[9]=====c6118e9e-3383-40c0-8121-e7f8c2435bf6,c6118e9e338340c08121e7f8c2435bf6