本篇文章会从并发编程的三大问题可见性、原子性、有序性的起源开始介绍,然后介绍可见性在JAVA中的体现,以及如何解决,介绍volatile的原理。
在并发编程中,一直存在三大问题可见性、原子性、有序性,只有理解了他们,我们才能具备分析并发编程中诡异BUG的能力。那这些问题最初是怎么诞生的呢?
其实,虽然这些年CPU、内存、IO设备都在不断迭代,速度越来越快,但他们之间有一个核心矛盾一直存在,那就是三者的速度差异。我们可以形象的描述为CPU上的一天,等于内存上的一年(假设CPU执行一条普通指令需要一年,那么CPU读写内存需要等待一年的时间),等于I/O设备的十年。
在程序中大部分语句都要访问内存,有些还要访问I/O,根据木桶理论(一只水桶能装多少水取决于它最短的那块木板),程序整体性能取决于最慢的操作,也就是读写I/O设备,因此单方面提升CPU性能是无效的。
为了更合理的利用CPU的高性能,平衡三者之间的速度差异,计算机体系结构、操作系统、编译程序都做出了共享,主要体现为:
现在几乎我们所有的程序都在默默享受着这些成功,但天下没有免费的午餐,它也给我们带来了很多并发编程中诡异的问题。
CPU为了平衡与内存直接的速度差异,增加了缓存。当多个线程使用的是同一个CPU时,使用的是同一个缓存,缓存一致,因此不存在可见性问题。但是,但我们多核CPU并行执行时,就可能出现可见性问题:
如上图,目前有俩个CPU,他们都各自将内存中的X读取到自己的缓存中。接下来CPU1修改了X的值:
发现了没有?虽然X的值已经被修改了,但CPU2缓存中的值依旧是5,缓存不一致了,也就是说线程1的修改对线程2来说不可见,这就是可见性问题。
总结一句话:缓存的加入导致可见性问题的产生
为了平衡CPU和I/O的速度差异,操作系统引入了进程、线程,以及CPU的分时复用。分时复用指的是每个线程每次只会被分配一个CPU时间片,当时间片到了,线程就需要让出CPU,让其他线程可以获取到CPU执行。
在一个时间片内,如果一个进程在进行IO操作,如读写文件,这时候进程可以把自己标记为"休眠"状态,并让出CPU使用权,等文件读入内存后,操作系统会把这个休眠的进程唤醒,唤醒后进程就有机会重新获取CPU的使用权了。
进程在等待IO时让出CPU使用权,这样可以让CPU在这段时间内去做一些其他的事情,从而提高了CPU的使用率。此外,如果此时有另外一个进程也读文件,读文件的操作就会排队,磁盘驱动在完成第一个进程的读操作后,发现有排队的任务,会立即启动下一个读操作,这样I/O的使用率也提升了。
不过,虽然多进程的分时复用提升了CPU利用率,也给我们带来了原子性问题。原因是CPU的任务切换可以发生在任意一条CPU指令执行完。但高级语言中的一条语句,往往需要多条CPU指令才能够完成,如count += 1,就需要至少三条CPU指令
也就是说,任务切换可能发生在上面三条指令任意一条,那这会带来什么问题呢?
在上图中线程1在做count+1后还未将结果写回内存中,此时发生了任务切换。线程2从内存中加载count的值,此时还是0,因此线程2就在0的基础上+1,最终将count=1写回内存中。线程1重新执行,将之前计算的结果count=1写回内存中。
发现了没有?俩个线程都进行了一次+1操作,但最终的结果确实count=1,这是因为任务切换破坏了我们的原子性。
编译器为了能够更好的利用CPU缓存,会对我们的代码进行"合理"的重排序。也就是说,最终代码的执行顺序可能与我们的代码编写顺序不同。
int a = 10; ①
int b = 3; ②
int c = a + 1; ③
比如上面的代码,最终的执行顺序可能是①③②
int a = 10; ①
int c = a + 1; ③
int b = 3; ②
这是因为在单线程环境下,②和③哪个先执行,最终的结果都是一样的。但因为①已经将a加载到CPU中了,如果此时能够先执行②的话,就可以省去再次从内存中读取a的步骤,而是直接从缓存中读取a,这样就能够更好的利用缓存。
但重排序只能保证在单线程环境下,最终的结果是正确的,如果是在多线程环境下,就可能发生意想不到的后果,这就是编译器优化带来的的可见性问题。
前面我们介绍了并发编程三大问题的源头,接下来我们介绍一下可见性问题在JAVA中的体现。首先,我们会先看一个有可见性问题的代码,然后从JAVA内存模型层面解释可见性问题的原因。
public class VisibilityTest {
private boolean flag = false;
private int count = 0;
public void refresh() {
flag = false;
System.out.println(Thread.currentThread().getName() + "修改flag:" + flag);
}
public void load() {
System.out.println(Thread.currentThread().getName() + "开始执行.....");
while (!flag) {
//TODO 业务逻辑
count++;
}
System.out.println(Thread.currentThread().getName() + "跳出循环: count=" + count);
}
public static void main(String[] args) throws InterruptedException {
VisibilityTest test = new VisibilityTest();
// 线程threadA模拟数据加载场景
Thread threadA = new Thread(() -> test.load(), "threadA");
threadA.start();
// 让threadA执行一会儿
Thread.sleep(1000);
// 线程threadB通过flag控制threadA的执行时间
Thread threadB = new Thread(() -> test.refresh(), "threadB");
threadB.start();
}
}
在上面的代码中,我们先启动了一个线程一直进行count++操作,直到flag被修改为false跳出循环。过了一秒后,我们启动第二个线程,将flag改为false,照理说线程1应该跳出循环,但实际上线程1并没有跳出循环,换句话说就是线程2对flag的修改对线程1不可见,也就是出现了可见性问题。
从JAVA内存模型(JMM)来解释一下可见性问题:
在JAVA内存模型中,每个线程都会有自己的工作内存,工作内存是私有的,对于其他线程不可见的。线程要读写一个变量,必须先将变量从主内存中读取到工作内存中,然后在工作内存中再进行操作,操作完后再写回主内存。也就是说,线程1需要先将flag读到自己的工作内存中。
接下来,启动线程2,线程将flag读取到进行的工作内存后,修改,并写回主内存中:
此时主内存中的flag已经被修改成true了,但由于线程1的工作内存flag还是false,因此线程1会一直死循环下去。
解决可见性问题很简单,只需要使用volatile关键字就可以了。比如上面的代码,我们只需要在flag前面加一个volatile关键字,就可以保证可见性,线程1最后会成功的跳出循环。
private volatile boolean flag = false;
当然,加锁,比如使用synchronized也可以保证可见性,毕竟synchronized会保证在任意时刻都只能一个线程访问共享资源,单线程自然也就没有可见性问题了,不过这样对我们的性能影响就大了,因此如果只是想保证可见性问题,使用volatile就够了。
为什么volatile能保证可见性?这还是要从Java内存模型说起,首先对volatile修饰的变量写操作会被立马写回出内存。但写回主内存还不够,因为其他线程的工作内存中可能也存在这个变量的副本,那么他就不会从主内存中读取最新的内容。因此volatile还有另外一层作用,就是让其他线程工作内存中的缓存失效,这样其他线程就会到主内存中读取最新的值,也就解决了可见性问题。
让其他线程的缓存失效是怎么做到的?这就要说到我们的MSEI协议了,MSEI协议是四个单词的缩写:
仅仅看概念是很难理解的,接下来我们演示一下这四种状态是如何转换的。我们还是以上面的JAVA内存模型为例:
1、首先线程1将缓存行读取到自己的工作内存中。此时因为缓存行只在线程1的工作内存中存在,因此缓存行的状态为E,表示独占
2、线程2也将缓存行读到自己得到工作内存中,此时缓存行在线程1和线程2中都存在,缓存行的状态会被修改为S共享状态
3、线程1修改flag值,缓存行被修改,缓存行状态变为M,此时有个叫总线的东西会感知到这个变化,
将其他线程中的缓存行状态改为I
4、线程1将缓存行写回主内存中,缓存行的状态重新变为独占
5、线程2读取工作内存的缓存行,发现缓存行的状态为Invalid,重新从主内存中读取最新的值
最后小结一下本篇文章的内容