## 11 眼见不实—可见性
> 人生的价值,并不是用时间,而是用深度去衡量的。
> ——列夫·托尔斯泰
本节介绍并发三大特性的可见性。并发编程路上可谓困难重重。不过没有关系,道高一尺,魔高一丈。我们现在讲解的所有问题,都有能降伏住他的武器。但要想做常胜将军,那就要做到知己知彼。我们只要搞清楚有哪些问题,问题的根本原因是什么,困难才会迎刃而解。
由于我们的程序在绝大多数情况下是单线程运行的,另外即使是多线程,如果对象是无状态的,也不会有线程安全的问题。所以 JVM 更多会考虑单线程的需求。这也就造就了多线程程序在共享资源访的访问上存在问题。比如本节所讨论的可见性。
## 1\. 什么是可见性
可见性指的是,某个线程对共享变量进行了修改,其它线程能够立刻看到修改后的最新值。乍一听这个定义,你可能会觉得这不是废话吗?变量被修改了,线程当然能够立刻读取到!否则即使单线程的程序也会出问题啊!没错,变量被修改后,在本线程中确实能够立刻被看到,但并不保证别的线程会立刻看到。原因就是编程领域经典的两大难题之一----缓存一致性。
我们看一个例子,代码如下:
~~~java
public class visibility {
private static class ShowVisibility implements Runnable{
public static Object o = new Object();
private Boolean flag = false;
@Override
public void run() {
while (true) {
if (flag) {
System.out.println(Thread.currentThread().getName()+":"+flag);
}
}
}
}
public static void main(String[] args) throws InterruptedException {
ShowVisibility showVisibility = new ShowVisibility();
Thread blindThread = new Thread(showVisibility);
blindThread.start();
//给线程启动的时间
Thread.sleep(500);
//更新flag
showVisibility.flag=true;
System.out.println("flag is true, thread should print");
Thread.sleep(1000);
System.out.println("I have slept 1 seconds. I guess there was nothing printed ");
}
}
~~~
这段代码很简单,ShowVisibility 实现 Runnable 接口,在 run 方法中判断成员变量 flag 值为 true 时进行打印。main 方法中通过 showVisibility 对象启动一个线程。主线程等待 0.5 秒后,改变 showVisibility 中 flag 的值为 true。按正常思路,此时 blindThread 应该开始打印。但是,实际情况并非如此。运行此程序,输出如下:
~~~
flag is true, thread should print
I have slept 1 seconds. I guess there was nothing printed
~~~
没错,flag 改为 true 后,blindThread 没有任何打印。也就是说 blindThread 并没有观察到到 flag 的值变化。为了测试 blindThread 到底多久能看到 flag 的变化,我决定先看会电视,可是等我刷完一集《乐队的夏天》回来,还是没有任何输出。
![图片描述](https://img.mukewang.com/5d898b9b0001361f09020442.jpg)
是不是很神奇?是不是很玄学?作为程序员,你一定碰到过怎么都找不出原因的 bug,最后归于玄学。其实作为代码来说,不会有什么玄学。遇到的所有问题一定有其原因。只不过有些隐藏得很深,我们很难发现。或者也可能限于自己的认知,苦苦思考也找不到答案。
回到例子的问题本身来,执行结果完全违背我们的直觉。如果是单线程程序,做了一个变量的修改,那么程序是立即就能看到的。然而在多线程程序中并非如此。原因是 CPU 为提高计算的速度,使用了缓存。
## 2\. CPU 缓存模型
大家一定都知道摩尔定律。根据定律,CPU 每 18 个月速度将会翻一番。CPU 的计算速度提升了,但是内存的访问速度却没有什么大幅度的提升。这就好比一个脑瓜很聪明程序员,接到需求后很快就想好程序怎么写了。但是他的电脑性能很差,每敲一行代码都要反应好久,导致完成编码的时间依旧很长。所以人再聪明没有用,瓶颈在计算机的速度上。CPU 计算也是同样的道理,瓶颈出现在对内存的访问上。没关系,我们可以使用缓存啊,这已经是路人皆知的手段了。CPU 更狠一点,用了 L1、L2、L3,一共三级缓存。其中 L1 缓存根据用途不同,还分为 L1i 和 L1d 两种缓存。如下图:
![图片描述](https://img.mukewang.com/5d898bad0001bcad04920422.jpg)
缓存的访问速度是主存的几分之一,甚至几十分之一。通过缓存,极大的提高了 CPU 计算速度。CPU 会先从主存中复制数据到缓存,CPU 在计算的时候就可以从缓存读取数据了,在计算完成后再把数据从缓存更新回主存。这样在计算期间,就无须访问主存了,速度大大提升。加上缓存后,CPU 的数据访问如下:
![图片描述](https://img.mukewang.com/5d898bb90001752107950443.jpg)
我们再回头看上文的例子。blindThread 线程启动后,就进入 while 循环中,一直进行运算,运算时把 flag 从主存拿到了自己线程中的缓存,此后就会一直从缓存中读取 flag 的值。即便是main线程修改了 flag 的值。但是 blindThread 线程的缓存并未更新,所以取到的还一直是之前的值。导致 blindThread 线程一致也不会有输出。
## 3\. 最低安全性
在前面的例子中,blindThread 线程读取到flag的值是之前有效的 false。但其现在已经失效了。也就是说 blindThread 读取到了失效数据。虽然线程在未做同步的时候会读取到失效值,但是起码这个值是曾经存在过的。这称之为最低安全性。我猜你一定会问,难道线程还能读取到从来没有设置过的值吗?是的,对于 64 位类型的变量 long 和 double,JVM 会把读写操作分解为两个 32 位的操作。如果两个线程分别去读和写,那么在读的时候,可能写线程只修改了一个 32 位的数据。此时读线程会读取到原来数值一个 32 位的数值和新的数值一个 32 位的数值。两个不同数值各自的一个 32 位数值合在一起会产生一个新的数值,没有任何线程设置过的数值。这就好比马和驴各一半的基因,会生出骡子一样。此时,就违背了最低安全性。
## 4\. 初识 volatile 关键字
要想解决可见性问题其实很简单。第一种方法就是解决一切并发问题的方法–同步。不过读和写都需要同步。
此外还有一个方法会简单很多,使用 volatile 关键字。
我们把例子中下面这行代码做一下修改。
~~~java
private Boolean flag = false;
~~~
改为:
~~~java
private volatile Boolean flag = false;
~~~
我们再次运行。现在程序居然可以正常输出了!是不是很简单的修改?
volatile 修饰的变量,在发生变化的时候,其它线程会立刻觉察到,然后从主存中取得更新后的值。volatile 除了简洁外,还有个好处就是它不会加锁,所以不会阻塞代码。关于 volatile 更多的知识我们后面还会做详细讲解。现在我们只要知道他能够以轻量级的方式实现同步就可以了。
## 5\. 总结
本节我们学习了可见性。如果不了解可见性,我们写出的并发代码,可能会出现各种违背逻辑的现象。现在我们已经弄清了问题产生的原因以及如何去解决,所以可见性的问题也没什么可怕的。开发遇到问题时不要慌,所有的问题都有其产生的原因,找到原因再对症下药,保准药到病除。
开发工作中,我会遇到一些同事,遇到问题后不去分析问题产生的原因,先是自己猜测,试着乱改。发现自己不能解决后,网上搜索。找到相关帖子或文章,也不看原因是什么,直接复制粘贴代码,又是一顿试。即使这样最后解决了问题,我想对于他来说也是毫无收获的。我们不管遇到什么难题,一定不能乱了阵脚,还是从分析问题入手。最终解决问题一定是基于你分析出的原因。而不是靠猜测和盲目乱试。
- 前言
- 第1章 Java并发简介
- 01 开篇词:多线程为什么是你必需要掌握的知识
- 02 绝对不仅仅是为了面试—我们为什么需要学习多线程
- 03 多线程开发如此简单—Java中如何编写多线程程序
- 04 人多力量未必大—并发可能会遇到的问题
- 第2章 Java中如何编写多线程
- 05 看若兄弟,实如父子—Thread和Runnable详解
- 06 线程什么时候开始真正执行?—线程的状态详解
- 07 深入Thread类—线程API精讲
- 08 集体协作,什么最重要?沟通!—线程的等待和通知
- 09 使用多线程实现分工、解耦、缓冲—生产者、消费者实战
- 第3章 并发的问题和原因详解
- 10 有福同享,有难同当—原子性
- 11 眼见不实—可见性
- 12 什么?还有这种操作!—有序性
- 13 问题的根源—Java内存模型简介
- 14 僵持不下—死锁详解
- 第4章 如何解决并发问题
- 15 原子性轻量级实现—深入理解Atomic与CAS
- 16 让你眼见为实—volatile详解
- 17 资源有限,请排队等候—Synchronized使用、原理及缺陷
- 18 线程作用域内共享变量—深入解析ThreadLocal
- 第5章 线程池
- 19 自己动手丰衣足食—简单线程池实现
- 20 其实不用造轮子—Executor框架详解
- 第6章 主要并发工具类
- 21 更高级的锁—深入解析Lock
- 22 到底哪把锁更适合你?—synchronized与ReentrantLock对比
- 23 按需上锁—ReadWriteLock详解
- 24 经典并发容器,多线程面试必备—深入解析ConcurrentHashMap上
- 25 经典并发容器,多线程面试必备—深入解析ConcurrentHashMap下
- 26不让我进门,我就在门口一直等!—BlockingQueue和ArrayBlockingQueue
- 27 倒数计时开始,三、二、一—CountDownLatch详解
- 28 人齐了,一起行动—CyclicBarrier详解
- 29 一手交钱,一手交货—Exchanger详解
- 30 限量供应,不好意思您来晚了—Semaphore详解
- 第7章 高级并发工具类及并发设计模式
- 31 凭票取餐—Future模式详解
- 32 请按到场顺序发言—Completion Service详解
- 33 分阶段执行你的任务-学习使用Phaser运行多阶段任务
- 34 谁都不能偷懒-通过 CompletableFuture 组装你的异步计算单元
- 35 拆分你的任务—学习使用Fork/Join框架
- 36 为多线程们安排一位经理—Master/Slave模式详解
- 第8章 总结
- 37 结束语