提交 c94ac889 编写于 作者: 沉默王二's avatar 沉默王二 💬

CopyOnWriteArrayList

上级 17a1aebc
......@@ -63,6 +63,14 @@ macOS 直接双击打开 dmg 文件进行安装。
我采购的是匀加速服务-Lite 版 100G 流量每月,两年价格 35.95 美刀(约等于 257.44 RMB)
### 服务 4:饿饭 cc 云服务
你可以通过以下地址购买饿饭 cc 云服务:
[https://api.efanapi.com/aff.php?aff=2849](https://api.efanapi.com/aff.php?aff=2849)
我采购的是 B 计划 20G流量 每月,三年价格 330RMB。
自己按需购买即可。
......
---
title: 吊打Java并发面试官之CopyOnWriteArrayList
shortTitle: CopyOnWriteArrayList
description: 吊打Java并发面试官之CopyOnWriteArrayList
description: CopyOnWriteArrayList 是一个线程安全的变体,它是 Java 的 ArrayList 类的并发版本。这个类的线程安全是通过一个简单但强大的想法实现的:每当列表修改时,就创建列表的一个新副本。
category:
- Java核心
tag:
......@@ -14,40 +14,42 @@ head:
# 14.23 并发容器 CopyOnWriteArrayList
java学习者都清楚ArrayList并不是线程安全的,在读线程在读取ArrayList的时候如果有写线程在写数据的时候,基于fast-fail机制,会抛出**ConcurrentModificationException**异常,也就是说ArrayList并不是一个线程安全的容器,当然您可以用Vector,或者使用Collections的静态方法将ArrayList包装成一个线程安全的类,但是这些方式都是采用java关键字synchronzied对方法进行修饰,利用独占式锁来保证线程安全的。但是,由于独占式锁在同一时刻只有一个线程能够获取到对象监视器,很显然这种方式效率并不是太高
学过 [ArrayList](https://javabetter.cn/collection/arraylist.html) 的小伙伴应该记得,ArrayList 是一个线程不安全的容器,如果在多线程环境下使用,需要手动加锁,或者使用 `Collections.synchronizedList()` 方法将其转换为线程安全的容器
回到业务场景中,有很多业务往往是读多写少的,比如系统配置的信息,除了在初始进行系统配置的时候需要写入数据,其他大部分时刻其他模块之后对系统信息只需要进行读取,又比如白名单,黑名单等配置,只需要读取名单配置然后检测当前用户是否在该配置范围以内
否则,将会出现 [ConcurrentModificationException](https://javabetter.cn/collection/fail-fast.html) 异常
类似的还有很多业务场景,它们都是属于**读多写少**的场景。如果在这种情况用到上述的方法,使用Vector,Collections转换的这些方式是不合理的,因为尽管多个读线程从同一个数据容器中读取数据,但是读线程对数据容器的数据并不会发生发生修改
于是,Doug Lea 大师为我们提供了一个并发版本的 ArrayList——CopyOnWriteArrayList
很自然而然的我们会联想到ReenTrantReadWriteLock,通过**读写分离**的思想,使得读读之间不会阻塞,无疑如果一个list能够做到被多个读线程读取的话,性能会大大提升不少。但是,如果仅仅是将list通过读写锁(ReentrantReadWriteLock)进行再一次封装的话,由于读写锁的特性,当写锁被写线程获取后,读写线程都会被阻塞。如果仅仅使用读写锁对list进行封装的话,这里仍然存在读线程在读数据的时候被阻塞的情况,如果想list的读效率更高的话,这里就是我们的突破口,如果我们保证读线程无论什么时候都不被阻塞,效率岂不是会更高?
CopyOnWriteArrayList 是线程安全的,可以在多线程环境下使用。CopyOnWriteArrayList 遵循写时复制的原则,每当对列表进行修改(例如添加、删除或更改元素)时,都会创建列表的一个新副本,这个新副本会替换旧的列表,而对旧列表的所有读取操作仍然可以继续。
Doug Lea大师就为我们提供CopyOnWriteArrayList容器可以保证线程安全,保证读读之间在任何时候都不会被阻塞,CopyOnWriteArrayList也被广泛应用于很多业务场景之中,CopyOnWriteArrayList值得被我们好好认识一番
由于在修改时创建了新的副本,所以读取操作不需要锁定。这使得在多读取者和少写入者的情况下读取操作非常高效。当然,由于每次写操作都会创建一个新的数组副本,所以会增加存储和时间的开销。如果写操作非常频繁,性能会受到影响
## COW的设计思想
### 什么是 CopyOnWrite
回到上面所说的,如果简单的使用读写锁的话,在写锁被获取之后,读写线程被阻塞,只有当写锁被释放后读线程才有机会获取到锁从而读到最新的数据,站在**读线程的角度来看,即读线程任何时候都是获取到最新的数据,满足数据实时性**
大家应该还记得读写锁 [ReentrantReadWriteLock](https://javabetter.cn/thread/ReentrantReadWriteLock.html) 吧?读写锁是通过读写分离的思想来实现的,即读写锁将读写操作分别加锁,从而实现读写操作的并发执行
既然我们说到要进行优化,必然有trade-off,我们就可以**牺牲数据实时性满足数据的最终一致性即可**。而CopyOnWriteArrayList就是通过Copy-On-Write(COW),即写时复制的思想来通过延时更新的策略来实现数据的最终一致性,并且能够保证读线程间不阻塞
但是,读写锁也存在一些问题,比如说在写锁执行后,读线程会被阻塞,直到写锁被释放后读线程才有机会获取到锁从而读到最新的数据,站在**读线程的角度来看,读线程在任何时候都能获取到最新的数据,满足数据实时性**
COW通俗的理解是当我们往一个容器添加元素的时候,不直接往当前容器添加,而是先将当前容器进行Copy,复制出一个新的容器,然后新的容器里添加元素,添加完元素之后,再将原容器的引用指向新的容器
而 CopyOnWriteArrayList 是通过 Copy-On-Write(COW),即写时复制的思想来通过延时更新的策略实现数据的最终一致性,并且能够保证读线程间不阻塞。当然,**这要牺牲数据的实时性**
对CopyOnWrite容器进行并发的读的时候,不需要加锁,因为当前容器不会添加任何元素。所以CopyOnWrite容器也是一种读写分离的思想,延时更新的策略是通过在写的时候针对的是不同的数据容器来实现的,放弃数据实时性达到数据的最终一致性
通俗的讲,CopyOnWrite 就是当我们往一个容器添加元素的时候,不直接往容器中添加,而是先复制出一个新的容器,然后在新的容器里添加元素,添加完之后,再将原容器的引用指向新的容器。多个线程在读的时候,不需要加锁,因为当前容器不会添加任何元素
## CopyOnWriteArrayList的实现原理
我们在介绍[并发容器](https://javabetter.cn/thread/map.html)的时候,也曾提到过,相信大家都还有印象。
现在我们来通过看源码的方式来理解CopyOnWriteArrayList,实际上CopyOnWriteArrayList内部维护的就是一个数组
### CopyOnWriteArrayList 的实现原理
OK,接下来我们来看一下 CopyOnWriteArrayList 的源码。顾名思义,实际上 CopyOnWriteArrayList 内部维护的就是一个数组:
```java
/** The array, accessed only via getArray/setArray. */
private transient volatile Object[] array;
```
并且该数组引用是被volatile修饰,注意这里**仅仅是修饰的是数组引用**,其中另有玄机,稍后揭晓。关于volatile很重要的一条性质是它能够够保证可见性。对list来说,我们自然而然最关心的就是读写的时候,分别为get和add方法的实现
```
该数组被 [volatile](https://javabetter.cn/thread/volatile.html) 修饰,能够保证数据的内存可见性
### get方法实现原理
#### get 方法
get方法的源码为
get 方法的源码如下
```java
public E get(int index) {
......@@ -63,13 +65,13 @@ final Object[] getArray() {
private E get(Object[] a, int index) {
return (E) a[index];
}
```
```
可以看出来get方法实现非常简单,几乎就是一个“单线程”程序,没有对多线程添加任何的线程安全控制,也没有加锁也没有CAS操作等等,原因是,所有的读线程只是会读取数据容器中的数据,并不会进行修改。
get 方法的实现非常简单,几乎就是一个“单线程”,没有添加任何的线程安全控制,没有[加锁](https://javabetter.cn/thread/lock.html)也没有 [CAS](https://javabetter.cn/thread/cas.html) 操作,原因就是所有的读线程只会读取容器中的数据,并不会进行修改。
### add方法实现原理
#### add 方法
再来看下如何进行添加数据的?add方法的源码为
add 方法的源码如下
```java
public boolean add(E e) {
......@@ -85,7 +87,7 @@ public boolean add(E e) {
//3. 创建新的数组,并将旧数组的数据复制到新数组中
Object[] newElements = Arrays.copyOf(elements, len + 1);
//4. 往新数组中添加新的数据
//4. 往新数组中添加新的数据
newElements[len] = e;
//5. 将旧数组引用指向新的数组
......@@ -97,30 +99,67 @@ public boolean add(E e) {
}
```
add方法的逻辑也比较容易理解,请看上面的注释。需要注意这么几点:
add 方法的逻辑也比较容易理解,需要注意这么几点:
01、采用 [ReentrantLock](https://javabetter.cn/thread/reentrantLock.html) 保证同一时刻只有一个写线程正在进行数组的复制;
02、通过调用 `getArray()` 方法获取旧的数组。
```java
final Object[] getArray() {
return array;
}
```
03、然后创建一个新的数组,把旧的数组复制过来,然后在新的数组中添加数据,再将新的数组赋值给旧的数组引用。
```java
final void setArray(Object[] a) {
array = a;
}
```
根据 volatile 的 happens-before 规则,所以这个更改对所有线程是立即可见的。
04、最后,在 finally 块中释放锁,以便其他线程可以访问和修改列表。
### CopyOnWriteArrayList 的使用
CopyOnWriteArrayList 的使用非常简单,和 ArrayList 的使用几乎一样,只是在创建对象的时候需要使用 CopyOnWriteArrayList 的构造方法,如下所示:
1. 采用ReentrantLock,保证同一时刻只有一个写线程正在进行数组的复制,否则的话内存中会有多份被复制的数据;
2. 前面说过数组引用是volatile修饰的,因此将旧的数组引用指向新的数组,根据volatile的happens-before规则,写线程对数组引用的修改对读线程是可见的。
3. 由于在写数据的时候,是在新的数组中插入数据的,从而保证读写实在两个不同的数据容器中进行操作。
```java
CopyOnWriteArrayList<String> list = new CopyOnWriteArrayList<>();
list.add("element1");
list.add("element2");
for (String element : list) {
System.out.println(element);
}
```
## 总结
### CopyOnWriteArrayList 的缺点
我们知道COW和读写锁都是通过读写分离的思想实现的,但两者还是有些不同,可以进行比较:
CopyOnWrite 容器有很多优点,但是同时也存在两个问题,即内存占用问题和数据一致性问题。所以在开发的时候需要特别注意。
### **COW vs 读写锁**
1. **内存占用问题**:因为 CopyOnWrite 的写时复制机制,在进行写操作的时候,内存里会同时有两个对象,旧的对象和新写入的对象,分析 add 方法的时候大家都看到了。
如果这些对象占用的内存比较大,比如说 200M 左右,那么再写入 100M 数据进去,内存就会占用 600M,那么这时候就会造成频繁的 minor GC 和 major GC。
1. **数据一致性问题**:CopyOnWrite 容器只能保证数据的最终一致性,不能保证数据的实时一致性。所以如果你希望写入的的数据,马上能读到,请不要使用 CopyOnWrite 容器,最好通过 [ReentrantReadWriteLock](https://javabetter.cn/thread/ReentrantReadWriteLock.html) 自定义一个的列表。
我们来比较一下 CopyOnWrite 和读写锁。
相同点:
1. 两者都是通过读写分离的思想实现
1. 两者都是通过读写分离的思想来实现的
2. 读线程间是互不阻塞的
不同点:
对读线程而言,为了实现数据实时性,在写锁被获取后,读线程会等待或者当读锁被获取后,写线程会等待,从而解决“脏读”等问题。也就是说如果使用读写锁依然会出现读线程阻塞等待的情况。
而COW则完全放开了牺牲数据实时性而保证数据最终一致性,即读线程对数据的更新是延时感知的,因此读线程不会存在等待的情况。
为了实现数据实时性,在写锁被获取后,读线程会阻塞;或者当读锁被获取后,写线程会阻塞,从而解决“脏读”的问题。而 CopyOnWrite 对数据的更新是写时复制的,因此读线程是延时感知的,单不会存在阻塞的情况。
对这一点从文字上还是很难理解,我们来通过debug看一下,add方法核心代码为:
对这一点从文字上可能比较难理解,我们通过 debug 来看一下,add 方法核心代码为:
```java
1.Object[] elements = getArray();
......@@ -130,37 +169,30 @@ add方法的逻辑也比较容易理解,请看上面的注释。需要注意
5.setArray(newElements);
```
假设COW的变化如下图所示:
假设 COW 的变化如下图所示:
![最终一致性的分析](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/thread/CopyOnWriteArrayList-01.png)
数组中已有数据1,2,3,现在写线程想往数组中添加数据4,我们在第5行处打上断点,让写线程暂停。读线程依然会“不受影响”的能从数组中读取数据,可是还是只能读到1,2,3。**如果读线程能够立即读到新添加的数据的话就叫做能保证数据实时性**。当对第5行的断点放开后,读线程才能感知到数据变化,读到完整的数据1,2,3,4,而保证**数据最终一致性**,尽管有可能中间间隔了好几秒才感知到
数组中已有数据 1,2,3,现在写线程想往数组中添加数据 4,我们在第 5 行处打上断点,让写线程暂停
这里还有这样一个问题: **为什么需要复制呢? 如果将array 数组设定为volitile的, 对volatile变量写happens-before读,读线程不是能够感知到volatile变量的变化**
此时,读线程依然会“不受影响”的从数组中读取数据,可是还是只能读到 1,2,3
原因是,这里volatile的修饰的**仅仅**只是**数组引用****数组中的元素的修改是不能保证可见性的**。因此COW采用的是新旧两个数据容器,通过第5行代码将数组引用指向新的数组
**如果读线程能够立即读到新添加的数据就叫数据实时性**。当对第 5 行的断点放开后,读线程感知到了数据的变化,所以读到了完整的数据 1,2,3,4,这叫**数据最终一致性**,尽管有可能中间间隔了好几秒才感知到
### **COW的缺点**
### 总结
CopyOnWrite容器有很多优点,但是同时也存在两个问题,即内存占用问题和数据一致性问题。所以在开发的时候需要注意一下
CopyOnWriteArrayList 是一个线程安全的变体,它是 Java 的 ArrayList 类的并发版本。这个类的线程安全是通过一个简单但强大的想法实现的:每当列表修改时,就创建列表的一个新副本
1. **内存占用问题**:因为CopyOnWrite的写时复制机制,所以在进行写操作的时候,内存里会同时驻扎两个对 象的内存,旧的对象和新写入的对象(注意:在复制的时候只是复制容器里的引用,只是在写的时候会创建新对 象添加到新容器里,而旧容器的对象还在使用,所以有两份对象内存)
CopyOnWriteArrayList 适用于读操作远远大于写操作的场景,比如说缓存。因为 CopyOnWriteArrayList 采用写时复制的思想,所以写操作的性能较低,因此不适合写操作频繁的场景
如果这些对象占用的内存比较大,比 如说200M左右,那么再写入100M数据进去,内存就会占用300M,那么这个时候很有可能造成频繁的minor GC和major GC
CopyOnWriteArrayList 也存在一些缺点,比如说内存占用问题和数据一致性问题,所以在开发的时候需要特别注意
2. **数据一致性问题**:CopyOnWrite容器只能保证数据的最终一致性,不能保证数据的实时一致性。所以如果你希望写入的的数据,马上能读到,请不要使用CopyOnWrite容器。
---
>编辑:沉默王二,内容大部分来源以下三个开源仓库:
>- [深入浅出 Java 多线程](http://concurrent.redspider.group/)
>- [并发编程知识总结](https://github.com/CL0610/Java-concurrency)
>- [Java八股文](https://github.com/CoderLeixiaoshuai/java-eight-part)
> 编辑:沉默王二,部分内容来自于CL0610的 GitHub 仓库[https://github.com/CL0610/Java-concurrency](https://github.com/CL0610/Java-concurrency/blob/master/19.%E5%B9%B6%E5%8F%91%E5%AE%B9%E5%99%A8%E4%B9%8BBlockingQueue/%E5%B9%B6%E5%8F%91%E5%AE%B9%E5%99%A8%E4%B9%8BBlockingQueue.md)。
----
GitHub 上标星 9000+ 的开源知识库《[二哥的 Java 进阶之路](https://github.com/itwanger/toBeBetterJavaer)》第一版 PDF 终于来了!包括Java基础语法、数组&字符串、OOP、集合框架、Java IO、异常处理、Java 新特性、网络编程、NIO、并发编程、JVM等等,共计 32 万余字,可以说是通俗易懂、风趣幽默……详情戳:[太赞了,GitHub 上标星 9000+ 的 Java 教程](https://javabetter.cn/overview/)
---
GitHub 上标星 9000+ 的开源知识库《[二哥的 Java 进阶之路](https://github.com/itwanger/toBeBetterJavaer)》第一版 PDF 终于来了!包括 Java 基础语法、数组&字符串、OOP、集合框架、Java IO、异常处理、Java 新特性、网络编程、NIO、并发编程、JVM 等等,共计 32 万余字,可以说是通俗易懂、风趣幽默……详情戳:[太赞了,GitHub 上标星 9000+ 的 Java 教程](https://javabetter.cn/overview/)
微信搜 **沉默王二** 或扫描下方二维码关注二哥的原创公众号沉默王二,回复 **222** 即可免费领取。
......
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册