JUC高并发-集合线程安全问题与解决方案

  1. 1. 一、ArrayList线程安全问题
  2. 2. 二、ArrayList线程安全问题解决方案:
    1. 2.1. 2.1 Vector
    2. 2.2. 2.2 Collections工具类
    3. 2.3. 2.3 * CopyOnWriteArrayList
      1. 2.3.1. 2.3.1 什么是CopyOnWrite容器
      2. 2.3.2. 2.3.2 源码:
      3. 2.3.3. 2.3.3 底层原理:
      4. 2.3.4. 2.3.4 CopyOnWrite的缺点
  3. 3. 三、Hashset线程不安全
    1. 3.1. demo:
    2. 3.2. 解决方案CopyOnWriteArraySet:
  4. 4. 四、HashMap线程不安全
    1. 4.1. demo:
    2. 4.2. 解决方案ConcurrentHashMap:

一、ArrayList线程安全问题

打开ArrayList的源码可以看到,add方法里面并没有synchronize关键字,即线程不安全的,当我们使用多线程进行操作的时候,可能会报错ConcurrentModificationException(并发修改异常)错误。

源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class ThreadDemo3 {
public static void main(String[] args) {
List<String> list = new ArrayList<>();

for (int i = 0; i < 30; i++) {
new Thread(()-> {
// 向集合中添加内容
list.add(UUID.randomUUID().toString().substring(0,4));
// 从集合中获取内容
System.out.println(list);
}, String.valueOf(i)).start();
}
}
}

运行截图:

二、ArrayList线程安全问题解决方案:

2.1 Vector

写法大致与ArrayList一致:

1
List<String> list = new Vector<>();

多次运行并没出现报错。

查看源码可发现有synchronize关键字:

Vector是一个线程安全的List, 但是它的线程安全实现方式是对所有操作都加上了synchronized关键字,这种方式严重影响效率。所以并不推荐使用Vector。

2.2 Collections工具类

方案二是使用Collections工具类synchronizedList方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class ThreadDemo3 {
public static void main(String[] args) {
List<String> list = Collections.synchronizedList(new ArrayList<>());

for (int i = 0; i < 30; i++) {
new Thread(()-> {
// 向集合中添加内容
list.add(UUID.randomUUID().toString().substring(0,4));
// 从集合中获取内容
System.out.println(list);
}, String.valueOf(i)).start();
}
}
}

但这两种方案都比较古老,但也可以解决问题。更推荐的是第三中方案,来自 JUC 的 CopyOnWriteArrayList。

2.3 * CopyOnWriteArrayList

2.3.1 什么是CopyOnWrite容器

  CopyOnWrite容器即写时复制的容器。通俗的理解是当我们往一个容器添加元素的时候,不直接往当前容器添加,而是先将当前容器进行Copy,复制出一个新的容器,然后新的容器里添加元素,添加完元素之后,再将原容器的引用指向新的容器。这样做的好处是我们可以对CopyOnWrite容器进行并发的读,而不需要加锁,因为当前容器不会添加任何元素。所以CopyOnWrite容器也是一种读写分离的思想,读和写不同的容器。

2.3.2 源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
private transient volatile Object[] array;

public boolean add(E e) {
final ReentrantLock lock = this.lock;
// 1 加锁(synchronized )。保证此时只有一个线程进入
lock.lock();
try {
// 2 获取原来的数组以及长度
Object[] elements = getArray();
int len = elements.length;
// 3 复制新数据,并且长度+1
Object[] newElements = Arrays.copyOf(elements, len + 1);
newElements[len] = e;
// 4 设置新数据(array使用volatile修饰,所以其他线程可见),覆盖原来的数组
setArray(newElements);
// 5 返回
return true;
} finally {
lock.unlock();
}
}

final Object[] getArray() {
return array;
}

final void setArray(Object[] a) {
array = a;
}

说明:setArray()的作用是给array赋值;其中,array是volatile transient
Object[]类型,即array是“volatile数组”。
关于volatile关键字,我们知道“volatile能让变量变得可见”,即对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入。正在由于这种特性,每次更新了“volatile数组”之后,其它线程都能看到对它所做的更新。
关于transient关键字,它是在序列化中才起作用,transient变量不会被自动序列化。

2.3.3 底层原理:

  1. CopyOnWriteArrayList实现了List接口,因此它是一个队列。
  2. CopyOnWriteArrayList包含了成员lock。每一个CopyOnWriteArrayList都和一个监视器锁lock绑定,通过lock,实现了对CopyOnWriteArrayList的互斥访问。
  3. CopyOnWriteArrayList包含了成员array数组,这说明CopyOnWriteArrayList本质上通过数组实现的。

4.CopyOnWriteArrayList的“动态数组”机制 – 它内部有个“volatile数组”(array)来保持数据。在“添加/修改/删除”数据时,都会新建一个数组,并将更新后的数据拷贝到新建的数组中,最后再将该数组赋值给“volatile数组”。这就是它叫做CopyOnWriteArrayList的原因!CopyOnWriteArrayList就是通过这种方式实现的动态数组;不过正由于它在“添加/修改/删除”数据时,都会新建数组,所以涉及到修改数据的操作,CopyOnWriteArrayList效率很低;但是单单只是进行遍历查找的话,效率比较高。
5.CopyOnWriteArrayList的“线程安全”机制 – 是通过volatile和监视器锁Synchrnoized来实现的。
6.CopyOnWriteArrayList是通过“volatile数组”来保存数据的。一个线程读取volatile数组时,总能看到其它线程对该volatile变量最后的写入;就这样,通过volatile提供了“读取到的数据总是最新的”这个机制的 保证。
7.CopyOnWriteArrayList通过监视器锁Synchrnoized来保护数据。在“添加/修改/删除”数据时,会先“获取监视器锁”,再修改完毕之后,先将数据更新到“volatile数组”中,然后再“释放互斥锁”;这样,就达到了保护数据的目的。

2.3.4 CopyOnWrite的缺点

CopyOnWrite容器有很多优点,但是同时也存在两个问题,即内存占用问题和数据一致性问题。所以在开发的时候需要注意一下。

1.数据一致性问题。CopyOnWrite容器只能保证数据的最终一致性,不能保证数据的实时一致性。所以如果你希望写入的的数据,马上能读到,请不要使用CopyOnWrite容器。【当执行add或remove操作没完成时,get获取的仍然是旧数组的元素】

2.CopyOnWriteArrayList采用“写入时复制”策略,对容器的写操作将导致的容器中基本数组的复制,性能开销较大。所以在有写操作的情况下,CopyOnWriteArrayList性能不佳,而且如果容器容量较大的话容易造成溢出。

3.适用于数据量不大的场景,不适用于数据量大的场景。由于写操作的时候,需要拷贝数组,会消耗内存,如果原数组的内容比较多的情况下,可能导致young gc或者full gc。

4.CopyOnWriteArrayList在写操作中,使用了ReentrantLock锁以保证线程安全,并替换原array属性;但是读的时候直接读取array,可能会发生在写操作替换array前后。这就会导致读到旧数据,引发不一致。

1、如果写操作未完成,那么直接读取原数组的数据;
2、如果写操作完成,但是引用还未指向新数组,那么也是读取原数组数据;
3、如果写操作完成,并且引用已经指向了新的数组,那么直接从新数组中读取数据。

三、Hashset线程不安全

​ Hashset它是线程安全的无序的集合,可以将它理解成线程安全的HashSet。有意思的是,CopyOnWriteArraySet和HashSet虽然都继承于共同的父类AbstractSet;但是,HashSet是通过“散列表(HashMap)”实现的,而CopyOnWriteArraySet则是通过“动态数组(CopyOnWriteArrayList)”实现的,并不是散列表。

demo:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class ThreadDemo3 {
public static void main(String[] args) {
Set<String> set = new CopyOnWriteArraySet<>();

for (int i = 0; i < 30; i++) {
new Thread(()-> {
// 向集合中添加内容
set.add(UUID.randomUUID().toString().substring(0,4));
// 从集合中获取内容
System.out.println(set);
}, String.valueOf(i)).start();
}
}
}

解决方案CopyOnWriteArraySet:

使用CopyOnWriteArraySet。

四、HashMap线程不安全

demo:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class ThreadDemo3 {
public static void main(String[] args) {
Map<String, String> map = new ConcurrentHashMap<>();

for (int i = 0; i < 30; i++) {
String key = String.valueOf(i);
new Thread(()-> {
// 向集合中添加内容
map.put(key, UUID.randomUUID().toString().substring(0,4));
// 从集合中获取内容
System.out.println(map);
}, String.valueOf(i)).start();
}
}
}

解决方案ConcurrentHashMap:

使用ConcurrentHashMap。

ConcurrentHashMap是 J.U.C (java.util.concurrent包)的重要成员,它是HashMap的一个线程安全的、支持高效并发的版本。

基于 JDK1.8的ConcurrentHashMap原理 大牛博客:https://www.cnblogs.com/ylspace/p/12726672.html