读多写少的场景下引发的问题?
假设现在我们的内存里有一个 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 思想来解决问题了。它的思想就是,不用加什么读写锁,把锁统统去掉,有锁就有问题,有锁就有互斥,有锁就可能导致性能低下,会阻塞请求,导致别的请求都卡着不能执行。
那么它怎么保证多线程并发的安全性呢?
很简单,顾名思义,利用“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(); } }
我们可以看看写数据的时候,它是怎么拷贝一个数组副本,然后修改副本,接着通过 Volatile 变量赋值的方式,把修改好的数组副本给更新回去,立马让其他线程可见的。
因为是通过副本来进行更新的,万一要是多个线程都要同时更新呢?那搞出来多个副本会不会有问题?
当然不能多个线程同时更新了,这个时候就是看上面源码里,加入了 Lock 锁的机制,也就是同一时间只有一个线程可以更新。
那么更新的时候,会对读操作有任何的影响吗?
绝对不会,因为读操作就是非常简单的对那个数组进行读而已,不涉及任何的锁。而且只要他更新完毕对 Volatile 修饰的变量赋值,那么读线程立马可以看到最新修改后的数组,这是 Volatile 保证的:
private E get(Object[] a, int index) { // 最简单的对数组进行读取 return (E) a[index]; }
这样就完美解决了我们之前说的读多写少的问题。如果用读写锁互斥的话,会导致写锁阻塞大量读操作,影响并发性能。
但是如果用了 CopyOnWriteArrayList,就是用空间换时间,更新的时候基于副本更新,避免锁,然后最后用 Volatile 变量来赋值保证可见性,更新的时候对读线程没有任何的影响!