深入理解Java内存模型

注意这里是Java内存模型,不是Java内存结构,这两个就不是在同一级别上的东东,有些人会把Java内存模型误解为Java内存结构,然后在面试问到的时候很自信的答到堆,栈,GC垃圾回收,最后和面试官想问的问题相差甚远。本文我们就来学习一下JMM有关的基础知识吧!

思维导图

什么是JMM?

JMM就是 Java Memory Model 的缩写,中文名即Java内存模型。因为在不同的硬件生产商和不同的操作系统下,内存的访问有一定的差异,所以会造成相同的代码运行在不同的系统上会出现各种问题。所以java内存模型(JMM)屏蔽掉各种硬件和操作系统的内存访问差异,以实现让java程序在各种平台下都能达到一致的并发效果。

Java内存模型将内存分为主内存工作内存两个部分,并且规定所有的变量都存储在主内存中,包括实例变量、静态变量,但是不包括局部变量和方法参数。每个线程都有自己的工作内存,线程工作内存保存了该线程用到的变量和主内存的副本拷贝,线程对变量的操作都在工作内存中进行。线程不能直接读写内存中的变量

不同的线程之间也无法访问对方工作内存中的变量。线程之间变量值的传递均需要通过主内存来完成

如果听起来抽象的话,我可以画张图给你看看,会直观一点:

每个线程的工作内存都是独立的,线程操作数据只能在工作内存中进行,然后刷回到主存。这是 Java 内存模型定义的线程基本工作方式。

JMM定义了什么?

这个简单,整个Java内存模型实际上是围绕着三个特征建立起来的。分别是:原子性,可见性,有序性。这三个特征可谓是整个Java并发的基础。

原子性

原子性指的是一个操作是不可分割,不可中断的,一个线程在执行时不会被其他线程干扰。

面试官拿笔写了段代码,下面这几句代码能保证原子性吗

int i = 2;
int j = i;
i++;
i = i + 1;

第一句是基本类型赋值操作,必定是原子性操作。

第二句先读取i的值,再赋值到j,两步操作,不能保证原子性。

第三和第四句其实是等效的,先读取i的值,再+1,最后赋值到i,三步操作了,不能保证原子性。

JMM只能保证基本的原子性,如果要保证一个代码块的原子性,提供了monitorenter 和 moniterexit 两个字节码指令,也就是 synchronized 关键字。因此在 synchronized 块之间的操作都是原子性的。

可见性

可见性指当一个线程修改共享变量的值,其他线程能够立即知道被修改了。Java是利用volatile关键字来提供可见性的。 当变量被volatile修饰时,这个变量被修改后会立刻刷新到主内存,当其它线程需要读取该变量时,会去主内存中读取新值。而普通变量则不能保证这一点。

除了volatile关键字之外,final和synchronized也能实现可见性

synchronized的原理是,在执行完,进入unlock之前,必须将共享变量同步到主内存中。

final修饰的字段,一旦初始化完成,如果没有对象逸出(指对象为初始化完成就可以被别的线程使用),那么对于其他线程都是可见的。

有序性

在Java中,可以使用synchronized或者volatile保证多线程之间操作的有序性。实现原理有些区别:

volatile关键字是使用内存屏障达到禁止指令重排序,以保证有序性。

synchronized的原理是,一个线程lock之后,必须unlock后,其他线程才可以重新lock,使得被synchronized包住的代码块在多线程之间是串行执行的。

JMM的八种内存交互操作?

好的,面试官,内存交互操作有8种,我画张图给你看吧:

  1. lock(锁定),作用于主内存中的变量,把变量标识为线程独占的状态。
  2. read(读取),作用于主内存的变量,把变量的值从主内存传输到线程的工作内存中,以便下一步的load操作使用。
  3. load(加载),作用于工作内存的变量,把read操作主存的变量放入到工作内存的变量副本中。
  4. use(使用),作用于工作内存的变量,把工作内存中的变量传输到执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时将会执行这个操作。
  5. assign(赋值),作用于工作内存的变量,它把一个从执行引擎中接受到的值赋值给工作内存的变量副本中,每当虚拟机遇到一个给变量赋值的字节码指令时将会执行这个操作。
  6. store(存储),作用于工作内存的变量,它把一个从工作内存中一个变量的值传送到主内存中,以便后续的write使用。
  7. write(写入):作用于主内存中的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中。
  8. unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。

我再补充一下JMM对8种内存交互操作制定的规则吧:

  • 不允许read、load、store、write操作之一单独出现,也就是read操作后必须load,store操作后必须write。
  • 不允许线程丢弃他最近的assign操作,即工作内存中的变量数据改变了之后,必须告知主存。
  • 不允许线程将没有assign的数据从工作内存同步到主内存。
  • 一个新的变量必须在主内存中诞生,不允许工作内存直接使用一个未被初始化的变量。就是对变量实施use、store操作之前,必须经过load和assign操作。
  • 一个变量同一时间只能有一个线程对其进行lock操作。多次lock之后,必须执行相同次数unlock才可以解锁。
  • 如果对一个变量进行lock操作,会清空所有工作内存中此变量的值。在执行引擎使用这个变量前,必须重新load或assign操作初始化变量的值。
  • 如果一个变量没有被lock,就不能对其进行unlock操作。也不能unlock一个被其他线程锁住的变量。
  • 一个线程对一个变量进行unlock操作之前,必须先把此变量同步回主内存。

volatile关键字详解?

很多并发编程都使用了volatile关键字,主要的作用包括两点:

  1. 保证线程间变量的可见性。
  2. 禁止CPU进行指令重排序。
1、保证线程间变量的可见性

用volatile修饰一个变量,可以保证变量在多个线程之间的可见性。即当一个线程修改了该变量的值,其他线程可以立即感知到这个修改。

volatile保证可见性的流程大概就是这个一个过程:

volatile一定能保证线程安全吗?

先说结论吧,volatile不能一定能保证线程安全。

怎么证明呢,我们看下面一段代码的运行结果就知道了:

public class VolatileTest extends Thread {
    private static volatile int count = 0;

    public static void main(String[] args) throws Exception {
        Vector<Thread> threads = new Vector<>();
        for (int i = 0; i < 100; i++) {
            VolatileTest thread = new VolatileTest();
            threads.add(thread);
            thread.start();
        }        //等待子线程全部完成
        for (Thread thread : threads) {
            thread.join();
        }
        //输出结果,正确结果应该是1000,实际却是984
        System.out.println(count);
    }

    @Override
    public void run() {
        for (int i = 0; i < 10; i++) {
            try {
                //休眠500毫秒
                Thread.sleep(500);
            } catch (Exception e) {
                e.printStackTrace();
            }
            count++;
        }
    }
}

为什么volatile不能保证线程安全?

很简单呀,可见性不能保证操作的原子性,前面说过了count++不是原子性操作,会当做三步,先读取count的值,然后+1,最后赋值回去count变量。需要保证线程安全的话,需要使用synchronized关键字或者lock锁,给count++这段代码上锁或者直接使用原子类。

2、禁止CPU进行指令重排序

现代CPU在执行一条指令的分为5个阶段:取指令-->指令译码-->执行指令-->内存访问-->数据写回

img

为了提高指令执行的吞吐量,现代CPU都支持多级指令流水线(一般就是五级指令流水线),就是可以在一个时钟周期内同时对5个指令执行不同阶段的操作,本质上,流水线方式不能缩短一条指令执行的时间,但它提高了CPU的吞吐率。

img

因此,在流水线模式下,为了提高CPU的指令执行效率以及CPU的吞吐率,就会对指令重新排序,让指令能够尽量不间断的送给CPU处理,当然重排序的前提是重排序前后执行的结果不变。这里就要讲一下as-if-serial语义了,语义规定:不管怎么重排序,(单线程)程序的执行结果不能被改变

重排序的种类分为三种,分别是:编译器重排序,指令级并行的重排序,内存系统重排序。整个过程如下所示:

img

指令重排序在单线程是没有问题的,不会影响执行结果,而且还提高了性能。但是在多线程的环境下就不能保证一定不会影响执行结果了。

所以在多线程环境下,就需要禁止指令重排序

volatile关键字禁止指令重排序有两层意思:

  • 当程序执行到volatile变量的读操作或者写操作时,在其前面的操作的更改肯定全部已经进行,且结果已经对后面操作可见,在其后面的操作肯定还没有进行。
  • 在进行指令优化时,不能将在对volatile变量访问的语句放在器后面执行,也不能把volatile变量后面的语句放到其掐面执行。

举个例子说明:

private static int a;//非volatile修饰变量
private static int b;//非volatile修饰变量
private static volatile int k;//volatile修饰变量
private void hello() {    
    a = 1;  //语句1
    b = 2;  //语句2
    k = 3;  //语句3
    a = 4;  //语句4
    b = 5;  //语句5
    //以下省略...
}

变量a,b是非volatile修饰的变量,k则使用volatile修饰。所以语句3不能放在语句1、2前,也不能放在语句4、5后。但是语句1、2的顺序是不能保证的,同理,语句4、5也不能保证顺序。

并且,执行到语句3的时候,语句1,2是肯定执行完毕的,而且语句1,2的执行结果对于语句3,4,5是可见的。

volatile禁止指令重排序的原理是什么

volatile禁止指令重排序的原理就是通过内存屏障实现的。Java编译器会在生成指令的时候在适当的位置插入内存屏障指令来禁止特定类型的处理器重排序。

JMM 会针对编译器制定 volatile 重排序规则表,如下:

img

其中“NO”表示禁止指令重排序,为了实现这一语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。需要注意的是:volatile写是在前面和后面分别插入内存屏障,volatile读操作是在后面插入两个内存屏障

volatile写

在每一个volatile写操作之前插入StoreStore屏障,在其之后插入StoreLoad屏障

  • StoreStore屏障:对于这样的语句Store1, StoreStore, Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。
  • StoreLoad 屏障:对于这样的语句Store1, StoreLoad,Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。

volatile读

  • LoadLoad 屏障:对于这样的语句Load1,LoadLoad,Load2。在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
  • LoadStore 屏障:对于这样的语句Load1, LoadStore,Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。

其实这里的内存屏障就是volatile的底层实现lock前缀指令提供内存屏障(也叫内存栅栏),内存屏障会提供3个功能:

  • (1)它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成
  • (2)它会强制将对缓存的修改操作立即写入主存
  • (3)如果是写操作,它会导致其他CPU中对应的缓存行无效

参考

留言区

还能输入500个字符