这是我参与2022首次更文挑战的第6天,活动详情查看:2022首次更文挑战
前言
COW - copyOnWriteArrayList
java中的CopyOnWrite的同步,大致上是通过以下方式维护线程安全性的:
-
读取时,预先获取array,对array进行操作
-
写入时(包括更新、添加、删除),预先上锁(RLock),获取array,在获取到的array上进行操作:
- 先copy(System.copy方式深拷贝一份新的)一份新的arr
- 在新的arr上做修改
- 把新的arrset回去
随后再解锁。
某种程度上来说,COW的性能实际并不好:
- 读取的时候相当于是基于当前版本读,修改的时候相当于是通过锁进行了串行。
因为每次修改都需要copy数组,因此COW更适合读多写少的场景。
而COW的这种方式,实际上和MVCC很相似:
- COW的读是基于当前稳定的数组(即:当前的数组),而不考虑当前并发的变更;
- MVCC下的读是基于版本进行的。
因此,这里通过ArrayList和数组的例子,来看看MVCC是如何实现的,以及COW设计较为不完善的地方。
隔离级别
mySQL相关的部分,都从juejin.cn/book/684473…
隔离级别之前,先来看看并发条件下,可能发生的问题。
并发下数据库的问题
脏写
此时假设有以下两个线程,并发地运行:
public static void job1(List<Integer> list){
list.set(1,2);
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(list.get(1));
}
public static void job2(List<Integer> list){
int originVal = list.get(1);
list.set(1,3);
list.set(1,originVal);
}
public static void main(String[] args) {
List<Integer> l1 = new ArrayList<>();
l1.add(0);
l1.add(0);
Thread t1 = new Thread(()->job1(l1));
t1.start();
new Thread(()->job2(l1)).start();
}
打印:0(或2,与线程分配有关)
也就是说:
- job2的修改被job1覆盖了
如果我们把job1和job2对应成一个事务(原子性的,方法结束后才算作事务执行成功提交了),那么这里就是数据库中的脏写了:
- job2修改了job1中未提交的数据
脏读
修改一下上面的代码,此时job1就不再修改数据了,我们在main方法中最后查看一下list的结果:
public static void job1(List<Integer> list){
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(list.get(1));
}
public static void job2(List<Integer> list){
int originVal = list.get(1);
list.set(1,3);
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
list.set(1,originVal);
}
public static void main(String[] args) throws InterruptedException {
List<Integer> l1 = new ArrayList<>();
l1.add(0);
l1.add(0);
Thread t1 = new Thread(()->job1(l1));
t1.start();
new Thread(()->job2(l1)).start();
Thread.sleep(3000);
System.out.println(l1.get(1));
}
输出结果:
3 0
这种情况对应的就是脏读,我们在job1中读取到了job2中未提交的数据,随后job2进行了回滚,导致job1中读到的数据找不到。
不可重复读
不可重复读相对来说是比较可以接受的一种并发下的错误,定义如下:
- A事务在条件相同的情况下,前后取出的同一个位置的数据,读出来的数据内容不同,这是由于读取到了其他事务最新的提交。
我们修改上面的代码,通过上锁和wait、notify,来模拟事务的提交:
static Object monitor = new Object();
public static void job1(List<Integer> list){
synchronized (monitor){
try {
System.out.println(list.get(0));
park();
System.out.println(list.get(0));
park();
System.out.println(list.get(0));
park();
System.out.println(list.get(0));
} catch (Exception e) {
e.printStackTrace();
}
System.out.println("exit job1");
}
}
public static void job2(List<Integer> list){
synchronized (monitor){
try {
list.set(0,1);
monitor.notify();
} catch (Exception e) {
e.printStackTrace();
}
}
}
public static void job3(List<Integer> list){
synchronized (monitor) {
list.set(0,2);
monitor.notify();
}
}
public static void job4(List<Integer> list){
synchronized (monitor) {
list.set(0,3);
monitor.notify();
}
}
public static void park() throws InterruptedException {
monitor.notify();
monitor.wait();
}
public static void main(String[] args) throws InterruptedException {
List<Integer> l1 = new ArrayList<>();
l1.add(0);
l1.add(0);
new Thread(()->job1(l1)).start();
new Thread(()->job2(l1)).start();
new Thread(()->job3(l1)).start();
new Thread(()->job4(l1)).start();
Thread.sleep(1000);
System.out.println(l1.get(0));
}
这里注意:
- 我们的job1模拟的是读取操作,job2~job4模拟的是多个事务修改的操作。
输出结果如下:
0 1 2 exit job1 3
在这里可以看到:在job1*”事务“*里,我们拿的位置都是相同的,但获取到的数据却不相同。
其实相对来说,不可重复读相对脏读脏写而言是可以忍受的,毕竟都提交了
幻读
幻读和不可重复读的区别在于:
- 不可重复读指的是之前查到的记录后面查不到了,幻读指的是读取到了之前没有读取到的记录。
我们再次修改上述代码来模拟幻读的情况:
static Object monitor = new Object();
public static void job1(List<Integer> list){
synchronized (monitor){
try {
System.out.println(list);
park();
System.out.println(list);
} catch (Exception e) {
e.printStackTrace();
}
System.out.println("exit job1");
}
}
public static void job2(List<Integer> list){
synchronized (monitor){
try {
list.add(1);
monitor.notify();
} catch (Exception e) {
e.printStackTrace();
}
}
}
public static void park() throws InterruptedException {
monitor.notify();
monitor.wait();
}
public static void main(String[] args) throws InterruptedException {
List<Integer> l1 = new ArrayList<>();
l1.add(0);
l1.add(0);
new Thread(()->job1(l1)).start();
new Thread(()->job2(l1)).start();
}
输出结果:
[0, 0] [0, 0, 1] exit job1
这里可以看到,第二次读取读取到了新的这个1,这是之前没有读取到的。
解决方式
CopyOnWrite
在COW是不会出现这些问题的,COW的解决方式堪称简单粗暴:
- 读取的操作,都以当前副本为准。
- 修改的操作,都以当前副本的copy为准,并且修改操作严格串行,直到修改结束,会让当前的list引用指向修改后的副本copy。
也就是说,不管我们查多少次,都是以我们进入时获取到的副本为基准的。
我们对上述代码中的job2做修改,来模拟COW中所做的操作:
public static void job1(List<Integer> list){
List<Integer> lista = new ArrayList<>(list);
//下面都以lista做修改了
synchronized(monitor){
//做完之后,把修改的写入,随后退出锁
setList(lista);
}
}
那么修改和读取本身就会被隔离,毕竟如果是读取,那么这个读取到的副本本身就是稳定的,并不会对这一对象做出修改。
MySQL
但对于上面COW而言性能上是有一个问题的:
- 写入都变成了串行,锁的粒度相当于是全体数据
那么,当并发写操作相对多的时候,会严重阻塞写的线程,虽然安全但性能过低,在写操作相对多的情况下就不会是一个好的选择了。
//todo
//MVCC+锁
MVCC:undo日志+readView
RC:每次读取生成一个readView
RR:只在事务开始的时候,生成一个readView