大家对线程安全容器可能最熟悉的就是ConcurrentHashMap了,因为这个容器经常会在面试的时候考查。
比如说,一个常见的面试场景:
-
面试官问:“HashMap是线程安全的吗?如果HashMap线程不安全的话,那有没有安全的Map容器”
-
3y:“线程安全的Map有两个,一个是Hashtable,一个是ConcurrentHashMap”
-
面试官继续问:“那Hashtable和ConcurrentHashMap有什么区别啊?”
-
3y:“balabalabalabalabalabala"
-
面试官:”ok,ok,ok,看你Java基础挺不错的呀“
那如果有这样的面试呢?
-
面试官问:“ArrayList是线程安全的吗?如果ArrayList线程不安全的话,那有没有安全的类似ArrayList的容器”
-
3y:“线程安全的ArrayList我们可以使用Vector,或者说我们可以使用Collections下的方法来包装一下”
-
面试官继续问:“嗯,我相信你也知道Vector是一个比较老的容器了,还有没有其他的呢?”
-
3y:“Emmmm,这个…“
-
面试官提示:“就比如JUC中有ConcurrentHashMap,那JUC中有类似"ArrayList"的线程安全容器类吗?“
-
3y:“Emmmm,这个…“
-
面试官:”ok,ok,ok,今天的面试时间也差不多了,你回去等通知吧。“
今天主要讲解的是CopyOnWriteArrayList~
目录
2.集合的并发修改异常 ConcurrentModificationException,知其然而知其所以然
3.CopyOnWriteArrayList(Set)介绍:
1.集合的线程安全性问题:
我们知道ArrayList是用于替代Vector的,Vector是线程安全的容器。因为它几乎在每个方法声明处都加了synchronized关键字来使容器安全。
如果使用Collections.synchronizedList(new ArrayList())
来使ArrayList变成是线程安全的话,也是几乎都是每个方法都加上synchronized关键字的,只不过它不是加在方法的声明处,而是方法的内部。
再来看看Vector和synchronizedList可能出现的问题
:
在讲解CopyOnWrite容器之前,我们还是先来看一下线程安全容器的一些可能没有注意到的地方~
下面我们直接来看一下这段代码:
import java.util.Vector;
public class UnsafeVectorHelpers {
public static void main(String[] args) {
// 初始化Vector
Vector<String> vector = new Vector();
vector.add("关注公众号");
vector.add("Java3y");
vector.add("买Linux可到我下面的链接,享受最低价");
vector.add("给3y加鸡腿");
new Thread(() -> getLast(vector)).start();
new Thread(() -> deleteLast(vector)).start();
new Thread(() -> getLast(vector)).start();
new Thread(() -> deleteLast(vector)).start();
}
// 得到Vector最后一个元素
public static Object getLast(Vector list) {
int lastIndex = list.size() - 1;
return list.get(lastIndex);
}
// 删除Vector最后一个元素
public static void deleteLast(Vector list) {
int lastIndex = list.size() - 1;
list.remove(lastIndex);
}
}
这个代码会不会报错?,我们分析一下流程:
A,B两个线程分别同时执行getLast和deleteLast两个方法,当两个都执行int lastIndex = list.size() - 1后,lastIndex=3,此时,此时注意,B线程首先执行了 list.remove(3);方法,然后A线程执行list.get(3),的方法就会报错。
要解决上面这种情况也很简单,因为我们都是对Vector进行操作的,只要操作Vector前把它锁住就没毛病了!
// 获得Vector最后一个元素
public static Object getLast(Vector list) {
synchronized (list) {
int lastIndex = list.size() - 1;
return list.get(lastIndex);
}
}
// 删除Vector最后一个元素
public static void deleteLast(Vector list) {
synchronized (list) {
int lastIndex = list.size() - 1;
list.remove(lastIndex);
}
}
2.集合的并发修改异常 ConcurrentModificationException,知其然而知其所以然
并发修改异常产生的原因 ,在java 5之前,我们使用for循环来遍历集合,在这种情况之下,我们遍历的时候并删除元素是没有任何问题的,
for (int i = 0; i < stringList.size(); i++) {
if (stringList.get(i).equals("Java3y")) {
stringList.remove(i);
}
}
但是在java5之后,出现了for-each
(迭代器)来遍历的集合,好处就是简洁、数组索引的边界值只计算一次。若是使用for-each
(迭代器)来作上面的操做,会抛出ConcurrentModificationException异常。
for (String s : stringList) {
if (s.equals("Java3y")) {
stringList.remove("Java3y");
}
}
为什么java5之后使用迭代器器遍历会出现这样的情况呢?
因为上面使用迭代器遍历等价于下面的代码
Iterator<String> iterator = vector.iterator();
while (iterator.hasNext()) {
String obj = iterator.next();
vector.remove(obj);
}
查看iterator.hasNext()的源码可以发现,他会有这么一个检查,当modCount != expectedModCount
这个条件成立的时候会抛出.
首先我们查看modCount
的来源,可以发现modCount
的值等于当前List的size
,当调用List.remove
方法的时候modCount
也会相应的减1;
然后我们查看expectedModCount
的来源,可以看到是在构造Iterator
(这里使用的是ArrayList的内部实现)的时候,有一个变量赋值,将modCount
的值赋给了expectedModCount
;最后当我们执行循环调用List.remove
方法的时候,modCount
改变了但是expectedModCount
并没有改变,当第一次循环结束删除一个数据准 备第二次循环调用iterator.hasNext()
方法的时候,checkForComodification()
方法就会抛出异常,因为此时List
的modCount
已经变为 了2,而expectedModCount
仍然是3,所以会抛出ConcurrentModificationException
异常;
知道原理以后我们可以想到解决这个问题的办法:
1.使用java5之前的for循环遍历删除
2.使用迭代器遍历就要使用迭代器的remove方法去删除,不能使用list的remove方法
3.stream流的filter可以剔除元素
4.collections的removeIf方法
5.使用CopyOnWriteArrayList
3.CopyOnWriteArrayList(Set)介绍:
一般来说,我们会认为:CopyOnWriteArrayList是同步List的替代品,CopyOnWriteArraySet是同步Set的替代品。
无论是Hashtable-->ConcurrentHashMap,还是说Vector-->CopyOnWriteArrayList。JUC下支持并发的容器与老一代的线程安全类相比,总结起来就是加锁粒度的问题
-
Hashtable、Vector加锁的粒度大(直接在方法声明处使用synchronized)
-
ConcurrentHashMap、CopyOnWriteArrayList加锁粒度小(用各种的方式来实现线程安全,比如我们知道的ConcurrentHashMap用了cas锁、volatile等方式来实现线程安全..)
-
JUC下的线程安全容器在遍历的时候不会抛出ConcurrentModificationException异常
所以一般来说,我们都会使用JUC包下给我们提供的线程安全容器,而不是使用老一代的线程安全容器。
下面我们来看看CopyOnWriteArrayList是怎么实现的,为什么使用迭代器遍历的时候就不用额外加锁,也不会抛出ConcurrentModificationException异常。
ConcurrentModificationException异常是怎么产生的?
4.CopyOnWriteArrayList实现原理:
我们还是先来回顾一下COW:
如果有多个调用者(callers)同时请求相同资源(如内存或磁盘上的数据存储),他们会共同获取相同的指针指向相同的资源,直到某个调用者试图修改资源的内容时,系统才会真正复制一份专用副本(private copy)给该调用者,而其他调用者所见到的最初的资源仍然保持不变。优点是如果调用者没有修改该资源,就不会有副本(private copy)被建立,因此多个调用者只是读取操作时可以共享同一份资源。
概括一下CopyOnWriteArrayList源码注释介绍了什么:
-
CopyOnWriteArrayList是线程安全容器(相对于ArrayList),底层通过复制数组的方式来实现。
-
CopyOnWriteArrayList在遍历的使用不会抛出ConcurrentModificationException异常,并且遍历的时候就不用额外加锁
-
元素可以为null
看一下CopyOnWriteArrayList基本的结构:
看起来挺简单的,CopyOnWriteArrayList底层就是数组,加锁就交由ReentrantLock来完成。
常见方法的实现:
之前集合在遍历的时候如果进行删除或者增加,那么会报并发的错误,不管是ArrayList还是Vector都是如此,还是SynchronizedList封装过的,可以采用ListIterator这个专门为List类型的集合封装的工具类来实现遍历,也可以自己加锁实现。
1.首先我们可以看看add()
方法
通过代码我们可以知道:在添加的时候就上锁,并复制一个新数组,增加操作在新数组上完成,将array指向到新数组中,最后解锁。
再来看看size()
方法和get()方法:
那再来看看set()
方法:
总结:
-
在修改时,复制出一个新数组,修改的操作在新数组中完成,最后将新数组交由array变量指向。
-
写加锁,读不加锁
剖析为什么遍历时不用调用者显式加锁???
常用的方法实现我们已经基本了解了,但还是不知道为啥能够在容器遍历的时候对其进行修改而不抛出异常。所以,来看一下他的迭代器吧:
到这里,我们应该就可以想明白了!CopyOnWriteArrayList在使用迭代器遍历的时候,操作的都是原数组!
5.CopyOnWriteArrayList缺点:
看了上面的实现源码,我们应该也大概能分析出CopyOnWriteArrayList的缺点了。
内存占用:如果CopyOnWriteArrayList经常要增删改里面的数据,经常要执行add()、set()、remove()
的话,那是比较耗费内 存的。因为我们知道每次`add()、set()、remove()`这些增删改操作都要复制一个数组出来。
数据一致性:CopyOnWrite容器只能保证数据的最终一致性,不能保证数据的实时一致性。从上面的例子也可以看出来,比如线程A在迭代CopyOnWriteArrayList容器的数据。线程B在线程A迭代的间隙中将CopyOnWriteArrayList部分的数据修改了(已经调用`setArray()`了)。但是线程A迭代出来的是原有的数据。
所以 CopyOnWriteArrayList 不适合内存敏感以及对实时性要求很高的场景。
6.CopyOnWriteArrayList的适用场景:
CopyOnWriteArrayList 在写操作的同时允许读操作,大大提高了读操作的性能,因此很适合读多写少的应用场景。
CopyOnWriteSet的原理就是CopyOnWriteArrayList。
参考资料:
-
《Java并发编程实战》
-
聊聊并发-Java中的Copy-On-Write容器:http://ifeve.com/java-copy-on-write/
-
Java 中的写时复制 (Copy on Write, COW)https://juejin.im/post/5bc3065ce51d450e8e7758b5
扩展阅读:
-
CopyOnWriteArrayList类set方法疑惑?http://ifeve.com/copyonwritearraylist-set/
-
Why setArray() method call required in CopyOnWriteArrayListhttps://stackoverflow.com/questions/28772539/why-setarray-method-call-required-in-copyonwritearraylist