现在属于一个查漏补缺的阶段,之前京东的面试中问到我,关于多线程操作集合时,集合不安全该如何解决?
当时就只想到了(可能因为紧张,我承认比较菜 )使用使用实现安全的集合类和使用Collections。synchronizedxxx的集合安全类来解决,现在回想起来自己当时的确回答的不好,这两种方式并不能保证“真正的线程安全”。查漏补缺,将掌握不熟练的知识一定要多练习,返回回顾知识,熟能生巧。
而且最近越来越感受到,每当看一些曾经掌握的知识的源码,就发现那些前辈真的很厉害,有一些设计很巧妙。
List集合不安全
当前企业的开发都会考虑到高并发的问题,博主我在今年秋招时也被多次问到集合类在多线程中使用的问题。这部分还是很重要的。
我们熟悉的ArrayList、HashMap等等集合类很多都不是线程安全的。也就是说在多线程情况下是可能造成多线程问题,因此有需求也必须
让使用的集合类变安全。
通常能够想到的使用多线程安全的集合类的方式有三种:
- 直接使用本身就是线程安全的集合类,比如使用Vector,HashTable等等。
- 使用集合类工具类Collections类下的工具类对集合类进行修饰。
- 使用JUC包下的多线程安全集合类,如CopyOnWriteArrayList等。
接下来,我将三种不同的多线程安全的使用集合类做代码演示。
先演示一下普通的集合类会造成多线程安全问题
package jucTest2;
import java.util.ArrayList;
import java.util.UUID;
/**
* @author 雷雨
* @date 2020/12/4 17:02
* 集合类在多线程下操作的不安全性
*/
public class UnFireCollectionDemo {
public static void main(String[] args) {
ArrayList<String> arrayList = new ArrayList<>();
new Thread(()->{
for (int i = 0; i < 10; i++) {
arrayList.add(UUID.randomUUID().toString().substring(0,5));
System.out.println(arrayList);
}
},"A线程").start();
new Thread(()->{
for (int i = 0; i < 10; i++) {
arrayList.add(UUID.randomUUID().toString().substring(0,5));
System.out.println(arrayList);
}
},"B线程").start();
}
}
出现了java.util.ConcurrentModificationException
异常(同步修改异常),也就是说在多线程同时操作集合时出现了多线程操作的问题。
方式1:直接使用多线程安全的集合类
package jucTest2;
import java.util.UUID;
import java.util.Vector;
/**
* @author 雷雨
* @date 2020/12/4 16:55
* 直接使用多线程安全的集合类
*/
public class FireCollectionDemo1 {
public static void main(String[] args) {
Vector<String> vector = new Vector<>();
new Thread(()->{
for (int i = 1; i <= 10 ; i++) {
vector.add(UUID.randomUUID().toString().substring(0,5));
System.out.println(vector);
}
},"线程A").start();
new Thread(()->{
for (int i = 1; i <= 10 ; i++) {
vector.add(UUID.randomUUID().toString().substring(0,5));
System.out.println(vector);
}
},"线程B").start();
}
}
有观察结果会发现:没有出现异常。也就是说在集合Vector进行多线程操作时没有发生多线程问题。
为什么Vector是线程安全的?
简单的讲,Vector是线程安全的,因为Vector中的每个方法都使用了synchronized修饰,从而保证访问 vector 的任何方法都必须获得对象的 intrinsic lock (或叫 monitor lock),也即,在vector内部,其所有方法不会被多线程所访问。
Vector一定不存在多线程安全问题吗?
if (!vector.contains(element))
vector.add(element);
...
}
其实Vector也可能存在多线程安全安全问题,虽然在Vector的内部的方法都使用了synchronized修饰,保证了在Vector内部使用时,Vector是线程安全的,但是如果如上述代码所示,是在外部环境中使用的,仍然存在锁竞争,对应上述代码,虽然contains和add方法都是原子性的操作,但是在if条件判断为真之后,关于contains的锁释放了,在多线程的环境中,其他线程有可能与add线程竞争并获取了锁资源后修改了其状态,而add线程在当时正在等待,只有其他线程释放锁资源后,add线程拿到了锁,add线程才执行(而在add方法执行时,它已经是基于一个错误的假设了)。
单个方法的synchronized了并不代表组合方法调用的原子性。
如何回答Vector是否是线程安全的集合类?
Vector 和 ArrayList 实现了同一接口 List, 但所有的 Vector 的方法都具有 synchronized 关键修饰。但对于复合操作,Vector 仍然需要进行同步处理。
方式2:使用Collections工具类修饰的集合类
package jucTest2;
import java.util.*;
/**
* @author 雷雨
* @date 2020/12/4 17:28
* 直接使用多线程安全的集合类
*/
public class FireCollectionDemo2 {
public static void main(String[] args) {
List<String> list = Collections.synchronizedList(new ArrayList<String>());
new Thread(()->{
for (int i = 1; i <= 10 ; i++) {
list.add(UUID.randomUUID().toString().substring(0,5));
System.out.println(list);
}
},"线程A").start();
new Thread(()->{
for (int i = 1; i <= 10 ; i++) {
list.add(UUID.randomUUID().toString().substring(0,5));
System.out.println(list);
}
},"线程B").start();
}
}
结果正常输出,没有发生多线程安全问题。
关于Collections.synchronizedList和Vector的区别:
- 在源码中Vector中线程安全的实现是使用了synchronized锁住了整个方法(也就是使用了同步方法的方式),而在Collections.synchronizedList中是使用synchronized锁了当前的mutex对象,而mutex对象指向的是当前的实例。
- 那么Vector锁的对象是调用者,而Collections.synchronizedList锁的是synchronizedList本身的实例对象。
方式3:使用集合安全类
比如使用CopyOnWriteArrayList
package jucTest2;
import java.util.UUID;
import java.util.concurrent.CopyOnWriteArrayList;
/**
* @author 雷雨
* @date 2020年12月4日20:45:23
* 直接使用多线程安全的集合类CopyOnWriteArratList
*/
public class FireCollectionDemo3 {
public static void main(String[] args) {
CopyOnWriteArrayList<String> list = new CopyOnWriteArrayList<>();
new Thread(()->{
for (int i = 1; i <= 10 ; i++) {
list.add(UUID.randomUUID().toString().substring(0,5));
System.out.println(list);
}
},"线程A").start();
new Thread(()->{
for (int i = 1; i <= 10 ; i++) {
list.add(UUID.randomUUID().toString().substring(0,5));
System.out.println(list);
}
},"线程B").start();
}
}
多次运行,发现没有出现异常,该集合类是一个多线程安全的类。
CopyOnWriteArrayList是如何保证线程安全的?
CopyOnWriteArrayList直接翻译就是写的时候复制,也就是说在写操作的时候是创建一个新的容器进行写操作,写完之后,再将原容器的引用指向新容器,整个过程加锁,保证了写的线程安全。
整个过程都使用Lock加锁,是线程安全的
//添加元素操作
public boolean add(E e) {
final ReentrantLock lock = this.lock;
//加锁
lock.lock();
try {
Object[] elements = getArray();
int len = elements.length;
//复制
Object[] newElements = Arrays.copyOf(elements, len + 1);
newElements[len] = e;
//将array引用指向新数组
setArray(newElements);
return true;
} finally {
//解锁
lock.unlock();
}
}
//删除操作
public E remove(int index) {
final ReentrantLock lock = this.lock;
lock.lock();
try {
Object[] elements = getArray();
int len = elements.length;
E oldValue = get(elements, index);
int numMoved = len - index - 1;
if (numMoved == 0)
setArray(Arrays.copyOf(elements, len - 1));
else {
Object[] newElements = new Object[len - 1];
System.arraycopy(elements, 0, newElements, 0, index);
System.arraycopy(elements, index + 1, newElements, index,
numMoved);
setArray(newElements);
}
return oldValue;
} finally {
lock.unlock();
}
}
而因为读操作的时候不会对当前容器做任何处理,所以我们可以对容器进行并发的读,而不需要加锁,也就是读写分离。
public E get(int index) {
return get(getArray(), index);
}
CopyOnWriteArrayList中可能出现的问题:
CopyOnWriteArrayList虽然实现了读写分离,提高了效率,并且在需要写操作的地方使用了ReentrantLock保证了线程的同步,但是仍然是存在问题的:
- 由于写操作是通过复制原数组,会消耗内存,如果原数组的数据量较大,可能会导致频繁的minor GC。
- 不能用于
实时性
的场景,因为是读写分离的,而且在写操作中采用的方式是通过复制写的操作,那么就会有耗时,可能会在写入数据的过程中,有读取的操作,那么可能导致读取的数据还是旧的数据。CopyOnWriteArrayList
能保证最终一致性
,但是却不能满足实时性的要求。
从第二点也就说明了CopyOnWriteArrayList其实比较适用于读多写少
的场景,但是还是慎用,不能保证每次写入的操作的数据量,可能会导致读取到旧的数据的可能性。
小结:
CopyOnWriteArrayList的思想:
1、读写分离,读和写分开
2、最终一致性
3、使用另外开辟空间的思路,来解决并发冲突
CopyOnWriteArrayList内部时如何实现的(梳理版)?
- 写操作的实现
再梳理一遍源码(以添加操作为例)
public boolean add(E e) {
final ReentrantLock lock = this.lock;
//加锁
lock.lock();
try {
Object[] elements = getArray();
int len = elements.length;
//复制
Object[] newElements = Arrays.copyOf(elements, len + 1);
newElements[len] = e;
//将array引用指向新数组
setArray(newElements);
return true;
} finally {
//解锁
lock.unlock();
}
}
首先CopyOnWriteArrayList的写操作的实现:
- 首先在写操作的内部创建了一个ReentrantLock同步锁。
- 另外在写操作,在加锁的情况下还使用了复制写的思想。复制一个新的数据,添加元素之后,将引用指向新数组。
为什么要使用ReentrantLock?
是为了保证多线程的同步操作。对于多线程同步,采用加锁,能够解决多线程的问题。
为什么要使用复制写的操作?
因为关于集合的操作不仅有写操作还有读操作,如何不采用复制写的思想,那么就要对读操作也要加锁,不然就可能会造成线程安全问题(锁竞争机制)。但是CopyOnWriteArrayList为了保证读写分离(保证读的操作的效率),因此没有对读操作加锁。
如果没有复制,写时加锁,读取不加锁,那么就会造成并发读写问题,产生不可预期的错误,造成ConcurrentModificationException问题。(是因为为了保证并发读写的安全性,在集合中维护了一个ModConcurrent用来计数集合修改次数)如果在写时,进行了读取操作,ModConcurrent变化了,就会抛出ConcurrentModificationException。
- 可能会问,为什么CopyOnWriteArrayList中采用写操作,读不加锁?
如果写操作加锁,读操作也使用ReentrantLock加锁,那么就退化为synchronized,读性能大大减弱。
synchronizedArrayList的实现和CopyOnWriteArrayList有什么不同?
synchronizedArrayList的实现是使用了synchronized关键字在方法的内部对操作进行加锁(同步代码块)的方式实现线层同步,CopyOnWriteArrayList的内部使用的是ReentrantLock(同步锁),CopyOnWriteArrayList底层保存元素的数组使用了volatile保证而来线程间的可见性。
Set不安全
package jucTest2;
import java.util.HashSet;
import java.util.Set;
import java.util.UUID;
/**
* @author 雷雨
* @date 2020/12/4 21:53
* Set 集合不安全
*/
public class UnFireCollectionSet {
public static void main(String[] args) {
Set<String> set = new HashSet<>();
new Thread(()->{
for (int i = 0; i < 10; i++) {
set.add(UUID.randomUUID().toString().substring(0,5));
System.out.println(set);
}
},"A线程").start();
new Thread(()->{
for (int i = 0; i < 10; i++) {
set.add(UUID.randomUUID().toString().substring(0,5));
System.out.println(set);
}
},"B线程").start();
}
}
结果可以看到发生了CurrentModifcationException异常(同步修改异常)。
使用Collections工具包下的synchronizedSet
package jucTest2;
import java.util.Collections;
import java.util.HashSet;
import java.util.Set;
import java.util.UUID;
/**
* @author 雷雨
* @date 2020/12/5 8:47
* 使用集合安全的类 set
* Collections工具包下的集合安全类
*/
public class FireCollectionSet2 {
public static void main(String[] args) {
Set<String> set = Collections.synchronizedSet(new HashSet<String>());
new Thread(()->{
for (int i = 0; i < 10; i++) {
set.add(UUID.randomUUID().toString().substring(0,5));
System.out.println(set);
}
},"线程A").start();
new Thread(()->{
for (int i = 0; i < 10; i++) {
set.add(UUID.randomUUID().toString().substring(0,5));
System.out.println(set);
}
},"线程B").start();
}
}
使用同步的Set集合
使用CopyOnWriteSet
package jucTest2;
import java.util.UUID;
import java.util.concurrent.ConcurrentSkipListSet;
import java.util.concurrent.CopyOnWriteArraySet;
/**
* @author 雷雨
* @date 2020/12/5 8:53
* 使用同步Set
*/
public class FireCollectionSet3 {
public static void main(String[] args) {
CopyOnWriteArraySet<String> set = new CopyOnWriteArraySet<String>();
new Thread(()->{
for (int i = 0; i < 10; i++) {
set.add(UUID.randomUUID().toString().substring(0,5));
System.out.println(set);
}
},"线程A").start();
new Thread(()->{
for (int i = 0; i < 10; i++) {
set.add(UUID.randomUUID().toString().substring(0,5));
System.out.println(set);
}
},"线程B").start();
}
}
CopyOnWriteSet的底层使用CopyOnWriteList来实现的,因此也能保证线程安全。
Map不安全
HashMap在多线程操作下不安全
package jucTest2;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
/**
* @author 雷雨
* @date 2020/12/5 9:03
* Map在多线程下操作不安全
*
*/
public class UnFireCollectionMap {
public static void main(String[] args) {
Map<String, Integer> map = new HashMap<>();
new Thread(()->{
for (int i = 0; i < 10; i++) {
int temp = i;
map.put(UUID.randomUUID().toString().substring(0,5),temp);
System.out.println(map);
}
},"线层A").start();
new Thread(()->{
for (int i = 0; i < 10; i++) {
int temp = i;
map.put(UUID.randomUUID().toString().substring(0,5),temp);
System.out.println(map);
}
},"线程B").start();
}
}
使用Hashtable
使用HashTable
package jucTest2;
import java.util.Hashtable;
import java.util.UUID;
/**
* @author 雷雨
* @date 2020/12/5 9:10
* 使用本身就是多线程安全的类
*/
public class FireCollectionMap1 {
public static void main(String[] args) {
Hashtable<String,Integer> map = new Hashtable<>();
new Thread(()->{
for (int i = 0; i < 10; i++) {
int temp = i;
map.put(UUID.randomUUID().toString().substring(0,5),temp);
System.out.println(map);
}
},"线层A").start();
new Thread(()->{
for (int i = 0; i < 10; i++) {
int temp = i;
map.put(UUID.randomUUID().toString().substring(0,5),temp);
System.out.println(map);
}
},"线程B").start();
}
}
使用本身是集合安全的Hashtable能够保证多线程操作的安全。
为什么Hashtable是线程安全的?
看源码分析一下
//写操作上加锁
public synchronized V put(K key, V value) {
// Make sure the value is not null
if (value == null) {
throw new NullPointerException();
}
// Makes sure the key is not already in the hashtable.
Entry<?,?> tab[] = table;
int hash = key.hashCode();
int index = (hash & 0x7FFFFFFF) % tab.length;
@SuppressWarnings("unchecked")
Entry<K,V> entry = (Entry<K,V>)tab[index];
for(; entry != null ; entry = entry.next) {
if ((entry.hash == hash) && entry.key.equals(key)) {
V old = entry.value;
entry.value = value;
return old;
}
}
addEntry(hash, key, value, index);
return null;
}
//读操作加锁
public synchronized V get(Object key) {
Entry<?,?> tab[] = table;
int hash = key.hashCode();
int index = (hash & 0x7FFFFFFF) % tab.length;
for (Entry<?,?> e = tab[index] ; e != null ; e = e.next) {
if ((e.hash == hash) && e.key.equals(key)) {
return (V)e.value;
}
}
return null;
}
小结:Hashtable就是将读写方法都进行了加锁,保证了读写的多线程安全性。
但是还是仍然存在多线程问题的,因为存在锁资源竞争。
使用Collections.synchronizedMap
package jucTest2;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
/**
* @author 雷雨
* @date 2020/12/5 9:17
* 使用Collections
*/
public class FireCollectionMap2 {
public static void main(String[] args) {
Map<Object, Object> map = Collections.synchronizedMap(new HashMap<>());
new Thread(()->{
for (int i = 0; i < 10; i++) {
int temp = i;
map.put(UUID.randomUUID().toString().substring(0,5),temp);
System.out.println(map);
}
},"线层A").start();
new Thread(()->{
for (int i = 0; i < 10; i++) {
int temp = i;
map.put(UUID.randomUUID().toString().substring(0,5),temp);
System.out.println(map);
}
},"线程B").start();
}
}
Collections.synchronizedMap是线程安全的类。
使用同步Map
使用ConcurrentHashMap
package jucTest2;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
/**
* @author 雷雨
* @date 2020/12/5 9:25
* 使用同步Map
*/
public class FireCollectionMap3 {
public static void main(String[] args) {
ConcurrentMap<String,Integer> map = new ConcurrentHashMap<>();
new Thread(()->{
for (int i = 0; i < 10; i++) {
int temp = i;
map.put(UUID.randomUUID().toString().substring(0,5),temp);
System.out.println(map);
}
},"线层A").start();
new Thread(()->{
for (int i = 0; i < 10; i++) {
int temp = i;
map.put(UUID.randomUUID().toString().substring(0,5),temp);
System.out.println(map);
}
},"线程B").start();
}
}
ConcurrentHashMap源码分析放在之后的博客,因为细节比较多。