ArrayList线程不安全怎么办?(CopyOnWriteArrayList详解)
- 2021 年 8 月 29 日
- 筆記
ArrayList线程不安全怎么办?
有三种解决方法:
-
使用对应的
Vector
类,这个类中的所有方法都加上了synchronized
关键字- 就和 HashMap 和 HashTable 的关系一样
-
使用 Collections 提供的
synchronizedList
方法,将一个原本线程不安全的集合类转换为线程安全的,使用方法如下:List<Integer> list = Collections.synchronizedList(new ArrayList<>());
-
其实 HashMap 也可以用这招:
Map<String, String> map = Collections.synchronizedMap(new HashMap<>());
-
这个看上去有点东西,其实也是给每个方法加上一个
synchronized
,不过不是直接加在方法上,而是加在方法内部,只有当线程获取到mutex
这个对象的锁,才能进入代码块:public E get(int index) { synchronized (mutex) { return list.get(index); } }
-
-
使用 JUC 包下提供的
CopyOnWriteArrayList
类- 其实 ConcurrentHashMap 也是 JUC 包下的
这里具体讨论一下 CopyOnWriteArrayList
这个类,它采用了“写时复制”的技术,也就是说,每当要往这个 list 中添加元素时,并不是直接就添加了,而是会先复制一份 list,然后在这个复制中添加元素,最后再修改指针的指向,看看 add 的源码:
public boolean add(E e) {
synchronized (lock) {
//得到当前的数组
Object[] es = getArray();
int len = es.length;
//复制一份并扩容
es = Arrays.copyOf(es, len + 1);
//把新元素添加进去
es[len] = e;
//修改指针的指向
setArray(es);
return true;
}
}
有人可能会疑惑,这有什么意义,这不也加了 synchronized
吗,而且还要复制数组,这**不是比 Vector 还要烂吗?
确实是这样的,在写操作比较多的场景下,CopyOnWriteArrayList
确实比 Vector
还要慢,但它有两个优势:
-
虽然写操作烂了,但读操作快了很多,因为在 vector 中,读操作也是需要锁的,而在这里,读操作就不需要锁了,get 方法比较短可能不便于理解,我们看看
indexOf
这个方法:public int indexOf(Object o) { Object[] es = getArray(); return indexOfRange(o, es, 0, es.length); } private static int indexOfRange(Object o, Object[] es, int from, int to) { if (o == null) { for (int i = from; i < to; i++) if (es[i] == null) return i; } else { //****here**** for (int i = from; i < to; i++) if (o.equals(es[i])) return i; } return -1; }
- 可以发现,这个方法先把当前数组 array 交给了 es 这个变量,后续的所有操作都是基于 es 进行的(此时 array 和 es 都指向内存中的同一份数组 a1)
- 由于所有写操作都是在 a1 的拷贝上进行的(我们把内存中的这份拷贝称为 a2),因此不会影响到那些正在 a1 上进行的读操作,并且就算写操作执行完毕了,array 指向了 a2,也不会影响到 es 这个数组,因为 es 指向的还是 a1
- 试想,如果 vector 的读操作不加锁会出现什么情况?由于 vector 中所有的读写操作都是基于同一个数组的,因此虽然读操作一开始拿到的数组是没问题的,但在后续遍历的过程中(比如上面代码标注了 here 的地方),很可能出现其他线程对数组进行了修改,夸张点说,如果有个线程把数组给清空了,那么读操作就肯定会报错了,而对于
CopyOnWriteArrayList
来说,就算有清空的操作,那也是在 a2 上进行的,而读操作还是在 a1 上进行,不会有任何影响
-
在 forEach 遍历一个 vector 时,是不允许对 vector 进行修改的,会报出
ConcurrentModificationException
这个异常,理由很简单,因为只有一份数组,要是遍历到一半有其它线程把数组清空了不就出问题了吗,因此 java 干脆就直接禁止这种遍历时修改数组的行为了,但对于CopyOnWriteArrayList
来说,它的遍历是一直在 a1 上进行的,其它写线程只能修改到 a2,这对 a1 是没有任何影响的,我们看一段代码来验证一下:public class Test { public static void main(String[] args) { CopyOnWriteArrayList<Integer> list = new CopyOnWriteArrayList<>(); for (int i = 0; i < 1000; i++) { list.add(i); } //遍历时把数组清空 for (Integer i : list) { System.out.println(i); list.clear(); } } }
- 结果是没有报错,并且完整输出了 0~999 所有的数字,可见这里遍历的就是最开始的那个数组 a1,期间哪怕有再多的写操作也不会影响到 a1,因为所有的写操作都是在 a2 a3 a4 上进行的
综上所述,CopyOnWriteArrayList
的优点就是,读操作很快,不需要锁,并且支持遍历,遍历过程中就算数组被修改也不会报错,在读多写少的场景下是很优秀的
但它的缺点也很明显,主要有两点:
- 首先,写操作的内存消耗非常大,每次修改数组都会进行一次拷贝,如果数组比较大或者修改次数比较多,很快就会消耗掉大量内存,触发 GC,因此在写多的场景下一定要慎用这个类
- 其次,虽然读操作和遍历不需要上锁,也允许遍历时数组被修改,但这些操作都是基于旧数组 a1 的,在它们执行
Object[] es = getArray()
这条语句的一瞬间就决定了它们所要操作的数组,因此它们是没有办法感知到最新的那些数据的,就算途中新增了一个很重要的数据,这个数据也是在 a2 中,遍历 a1 是无法得到这个数据的