Java多线程(8)-JMM(Java Memory Model)

199 阅读6分钟

JMM

Java的内存模型分为栈(stack)堆(heap), 栈空间是每个线程独有的,每个线程的栈空间中都会有多个方法栈桢,看下面的代码,


public static void main(String[] args) {
   Object a = new Object();
}

这份代码运行起来后,jvm会启动一个名叫main的线程,同时为该线程分配栈空间,并运行一个名为main的方法栈桢,

而这个main方法运行后,栈桢中将会有一个类型为object,名为a的局部私有变量,

new Object() 这个对象将被创建到heap堆上去,在堆中的对象也可以被其他的地方的使用,但是栈桢中的局部私有变量,只有自己可以访问到,

例如下方的代码,test1test2方法中,都有一个局部变量叫做i, 并且他们的状态值都是相互隔离的,但是他们可以访问同一个对象a,

Object a = new Object();

public void test1() {
    int i = 0;
    System.out.println(a);
}

public void test2() {
    int i = 1;
    System.out.println(a);
}

image.png

Java内存模型中,除了方法中的局部变量是线程私有的之外,除此之外的其他东西都是可以共享的,由于数据状态共享,就会引发一系列的问题,例如安全性,正确性等等,

Java内存模型的定义规范为: JSR-133: Java Memory Model and Thread Specification, 可以搜索一下该资料查看。

副本

虽然公有变量是可以共享的,但是为了发挥多核CPU线程的威力,公有变量在每个线程当中也是会存有副本的,

image.png

因为线程和内存之间同步数据的开销很大,CPU和自己的高速缓存取数据,比去内存当中取数据要快很多,每个线程在自己的工作内存当中会存有一个副本,定期和主内存(也就是真正存有这个数据的内存,就是堆)进行数据状态的同步,

由于每个线程都在自己的内存中持有数据副本,所以当多个线程对自己本地的副本做修改时,最终再同步到主内存中,就会出现由于副本数据不准确,导致的最终回写回去的数据也是错误的情况,

看下方代码:

static boolean cancel = false;

public static void main(String[] args) {
    new Thread(() -> {
        while (!cancel) {

        }
        System.out.println("我结束了");
    }).start();
    cancel = true;
}

笔者运行结果如下:

image.png

不停的在死循环,但是这种情况不是每次运行都会发生的,笔者这个死循环是运行了多次该程序出现的,

也就是说有部分几率会正常结束,有部分几率会死循环,读者可以自己测试一下,多运行几次,

image.png

这个是为什么呢? 这就是刚刚提到的副本引起的,由于死循环是在新的线程中,并且验证一个公有变量cancel,如果是false,就取消死循环,

我们在main线程汇总,设置了cancel = false,意味着让另外一个线程取消死循环,但这个设置并不是每次都生效,

有时候不会发生死循环,有时候发生死循环,正是因为main线程和newThread,都对这个cancel持有副本,

有的时候main线程修改了自己本地副本为false,但是这个副本的值没有被及时写会主内存,或者newThread没有及时从主内存中同步这个值,回到自己的副本,

这就导致了两把的cancel的值不同,程序的运行状态也无法达到预期的效果,如何解决这个问题?

volatile

刚刚的副本问题,我们可以使用valatile关键字进行修饰变量,可以保证变量副本引起的问题,

这个关键字的特性如下:

  1. volatile修饰的变量,对其进行写入时,会直接写入主内存

  2. volatile修饰的变量,对其进行读取时,会直接读取主内存

所以采用了这个关键字后,两个线程对该变量的读取和写入都同时使用了主内存的数据,而不是本地的副本,

所以很快的就可以看到对方所做的修改,并做出正确的反应,修改如下:


static volatile boolean cancel = false;

public static void main(String[] args) {
    new Thread(() -> {
        while (!cancel) {

        }
        System.out.println("我结束了");
    }).start();
    cancel = true;
}

但是需要注意的是,volatile保证的仅仅是可见性,也就是数据被改后的及时反应,不保证原子性,也就是多个线程对同一个变量进行修改状态时,不保证最终的结果一定是正确的,这个需要锁去做处理,

指令重排

指令重排, 编译器与CPU会在代码编译或运行的时候,对一些看起来毫不相关的代码,做顺序调整,以此达到更好的执行性能,

也就是你的代码可能在真正执行的时候,和你书写的顺序是不一样的,如果是这样的话,在某些特殊的情况下,可能会出现问题,看如下代码:

static boolean initFinished = false;

public static void main(String[] args) {
   init();
   initFinished = true;
   // 开启一个线程监听初始化结束后,做一些操作
   new Thread(() -> {
       while (true) {
           if (initFinished) {
               // 执行结束, 做一些执行结束后的清理工作,并结束死循环
               break;
           }
       }
   }).start();

}

public static void init () {
    // 一些初始化操作
}

上面的代码中,我们先进行初始化方法,初始化方法执行完毕以后,我们将initFinished设置为true,

随后另外一个线程监听到initFinished为true以后,做一些资源清理的处理,

但是,由于指令重排,我们上面的两行代码很有可能会被处理为:

   init();
   initFinished = true;

重排为

   initFinished = true;
   init();

也就是说CPU或者编译器认为我们的两行代码,毫无关联,因为优化执行效率,将两行互换了,

这时候会产生,initFinished变成true以后,初始化方法还没有执行,这时候另一个线程监听到initFinished为true,以为init方法已经执行完毕了,

进行一些事后清理操作,但此时init方法其实还没有执行结束,这就是指令重排带来的不便之处,

解决这个问题使用 volatile 关键字修饰 initFinished 变量就好,也就是说volatile可以禁止指令重排,

   static volatile boolean initFinished = false;

   .......

   init();
   // 在读之前产生内存屏障
   initFinished = true;
   // 在写之后产生内存屏障
   
   .......

volatile 修饰的变量,在该变量从内存中读取前,或者写入后,产生一个内存屏障, 其作用是用来防止指令重排的 ,

所以当使用了volatile关键字后,给我们带来的好处就是,变量的及时可见性,以及防止指令重排把程序的顺序调整,带来的不便。

具有同步手段的时候无需volatile

当我们使用synchronizedLockAtomicInteger 等同步手段对一个变量进行处理的时候,就不在需要volatile了,

因为同步手段更为严格,除了保证可见性和重排的问题外,还可以保证原子性。