什么是可见性问题

在JMM(Java内存模型)中规定了所有的变量都存储在主内存(Main Memory)中,每条线程还有自己的工作内存(Working Memory,可以与处理器的高速缓存类比),线程的工作内存中保存了被该线程使用的的变量的主内存副本,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读取主内存中的数据。不同线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。
因此,如果工作内存和主存的信息没有及时同步,就会存在线程间对共享变量(例如类的成员变量)操作时其值不一致,从而导致程序的错误运行。

在这里插入图片描述

以下是笔者认为的两种导致可见性问题的观点与分析(欢迎大家一起探讨并指正)

可见性问题分析与解决

主存和工作内存导致

我们从一段简单的代码来看看到底什么是可见性问题。

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();
            }
        },"start-Thread");
        Thread checkThread = new Thread(new Runnable() {
            @Override
            public void run() {
                while (true){
                    demo.checkStartes();
                }
            }
        },"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多线程编程环境下的可见性问题。也是上面代码会出现问题的原因。

JIT即时编译器优化导致

先来看一个现象,main 线程对 run 变量的修改对于 t 线程不可见,导致了 t 线程无法停止:

public class TestVisible {
static boolean run = true;
    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(()->{
            while(run){
            // ....线程内操作
            }
        });
        t.start();
        sleep(1);
        run = false; // 线程t不会如预想的停下来
    }
}

为什么呢?分析一下:

  1. 初始状态, t 线程刚开始从主内存读取了 run 的值到工作内存。

    在这里插入图片描述

  2. 因为 t 线程要频繁从主内存中读取 run 的值,JIT 编译器会将 run 的值缓存至自己工作内存中的高速缓存中,减少对主存中 run 的访问,提高效率

    在这里插入图片描述

  1. 秒之后,main 线程修改了 run 的值,并同步至主存,而 t 是从自己工作内存中的高速缓存中读取这个变量
    的值,结果永远是旧值

    在这里插入图片描述

解决方法:

volatile(易变关键字)
它可以用来修饰成员变量和静态成员变量,他可以避免线程从自己的工作缓存中查找变量的值,必须到主存中获取它的值,线程操作 volatile 变量都是直接操作主存。
使用volatile关键字修饰一个变量可以保证变量的可见性。所以对于上面的代码,我们只需要简单的修改下代码就可以让程序正确运行了。(即用volatile关键字修饰以上代码的共享变量)

使用volatile修饰一个共享变量可以达到如下的效果:
1、一旦线程对这个共享变量的副本做了修改,会立马刷新最新值到主内存中去;
2、一旦线程对这个共享变量的副本做了修改,其他线程中对这个共享变量拷贝的副本值会失效,其他线程如果需要对这个共享变量进行读写,必须重新从主内存中加载。

那么volatile具体是怎么达到上面两个效果的呢?其实volatile底层使用的是内存屏障来保证可见性的。

内存屏障(英语:Memory barrier),也称内存栅栏,内存栅障,屏障指令等,是一类同步屏障指令,是CPU或编译器在对内存随机访问的操作中的一个同步点,使得此点之前的所有读写操作都执行后才可以开始执行此点之后的操作。大多数现代计算机为了提高性能而采取乱序执行,这使得内存屏障成为必须。

语义上,内存屏障之前的所有写操作都要写入内存;内存屏障之后的读操作都可以获得同步屏障之前的写操作的结果。因此,对于敏感的程序块,写操作之后、读操作之前可以插入内存屏障。

public class TestVolatile {
    int num = 0;
    volatile boolean ready = false;
    // 线程1 执行此方法
    public void actor1(I_Result r) {
        //读屏障会在volatile修饰的变量前,保证此屏障后的屏障之后的读操作都可以获得同步屏障之前的写操作的结果
        if(ready) {
            r.r1 = num + num;
        } else {
            r.r1 = 1;
        }
    }
    // 线程2 执行此方法
    public void actor2(I_Result r) {
        num = 2;
        ready = true;
        //写屏障会加载volatile修饰的变量后,保证此屏障之前的写操作结果都同步到主存中(因为num的操作在ready之前,所以也被该屏障保证,所以num不需要volatile修饰)
    }
}

class I_Result {
    public int r1;
}

对内存屏障做下简单的总结:
1、内存屏障是一个指令级别的同步点;
2、内存屏障之前的写操作都必须立马刷新回主内存;
3、内存屏障之后的读操作都必须从主内存中读取最新值;
4、在有内存屏障的地方,会禁止指令重排序,即屏障下面的代码不能跟屏障上面的代码交换执行顺序,即在执行到内存屏障这句指令时,在它前面的操作已经全部完成。

JMM针对可见性问题,主要提供了如下手段:

  • volatile关键字
  • synchronized关键字
  • Lock锁
  • CAS操作(原子操作类)
    有兴趣的同学可以自行研究其他几种方式如何保证可见性

注:volatile只能保证可见性和有序性,不能保证原子性
博客链接:
https://regardlessman.github.io/2022/12/03/JMM%E4%B9%8B%E5%8F%AF%E8%A7%81%E6%80%A7/

Logo

为开发者提供学习成长、分享交流、生态实践、资源工具等服务,帮助开发者快速成长。

更多推荐