可见性是什么?(通俗易懂)
谈谈硬件架构模型
先谈谈硬件是如何工作的,举个例子,你在 window 操作系统上需要下载一个游戏(20M),就需要使用 cpu 和内存了,在这个过程中 cpu 负责计算,比如计算下载进度,统计下载完成一共需要多少时间等,内存为 cpu 提供数据的,负责保存游戏的所有信息,比如游戏的大小(20M)数据。在这个过程中,cpu 从内存上取游戏大小这个数据,然后 cpu 去计算下载进度,把计算出的进度结果再写到内存,最终呈现到用户页面,大概对 cpu 和内存应该有个大概的认识了吧!看上去下载游戏这个过程分工明确,没有问题,但实际上 cpu 的计算速度比内存的存取速度高了不知道多少个数量级,这个过程 cpu 很空闲啊(如图一),cpu 你闲着没事干那就是浪费资源浪费钱啊,这是个问题,于是人们就想了个办法,在内存上面加个(高速)缓存,如果是一些常用信息,比如游戏大小这个数据,那就不用在内存取了,直接在缓存上拿(如图二),而缓存设计的存取速度是很快的,当然价格也更高,如果刚好缓存上有这个游戏大小数据,这个操作在计算机的世界叫做缓存命中,这样就解决了 cpu 很闲的问题。哈哈,还是举个简单例子吧,咱春节买票回家,尽管你的手速很快,但是还是一票难求,12306 官网响应速度慢,没办法家还是要回的,那就找黄牛,虽然价格贵但是能解决你的痛点。这个例子中你,12306 系统,黄牛分别对应 cpu,内存和缓存,方便你理解。顺便说下,这个黄牛其实也是设计模式中的代理。(图三,图四)
案例分析 JMM 不可见性
了解了硬件架构,再来理解 Java 内存模型(JMM),如鱼得水,JMM 是根据硬件架构映射出来的,不是真实存在的,硬件模型是物理的,是真实存在的,如下图所示,如果现在有两个线程 AB 需要同时将共享变量 c 的值加 1,最终的程序运行的结果 c 的值可能是 3,也可能是 2。那我们一起来看看程序执行过程吧,程序初始化,线程 AB 将拷贝主内存的共享变量 c 到各自的工作内存,此时工作内存 A,工作内存 B 的初始化值 c 值都为 1,初始化结束,如下图所示。这里可以把线程 A 理解成 cpu1,线程 B 理解成 cpu2,工作内存理解成高速缓存。这个过程因为工作内存是线程私有的,因为每个高速缓存是属于不同 CPU 是不可见的,工作内存 A 看不见工作内存 B 的 c 值为 1,相反工作内存 B 也看不到工作内存 A 的 c 值。
当线程 AB 同时将共享变量 c 加 1 时,如果线程 A 先获取时间片,此时工作内存 A 的 c 值加 1 等于 2,然后由工作内存 A 将变量 c=2 同步到主内存,此时主内存 c 变量为 2 了,线程 A 执行结束,释放时间片给线程 B,如下图所示。此时主内存会更新线程 B 的工作内存 B,将 c=2 告诉线程 B,并更新工作内存 B 的值 c=2,此时 B 获取时间片,看到工作内存 B 值是 c=2,加 1,c=3,线程 B 将 c=3 写到主内存,此时主内存 c 的值就是 3 了,线程 B 执行结束,整个程序结束。其实在这个过程中,还有一种意外情况,如果线程 A 执行结束后,将主内存的 c 值变为 2,如果主内存 c=2 还没有同步更新到工作内存 B 呢?此时问题就来了,线程 B 获取时间片后发现自己的工作内存变量 c 还是 1,然后加 1,此时 c=2,将 c 再更新到主内存,此时主内存的值还是 2,主内存再同步 c=2 的值给线程 B 已经失去意义了,因为线程全部执行完毕。在这个程序执行过程中,其实导致线程安全的本质问题是主内存通知不及时才导致发生的,这个案例中因为主内存不能及时将 c=2 的值更新到线程 B 的工作内存,导致线程 B 获取不到 c 已经更新为 2 了。
那问题来了,cpu 各自带着私有缓存,线程带着各自私有工作内存,数据都靠着主内存来通信,但是主内存偏偏又不给力啊,通知线程 B 的工作内存不给力,导致结果 c=2 或者 c=3 的,这就是出现了线程安全问题了,这种安全性问题是由于缓存不可见造成的,于是我开始怀恋单核 cpu 时代了,但是逃避也解决不了实际问题,于是那些聪明的人们就想既然缓存不一致,那所有缓存都实现统一的协议可以吗,下面我们就简单聊下缓存一致性协议。
硬件缓存不一致方案
1)总线 Lock#锁。锁定总线的开销比较大,在缓存更新内存后,其他的 cpu 都会被锁定住,禁止与内存通信,这样开销就大了。
2)MESI 协议。这是缓存一致性协议的具体实现,它通过嗅探技术识别哪个 cpu 想修改主内存缓存行信息,如果该缓存行是共享的,先将该缓存行刷新到主内存,再设置其他 cpu 的高速缓存的缓存行无效,但频繁的嗅探其他 cpu 想修改的共享数据,也会导致总线风暴。
什么是可见性
可见性,有序性,原子性是线程安全的三个重要指标。可见性对理解多线程非常非常非常重要!由于多核硬件架构的问题,cpu 高速缓存之间本身是不可见的,必须要实现缓存一致性协议。我们刚才上面也说了硬件方面的方案,多线程对共享变量是不可见的,Java 方面也提供了两个关键字来保证多线程情况下共享变量的可见性方案。
volatile 实现可见性
在 JVM 手册中,当多线程写被 volatile 修饰的共享变量时,有两层语义。
1)该变量立即刷新到主内存。
2)使其他线程的共享变量立即失效。言外之意当其他线程需要的时候再从主内存取。
在上述案例中,如果 c 为一个布尔值并且被 volatile 修饰,那么当线程 AB 同时更新共享变量 c 时,此时 c 对于工作内存 AB 是可见的。
synchronized 实现可见性
在 JVM 手册中,synchronized 可见性也有两层语义。
1)在线程加锁时,必须从主内存获取值。
2)在线程解锁时,必须把共享变量刷新到主内存。
这两句说明了,时刻保持主内存数据最新,当新的线程获取锁需要从主内存获取值。
总结
今天通过可见性的话题,引出了硬件架构,硬件架构因为多 cpu 高速缓存引出的不可见性问题,从而引出了解决可见性的方案,这是基于硬件的。从 Java 高级语言的角度,引出了基于硬件的映射模型 JMM,并给出了 jvm 要实现可见性用的一些关键字。谢谢大家的观看,我是叫练,边叫边练。有疑问和错误欢迎留言和指正。
版权声明: 本文为 InfoQ 作者【叫练】的原创文章。
原文链接:【http://xie.infoq.cn/article/19912d432532863dcb683e44e】。文章转载请联系作者。
评论