一个线程对变量进行了修改,另外一个线程能够立刻读取到此变量的最新值。
可见性指的是,某个线程对共享变量进行了修改,其它线程能够立刻看到修改后的最新值。乍一听这个定义,你可能会觉得这不是废话吗?变量被修改了,线程当然能够立刻读取到!否则即使单线程的程序也会出问题啊!没错,变量被修改后,在本线程中确实能够立刻被看到,但并不保证别的线程会立刻看到。原因就是编程领域经典的两大难题之一----缓存一致性。
下面看个代码:
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 的变化,我决定先看会电视,可是等我刷完一集《乐队的夏天》回来,还是没有任何输出。
CPU 缓存模式
大家一定都知道摩尔定律。根据定律,CPU 每 18 个月速度将会翻一番。CPU 的计算速度提升了,但是内存的访问速度却没有什么大幅度的提升。这就好比一个脑瓜很聪明程序员,接到需求后很快就想好程序怎么写了。但是他的电脑性能很差,每敲一行代码都要反应好久,导致完成编码的时间依旧很长。所以人再聪明没有用,瓶颈在计算机的速度上。CPU 计算也是同样的道理,瓶颈出现在对内存的访问上。没关系,我们可以使用缓存啊,这已经是路人皆知的手段了。CPU 更狠一点,用了 L1、L2、L3,一共三级缓存。其中 L1 缓存根据用途不同,还分为 L1i 和 L1d 两种缓存。如下图:
缓存的访问速度是主存的几分之一,甚至几十分之一。通过缓存,极大的提高了 CPU 计算速度。CPU 会先从主存中复制数据到缓存,CPU 在计算的时候就可以从缓存读取数据了,在计算完成后再把数据从缓存更新回主存。这样在计算期间,就无须访问主存了,速度大大提升。加上缓存后,CPU 的数据访问如下:
我们再回头看上文的例子。blindThread 线程启动后,就进入 while 循环中,一直进行运算,运算时把 flag 从主存拿到了自己线程中的缓存,此后就会一直从缓存中读取 flag 的值。即便是 main 线程修改了 flag 的值。但是 blindThread 线程的缓存并未更新,所以取到的还一直是之前的值。导致 blindThread 线程一致也不会有输出。
其实概括下来就是:多个线程对同一个变量(称为:共享变量)进行操作,但是这多个线程有可能被分配到多个处理器中运行,那么编译器会对代码进行优化,当线程要处理该变量时,多个处理器会将变量从主存复制一份分别存储在自己的存储器中,等到进行完操作后,再赋值回主存。
这样做的好处是提高了运行的速度,同样优化带来的问题之一是变量可见性——如果线程 t1 与线程 t2 分别被安排在了不同的处理器上面,那么 t1 与 t2 对于变量 A 的修改时相互不可见,如果 t1 给 A 赋值,然后 t2 又赋新值,那么 t2 的操作就将 t1 的操作覆盖掉了,这样会产生不可预料的结果。因此,需要保证变量的可见性(一个线程对共享变量值的修改,能够及时地被其它线程看到)。
最低安全性
在前面的例子中,blindThread 线程读取到 flag 的值是之前有效的 false。但其现在已经失效了。也就是说 blindThread 读取到了失效数据。虽然线程在未做同步的时候会读取到失效值,但是起码这个值是曾经存在过的。这称之为最低安全性。我猜你一定会问,难道线程还能读取到从来没有设置过的值吗?是的,对于 64 位类型的变量 long 和 double,JVM 会把读写操作分解为两个 32 位的操作。如果两个线程分别去读和写,那么在读的时候,可能写线程只修改了一个 32 位的数据。此时读线程会读取到原来数值一个 32 位的数值和新的数值一个 32 位的数值。两个不同数值各自的一个 32 位数值合在一起会产生一个新的数值,没有任何线程设置过的数值。这就好比马和驴各一半的基因,会生出骡子一样。此时,就违背了最低安全性。
初识 volatile 关键字
要想解决可见性问题其实很简单。第一种方法就是解决一切并发问题的方法–同步。不过读和写都需要同步。 此外还有一个方法会简单很多,使用 volatile 关键字。 我们把例子中下面这行代码做一下修改。
flag 改成:
private volatile Boolean flag = false;
我们再次运行。现在程序居然可以正常输出了!是不是很简单的修改?
volatile 修饰的变量,在发生变化的时候,其它线程会立刻觉察到,然后从主存中取得更新后的值。volatile 除了简洁外,还有个好处就是它不会加锁,所以不会阻塞代码。关于 volatile 更多的知识我们后面还会做详细讲解。现在我们只要知道他能够以轻量级的方式实现同步就可以了。
总结
本节我们学习了可见性。如果不了解可见性,我们写出的并发代码,可能会出现各种违背逻辑的现象。现在我们已经弄清了问题产生的原因以及如何去解决,所以可见性的问题也没什么可怕的。开发遇到问题时不要慌,所有的问题都有其产生的原因,找到原因再对症下药,保准药到病除。
欢迎来到这里!
我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。
注册 关于