今天聊一个非常硬核的技术知识,给大家分析一下 CopyOnWrite 思想是什么,以及在 Java 并发包中的具体体现,包括在 Kafka 内核源码中是如何运用这个思想来优化并发性能的。
这个 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();
}
- 1.
- 2.
- 3.
- 4.
- 5.
- 6.
- 7.
- 8.
- 9.
- 10.
大家想想,类似上面的代码有什么问题呢?***的问题,其实就在于写锁和读锁的互斥。假设写操作频率很低,读操作频率很高,是写少读多的场景。
那么偶尔执行一个写操作的时候,是不是会加上写锁,此时大量的读操作过来是不是就会被阻塞住,无法执行?
这个就是读写锁可能遇到的***的问题。
引入 CopyOnWrite 思想解决问题
这个时候就要引入 CopyOnWrite 思想来解决问题了。
它的思想就是,不用加什么读写锁,锁统统给我去掉,有锁就有问题,有锁就有互斥,有锁就可能导致性能低下,你阻塞我的请求,导致我的请求都卡着不能执行。
那么它怎么保证多线程并发的安全性呢?很简单,顾名思义,利用“CopyOnWrite”的方式,这个英语翻译成中文,大概就是“写数据的时候利用拷贝的副本来执行”。
你在读数据的时候,其实不加锁也没关系,大家左右都是一个读罢了,互相没影响。
问题主要是在写的时候,写的时候你既然不能加锁了,那么就得采用一个策略。
假如说你的 ArrayList 底层是一个数组来存放你的列表数据,那么这时比如你要修改这个数组里的数据,你就必须先拷贝这个数组的一个副本。
然后你可以在这个数组的副本里写入你要修改的数据,但是在这个过程中实际上你都是在操作一个副本而已。
这样的话,读操作是不是可以同时正常的执行?这个写操作对读操作是没有任何的影响的吧!
大家看下面的图,一起来体会一下这个过程:
关键问题来了,那那个写线程现在把副本数组给修改完了,现在怎么才能让读线程感知到这个变化呢?
关键点来了,划重点!这里要配合上 Volatile 关键字的使用。
笔者之前写过文章,给大家解释过 Volatile 关键字的使用,核心就是让一个变量被写线程给修改之后,立马让其他线程可以读到这个变量引用的最近的值,这就是 Volatile 最核心的作用。
所以一旦写线程搞定了副本数组的修改之后,那么就可以用 Volatile 写的方式,把这个副本数组赋值给 Volatile 修饰的那个数组的引用变量了。
只要一赋值给那个 Volatile 修饰的变量,立马就会对读线程可见,大家都能看到***的数组了。
下面是 JDK 里的 CopyOnWriteArrayList 的源码:
// 这个数组是核心的,因为用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();
}
}
- 1.
- 2.
- 3.
- 4.
- 5.
- 6.
- 7.
- 8.
- 9.
- 10.
- 11.
- 12.
- 13.
- 14.
- 15.
- 16.
- 17.
- 18.
- 19.
- 20.
- 21.
大家看看写数据的时候,他是怎么拷贝一个数组副本,然后修改副本,接着通过 Volatile 变量赋值的方式,把修改好的数组副本给更新回去,立马让其他线程可见的。
然后大家想,因为是通过副本来进行更新的,万一要是多个线程都要同时更新呢?那搞出来多个副本会不会有问题?
当然不能多个线程同时更新了,这个时候就是看上面源码里,加入了 Lock 锁的机制,也就是同一时间只有一个线程可以更新。
那么更新的时候,会对读操作有任何的影响吗?绝对不会,因为读操作就是非常简单的对那个数组进行读而已,不涉及任何的锁。
而且只要他更新完毕对 Volatile 修饰的变量赋值,那么读线程立马可以看到***修改后的数组,这是 Volatile 保证的:
private E get(Object[] a, int index) {
// 最简单的对数组进行读取
return (E) a[index];
}
- 1.
- 2.
- 3.
- 4.
这样就***解决了我们之前说的读多写少的问题。如果用读写锁互斥的话,会导致写锁阻塞大量读操作,影响并发性能。
但是如果用了 CopyOnWriteArrayList,就是用空间换时间,更新的时候基于副本更新,避免锁,然后***用 Volatile 变量来赋值保证可见性,更新的时候对读线程没有任何的影响!
CopyOnWrite 思想在 Kafka 源码中的运用
在 Kafka 的内核源码中,有这么一个场景,客户端在向 Kafka 写数据的时候,会把消息先写入客户端本地的内存缓冲,然后在内存缓冲里形成一个 Batch 之后再一次性发送到 Kafka 服务器上去,这样有助于提升吞吐量。
话不多说,大家看下图:
这个时候 Kafka 的内存缓冲用的是什么数据结构呢?大家看源码:
private final ConcurrentMap<TopicPartition, Deque<RecordBatch>> batches =
new CopyOnWriteMap<TopicPartition, Deque<RecordBatch>>();
- 1.
- 2.
这个数据结构就是核心的用来存放写入内存缓冲中的消息的数据结构,要看懂这个数据结构需要对很多 Kafka 内核源码里的概念进行解释,这里先不展开。
但是大家关注一点,他是自己实现了一个 CopyOnWriteMap,这个CopyOnWriteMap 采用的就是 CopyOnWrite 思想。
我们来看一下这个 CopyOnWriteMap 的源码实现:
// 典型的volatile修饰普通Map
private volatile Map<K, V> map;
@Override
public synchronized V put(K k, V v) {
// 更新的时候先创建副本,更新副本,然后对volatile变量赋值写回去
Map<K, V> copy = new HashMap<K, V>(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);
}
- 1.
- 2.
- 3.
- 4.
- 5.
- 6.
- 7.
- 8.
- 9.
- 10.
- 11.
- 12.
- 13.
- 14.
- 15.
Kafka 这个核心数据结构在这里之所以采用 CopyOnWriteMap 思想来实现,就是因为这个 Map 的 Key-Value 对,其实没那么频繁更新。
也就是 TopicPartition-Deque 这个 Key-Value 对,更新频率很低。
但是它的 Get 操作却是高频的读取请求,因为会高频的读取出来一个 TopicPartition 对应的 Deque 数据结构,来对这个队列进行入队出队等操作,所以对于这个 Map 而言,高频的是其 Get 操作。
这个时候,Kafka 就采用了 CopyOnWrite 思想来实现这个 Map,避免更新 Key-Value 的时候阻塞住高频的读操作,实现无锁的效果,优化线程并发的性能。
相信大家看完这个文章,对于 CopyOnWrite 思想以及适用场景,包括 JDK 中的实现,以及在 Kafka 源码中的运用,都有了一个切身的体会了。
如果你能在面试时说清楚这个思想以及他在 JDK 中的体现,并且还能结合知名的开源项目 Kafka 的底层源码进一步向面试官进行阐述,面试官对你的印象肯定大大的加分。
中华石杉:十余年 BAT 架构经验,一线互联网公司技术总监。带领上百人团队开发过多个亿级流量高并发系统。现将多年工作中积累下的研究手稿、经验总结整理成文,倾囊相授。微信公众号:石杉的架构笔记(ID:shishan100)。