本博客系列是学习并发编程过程中的记录总结. 由于文章比较多, 写的时间也比较散, 所以我整理了个目录贴 (传送门), 方便查阅.
并发编程系列博客传送门
前言
之前的文章中讲到, JMM 是内存模型规范在 Java 语言中的体现. JMM 保证了在多核 CPU 多线程编程环境下, 对共享变量读写的原子性, 可见性和有序性.
本文就具体来讲讲 JMM 是如何保证共享变量访问的可见性的.
什么是可见性问题
我们从一段简单的代码来看看到底什么是可见性问题.
- public class VolatileDemo {
- boolean started = false;
- public void startSystem(){
- System.out.println(Thread.currentThread().getName()+"begin to start system, time:"+System.currentTimeMillis());
- started = true;
- System.out.println(Thread.currentThread().getName()+"success to start system, time:"+System.currentTimeMillis());
- }
- public void checkStartes(){
- if (started){
- System.out.println("system is running, time:"+System.currentTimeMillis());
- }else {
- System.out.println("system is not running, time:"+System.currentTimeMillis());
- }
- }
- public static void main(String[] args) {
- VolatileDemo demo = new VolatileDemo();
- Thread startThread = new Thread(new Runnable() {
- @Override
- public void run() {
- demo.startSystem();
- }
- });
- startThread.setName("start-Thread");
- Thread checkThread = new Thread(new Runnable() {
- @Override
- public void run() {
- while (true){
- demo.checkStartes();
- }
- }
- });
- checkThread.setName("check-Thread");
- startThread.start();
- checkThread.start();
- }
- }
上面的列子中, 一个线程来改变 started 的状态, 另外一个线程不停地来检测 started 的状态, 如果是 true 就输出系统启动, 如果是 false 就输出系统未启动. 那么当 start-Thread 线程将状态改成 true 后, check-Thread 线程在执行时是否能立即 "看到" 这个变化呢? 答案是不一定能立即看到. 这边我做了很多测试, 大多数情况下是能 "感知" 到 started 这个变量的变化的. 但是偶尔会存在感知不到的情况. 请看下下面日志记录:
- start-Thread begin to start system, time:1577079553515
- start-Thread success to start system, time:1577079553516
system is not running, time:1577079553516 ==> 此处 start-Thread 线程已经将状态设置成 true, 但是 check-Thread 线程还是没检测到
- system is running, time:1577079553516
- system is running, time:1577079553516
- system is running, time:1577079553516
- system is running, time:1577079553516
- system is running, time:1577079553516
- system is running, time:1577079553516
- system is running, time:1577079553517
- system is running, time:1577079553517
- system is running, time:1577079553517
- system is running, time:1577079553517
- system is running, time:1577079553517
- system is running, time:1577079553517
- system is running, time:1577079553517
- system is running, time:1577079553519
- system is running, time:1577079553519
- system is running, time:1577079553519
- system is running, time:1577079553519
- system is running, time:1577079553519
- system is running, time:1577079553519
- system is running, time:1577079553519
- system is running, time:1577079553519
- system is running, time:1577079553519
上面的现象可能会让人比较困惑, 为什么有时候 check-Thread 线程能感知到状态的变化, 有时候又感知不到变化呢? 这个现象就是在多核 CPU 多线程编程环境下会出现的可见性问题.
Java 内存模型规定了所有的变量都存储在主内存中, 每条线程还有自己的工作内存, 线程在工作内存中保存的值是主内存中值的副本, 线程对变量的所有操作都必须在工作内存中进行, 而不能直接读写主内存. 等到线程对变量操作完毕之后会将变量的最新值刷新回到主内存.
但是何时刷新这个最新值又是随机的. 所以就有可能一个线程已经将一个共享变量更新了, 但是还没刷新回主内存, 那么这时其他对这个变量进行读写的线程就看不到这个最新值. 这个就是多 CPU 多线程编程环境下的可见性问题. 也是上面代码会出现问题的原因.
JMM 对可见性问题的保证
在多 CPU 多线程编程环境下, 对共享变量的读写会出现可见性问题. 但是幸好 JMM 提供了相应的技术手段来帮我们规避这些问题, 可以让程序正确运行. JMM 针对可见性问题, 主要提供了如下手段:
volatile 关键字
synchronized 关键字
Lock 锁
CAS 操作 (原子操作类)
volatile 关键字
使用 volatile 关键字修饰一个变量可以保证变量的可见性. 所以对于上面的代码, 我们只需要简单的修改下代码就可以让程序正确运行了.
private volatile boolean started = false;
使用 volatile 修饰一个共享变量可以达到如下的效果:
一旦线程对这个共享变量的副本做了修改, 会立马刷新最新值到主内存中去;
一旦线程对这个共享变量的副本做了修改, 其他线程中对这个共享变量拷贝的副本值会失效, 其他线程如果需要对这个共享变量进行读写, 必须重新从主内存中加载.
那么 volatile 具体是怎么达到上面两个效果的呢? 其实 volatile 底层使用的是内存屏障来保证可见性的.
内存屏障 (英语: Memory barrier), 也称内存栅栏, 内存栅障, 屏障指令等, 是一类同步屏障指令, 是 CPU 或编译器在对内存随机访问的操作中的一个同步点, 使得此点之前的所有读写操作都执行后才可以开始执行此点之后的操作. 大多数现代计算机为了提高性能而采取乱序执行, 这使得内存屏障成为必须.
语义上, 内存屏障之前的所有写操作都要写入内存; 内存屏障之后的读操作都可以获得同步屏障之前的写操作的结果. 因此, 对于敏感的程序块, 写操作之后, 读操作之前可以插入内存屏障.
对内存屏障做下简单的总结:
内存屏障是一个指令级别的同步点;
内存屏障之前的写操作都必须立马刷新回主内存;
内存屏障之后的读操作都必须从主内存中读取最新值;
在有内存屏障的地方, 会禁止指令重排序, 即屏障下面的代码不能跟屏障上面的代码交换执行顺序, 即在执行到内存屏障这句指令时, 在它前面的操作已经全部完成.
synchronized 关键字
使用 synchronized 代码块或者 synchronized 方法也可以保证共享变量的可见性. 只要如下修改上面的代码, 我们就能得到正确的执行结果.
- public synchronized void startSystem(){
- System.out.println(Thread.currentThread().getName()+"begin to start system, time:"+System.currentTimeMillis());
- value = 2;
- started = true;
- System.out.println(Thread.currentThread().getName()+"success to start system, time:"+System.currentTimeMillis());
- }
- public synchronized void checkStartes(){
- if (started){
- System.out.println("system is running, time:"+System.currentTimeMillis());
- }else {
- System.out.println("system is not running, time:"+System.currentTimeMillis());
- }
- }
当线程释放锁时, JMM 会把该线程对应的本地内存中的共享变量刷新到主内存中. 当线程获取锁时, JMM 会把该线程对应的本地内存置为无效. 从而使得被监视器保护的临界区代码必须从主内存中读取共享变量. 我们发现锁具有和 volatile 一致的内存语义, 所以使用 synchronized 也可以实现共享变量的可见性.
Lock 接口
使用 Lock 相关的实现类也可以保证共享变量的可见性. 其实现原理和 synchronized 的实现原理类似, 这边也就不再赘述了.
CAS 机制 (Atomic 类)
使用原子操作类也可以保证共享变量操作的可见性. 所以我们只要如下修稿上面的代码就行了.
private AtomicBoolean started = new AtomicBoolean(false);
原子操作类底层使用的是 CAS 机制. Java 中 CAS 机制每次都会从主内存中获取最新值进行 compare, 比较一致之后才会将新值 set 到主内存中去. 而且这个整个操作是一个原子操作. 所以 CAS 操作每次拿到的都是主内存中的最新值, 每次 set 的值也会立即写到主内存中.
来源: https://www.cnblogs.com/54chensongxia/p/12084425.html