“ 今天聊一个很是硬核的技术知识,给你们分析一下CopyOnWrite思想是什么,以及在Java并发包中的具体体现,包括在Kafka内核源码中是如何运用这个思想来优化并发性能的。java
这个CopyOnWrite在面试的时候,极可能成为面试官的一个杀手锏把候选人给一击必杀,也颇有可能成为候选人拿下Offer的独门秘籍,是相对高级的一个知识。面试
你们能够设想一下如今咱们的内存里有一个ArrayList,这个ArrayList默认状况下确定是线程不安全的,要是多个线程并发读和写这个ArrayList可能会有问题。数组
好,问题来了,咱们应该怎么让这个ArrayList变成线程安全的呢?安全
有一个很是简单的办法,对这个ArrayList的访问都加上线程同步的控制。服务器
好比说必定要在synchronized代码段来对这个ArrayList进行访问,这样的话,就能同一时间就让一个线程来操做它了,或者是用ReadWriteLock读写锁的方式来控制,均可以。微信
咱们假设就是用ReadWriteLock读写锁的方式来控制对这个ArrayList的访问。数据结构
这样多个读请求能够同时执行从ArrayList里读取数据,可是读请求和写请求之间互斥,写请求和写请求也是互斥的。多线程
你们看看,代码大概就是相似下面这样:架构
public Object read() {
lock.readLock().lock();
// 对ArrayList读取
lock.readLock().unlock();
}
public void write() {
lock.writeLock().lock();
// 对ArrayList写
lock.writeLock().unlock();
}
复制代码
你们想一想,相似上面的代码有什么问题呢?并发
最大的问题,其实就在于写锁和读锁的互斥。假设写操做频率很低,读操做频率很高,是写少读多的场景。
那么偶尔执行一个写操做的时候,是否是会加上写锁,此时大量的读操做过来是否是就会被阻塞住,没法执行?
这个就是读写锁可能遇到的最大的问题。
这个时候就要引入CopyOnWrite思想来解决问题了。
他的思想就是,不用加什么读写锁,锁通通给我去掉,有锁就有问题,有锁就有互斥,有锁就可能致使性能低下,你阻塞个人请求,致使个人请求都卡着不能执行。
那么他怎么保证多线程并发的安全性呢?
很简单,顾名思义,利用“CopyOnWrite”的方式,这个英语翻译成中文,大概就是**“写数据的时候利用拷贝的副原本执行”。**
你在读数据的时候,其实不加锁也不要紧,你们左右都是一个读罢了,互相没影响。
问题主要是在写的时候,写的时候你既然不能加锁了,那么就得采用一个策略。
假如说你的ArrayList底层是一个数组来存放你的列表数据,那么这时好比你要修改这个数组里的数据,你就必须先拷贝这个数组的一个副本。
而后你能够在这个数组的副本里写入你要修改的数据,可是在这个过程当中实际上你都是在操做一个副本而已。
这样的话,读操做是否是能够同时正常的执行?这个写操做对读操做是没有任何的影响的吧!
你们看下面的图,一块儿来体会一下这个过程:
关键点来了,划重点!这里要配合上volatile关键字的使用。
笔者以前写过文章,给你们解释过volatile关键字的使用,核心就是让一个变量被写线程给修改以后,立马让其余线程能够读到这个变量引用的最近的值,这就是volatile最核心的做用。
因此一旦写线程搞定了副本数组的修改以后,那么就能够用volatile写的方式,把这个副本数组赋值给volatile修饰的那个数组的引用变量了。
只要一赋值给那个volatile修饰的变量,立马就会对读线程可见,你们都能看到最新的数组了。
下面是JDK里的 CopyOnWriteArrayList 的源码。
你们看看写数据的时候,他是怎么拷贝一个数组副本,而后修改副本,接着经过volatile变量赋值的方式,把修改好的数组副本给更新回去,立马让其余线程可见的。
// 这个数组是核心的,由于用volatile修饰了
// 只要把最新的数组对他赋值,其余线程立马能够看到最新的数组
private transient volatile Object[] array;
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;
// 而后把副本数组赋值给volatile修饰的变量
setArray(newElements);
return true;
} finally {
lock.unlock();
}
}
复制代码
而后你们想,由于是经过副原本进行更新的,万一要是多个线程都要同时更新呢?那搞出来多个副本会不会有问题?
固然不能多个线程同时更新了,这个时候就是看上面源码里,加入了lock锁的机制,也就是同一时间只有一个线程能够更新。
那么更新的时候,会对读操做有任何的影响吗?
绝对不会,由于读操做就是很是简单的对那个数组进行读而已,不涉及任何的锁。并且只要他更新完毕对volatile修饰的变量赋值,那么读线程立马能够看到最新修改后的数组,这是volatile保证的。
这样就完美解决了咱们以前说的读多写少的问题。
若是用读写锁互斥的话,会致使写锁阻塞大量读操做,影响并发性能。
可是若是用了CopyOnWriteArrayList,就是用空间换时间,更新的时候基于副本更新,避免锁,而后最后用volatile变量来赋值保证可见性,更新的时候对读线程没有任何的影响!
在Kafka的内核源码中,有这么一个场景,客户端在向Kafka写数据的时候,会把消息先写入客户端本地的内存缓冲,而后在内存缓冲里造成一个Batch以后再一次性发送到Kafka服务器上去,这样有助于提高吞吐量。
话很少说,你们看下图:
private final ConcurrentMap<topicpartition, deque<="" span="">
batches = new CopyOnWriteMap<TopicPartition, Deque>();
复制代码
这个数据结构就是核心的用来存放写入内存缓冲中的消息的数据结构,要看懂这个数据结构须要对不少Kafka内核源码里的概念进行解释,这里先不展开。
可是你们关注一点,他是本身实现了一个CopyOnWriteMap,这个CopyOnWriteMap采用的就是CopyOnWrite思想。
咱们来看一下这个CopyOnWriteMap的源码实现:
// 典型的volatile修饰普通Map
private volatile Mapmap;
@Override
public synchronized V put(K k, V v) {
// 更新的时候先建立副本,更新副本,而后对volatile变量赋值写回去
Mapcopy= new HashMap(this.map);
V prev = copy.put(k, v);
this.map = Collections.unmodifiableMap(copy);
return prev;
}
@Override
public V get(Object k) {
// 读取的时候直接读volatile变量引用的map数据结构,无需锁
return map.get(k);
}
复制代码
因此Kafka这个核心数据结构在这里之因此采用CopyOnWriteMap思想来实现,就是由于这个Map的key-value对,其实没那么频繁更新。
也就是TopicPartition-Deque这个key-value对,更新频率很低。
可是他的get操做倒是高频的读取请求,由于会高频的读取出来一个TopicPartition对应的Deque数据结构,来对这个队列进行入队出队等操做,因此对于这个map而言,高频的是其get操做。
这个时候,Kafka就采用了CopyOnWrite思想来实现这个Map,避免更新key-value的时候阻塞住高频的读操做,实现无锁的效果,优化线程并发的性能。
相信你们看完这个文章,对于CopyOnWrite思想以及适用场景,包括JDK中的实现,以及在Kafka源码中的运用,都有了一个切身的体会了。
若是你能在面试时说清楚这个思想以及他在JDK中的体现,而且还能结合知名的开源项目 Kafka 的底层源码进一步向面试官进行阐述,面试官对你的印象确定大大的加分。
原文自:石杉的架构笔记微信公众号