Java内存模型JMM与可见性

Java内存模型JMM与可见性

标签(空格分隔): java

1 何为JMM

JMM:通俗地讲,就是描述Java中各种变量(线程共享变量)的访问规则,以及在JVM中将变量存储到内存和从内存中读取变量这样的底层细节。

《Java内存模型JMM与可见性》

结合上图,先介绍几个概念:

主内存:保存了所有的变量。
共享变量:如果一个变量被多个线程使用,那么这个变量会在每个线程的工作内存中保有一个副本,这种变量就是共享变量。
工作内存:每个线程都有自己的工作内存,线程独享,保存了线程用到了变量的副本(主内存共享变量的一份拷贝)。工作内存负责与线程交互,也负责与主内存交互。

JMM对共享内存的操作做出了如下两条规定:

  • 线程对共享内存的所有操作都必须在自己的工作内存中进行,不能直接从主内存中读写;
  • 不同线程无法直接访问其他线程工作内存中的变量,因此共享变量的值传递需要通过主内存完成。

2 共享变量在线程间的可见性

由此可见不同线程都是直接操作自身工作内存中的副本,因此可能导致共享变量的修改在线程间不可见,所谓不可见,是指一个线程对共享变量的修改不能及时地被其他线程看到。导致共享变量在进程间不可见的原因有以下几个:

  • 指令重排序 & 线程交叉执行
  • 共享变量更新后的值没有在工作内存和主内存间及时更新

线程交叉执行:主要指线程调度。
指令重排序:为了发挥CPU性能,指令执行顺序可能与书写的不同,分为编译器优化的重排序(编译器优化),指令集并行重排序(处理器优化),内存系统的重排序(处理器优化)。
共享变量更新:如果想让线程A对共享变量的修改被线程B看到,需要以下步骤:把线程A的工作内存中更新过的变量刷新到主内存中,再将主内存中最新的共享变量的值刷新到线程B的工作内存中。如果更新不及时,则会导致共享变量的不可见,数据不准确,线程不安全。
说到重排序,就不得不说一下as-if-serial语义:无论如何重排序,程序执行的结果应该与代码顺序执行的结果一致。(编译器,运行时和处理器都会保证Java在单线程下遵循as-if-serial语义)

下面看一段代码:

public class PossibleReordering {
    static int x = 0, y = 0;
    static int a = 0, b = 0;

    public PossibleReordering() {}

    public static void main(String[] args) throws InterruptedException {
        int result[] = new int[4];
        for(int i = 0; i < 1000000; i++){
            Thread one = new Thread(new Runnable() {

                @Override
                public void run() {
                    a = 1;
                    x = b;
                }
            });
    
            Thread two = new Thread(new Runnable() {
                
                @Override
                public void run() {
                    b = 1;
                    y = a;
                }
            });
        
            x=y=a=b=0;
            one.start();
            two.start();
            one.join();
            two.join();
            
            //注意,此时线程one和two均以结束,其对x,y的修改已经写回到主内存
            int r = (x << 1) | (y);
            result[r]++;

        }
        
        System.out.println(Arrays.toString(result));
    }
}

该段代码可能输出的x和y有4种组合,分别为(0,0) (0,1) (1,0) (1,1)。一次典型的运行输出如下:[4, 941466, 58524, 6]。该输出代表(0,0)组合产生了4次,(0,1)组合产生了941466次,(1,0)组合产生了58524次,(1,1)组合产生了6次。
各种组合及其可能的原因如下表:

组合可能的产生原因
0 1线程one在two开始之前就完成
1 0线程two在one开始前就完成
1 1线程one和two交叉执行的结果
0 0乱序执行或共享变量更新到主内存不及时

由此可见,因此在没有正确同步的情况下,即是要推断最简单的并发程序的行为也很困难。

可能的一种乱序执行情况如下图所示:
《Java内存模型JMM与可见性》

重排序不会导致单线程的内存可见性问题,但多线程交错执行时,可能导致可见性问题,那么如何解决线程间对共享变量修改的可见性问题呢?

3 Java在语言层面实现可见性的两种方式

使用synchronized实现可见性:

Java中synchronized关键字有两重含义,一是大家所熟知的实现原子性,二就是实现内存可见性。
synchronized可见性规范:

  • 线程解锁前必须把共享变量的最新值刷新到主内存中
  • 线程加锁时,将清空工作内存中共享变量的值,从而需要从主内存中重新读取最新值。

因此,线程解锁前对共享变量的修改在下次加锁时对其他线程可见。整个过程如下:获得互斥锁-》清空工作内存-》从主内存拷贝-》执行代码-》写回主内存-》释放互斥锁。
与此同时,synchronized还会限制编译器、运行时和硬件对内存操作重排序的方式,从而在实施重排序时不会破坏JMM提供的可见性保证。

共享变量在线程间不可见的原因synchronized解决方案
重排序 & 线程交叉执行原子性(结合as-if-serial语义)
共享变量未及时更新通过synchronized可见性规范

使用volatile实现可见性:

Java中的volatile可以保证volatile变量的可见性,但不保证复合操作的原子性(如++)
volatile可见性规范:

  • 对volatile变量执行写操作时,会在写操作后加入一条store写屏障指令,强制将缓存刷新到主内存中
  • 对volatile变量执行读操作时,会在读操作前加入一条load读屏障指令,强制使缓冲区缓存失效,所以会从主内存读取最新值。
  • 防止指令重排序。

通俗来讲:volatile变量在每次被线程访问时,都强迫从主内存中重读该变量的值,而当该变量发生变化时,又会强迫线程将最新的值刷新到主内存。这样在任何时刻,不同的线程总能看到该变量的最新值。

共享变量在线程间不可见的原因volatile解决方案
重排序 & 线程交叉执行防止指令重排序
共享变量未及时更新通过volatile可见性规范

synchronized与volatile对比:

  • volatile不需要加锁,比synchronized轻量,不会阻塞线程。
  • 从内存可见性角度来看,volatile读相当于加锁,volatile写相当于解锁。
  • synchronized可以保证可见性+原子性。volatile只能保证可见性,不能保证原子性。

参考资料

慕课网:细说Java多线程之内存可见性
Java并发编程实战
程晓明:深入理解Java内存模型(一)——基础

    原文作者:冰峰029
    原文地址: https://www.cnblogs.com/IcePeak/p/4450972.html
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞