当前位置 : 主页 > 编程语言 > java >

Java内存模型(JMM)详解

来源:互联网 收集:自由互联 发布时间:2023-09-06
面试官:讲讲什么是 JMM JMM(Java 内存模型)详解 1 什么是JMM? JMM就是Java内存模型(Java Memory Model) JMM是一个抽象的概念,他描述的是和多线程相关的一组规范,需要各个JVM的实现来遵

面试官:讲讲什么是 JMM

JMM(Java 内存模型)详解

图片

1 什么是JMM?

JMM就是Java内存模型(Java Memory Model)

JMM是一个抽象的概念,他描述的是和多线程相关的一组规范,需要各个JVM的实现来遵守JMM规范,以便于开发者可以利用这些规范,更方便的开发多线程程序。这样一来,即使同一个程序在不同的虚拟机上运行,得到的程序结果也是一致的。

如果没有JMM内存模型来规范,那么经过了不同JVM的“翻译”之后,导致在不同的虚拟机上的运行的结果不一样,那是很大的问题。

因此,JMM与处理器、缓存、并发、编译器有关。他解决了CPU多级缓存、处理器优化、指令重排序等导致的结果不可预期的问题。

Java作为高级语言,屏蔽了CPU多级缓存这些底层细节,用JMM定义了一套读写内存数据的规范,虽然我们不需要关心一级缓存和二级缓存的问题,但是JMM抽象了主内存与工作内存的概念。

1.1 JMM 抽象:主内存与工作内存

这里说的工作内存并不是真的给每一块线程分配的内存,而是JMM的一个抽象,是对于寄存器、一级缓存、二级缓存等的抽象。

图片

每个线程只能够直接接触到工作内存,无法直接操作主内存,而工作内存中所保存的正是主内存的共享变量的副本,主内存和工作内存之间的通信是由 JMM 控制的。

主内存和工作内存的关系

JMM 有以下规定:

  • 所有的变量都存储在主内存中,同时每个线程拥有自己独立的工作内存,而工作内存中的变量的内容是主内存中该变量的拷贝;
  • 线程不能直接读 / 写主内存中的变量,但可以操作自己工作内存中的变量,然后再同步到主内存中,这样,其他线程就可以看到本次修改;
  • 主内存是由多个线程所共享的,但线程间不共享各自的工作内存,如果线程间需要通信,则必须借助主内存中转来完成。

JMM 中的主内存:

  • 存储 Java 实例对象,包括成员变量、类信息、常量、静态变量等,但是不包括局部变量和方法参数。
  • 主内存属于数据共享区域,多线程并发操作时会引发线程安全问题。

JMM 中工作内存:

  • 存储当前方法的所有本地变量信息,每个线程只能访问自己的工作内存,每个线程工作内存的本地变量对其他线程不可见
  • 字节码行号指示器、Native 方法等信息
  • 属于线程私有数据区域,不存在线程安全问题

主内存与工作内存的数据存储类型以及操作方式归纳:

  • 对于实例对象中的成员方法,方法里的基本数据类型的局部变量将直接存储在工作内存的栈帧结构中。方法里引用类型的局部变量的引用在工作内存中的栈帧结构中,对象实例存储在**主内存(堆)**中。
  • 对于实例对象的成员变量,不管它是基本数据类型或者包装类型(Integer、Double等)还是引用类型,都会被存储到堆区。
  • 对于实例对象中的静态变量以及类信息都会被存储在主内存中。
  • 需要注意的是,在主内存中的实例对象可以被多个线程共享,如果两个线程调用了同一个对象的同一个方法,两个线程会将数据拷贝到自己的工作内存中,执行完成后刷新回主内存。

图片

温馨提醒一下,这里有些人会把 Java 内存模型误解为 Java 内存结构,然后答到堆,栈,GC 垃圾回收,最后和面试官想问的问题相差甚远。实际上一般问到 Java 内存模型都是想问多线程,Java 并发相关的问题。

1.2 为什么需要 JMM,它试图解决什么问题?

我们先来看一段代码:

public class JMMDemo {
    int value = 0;

    void add() {
        value++;
    }

    public static void main(String[] args) throws Exception {
        final int count = 100000;
        final JMMDemo demo = new JMMDemo();
        Thread t1 = new Thread(() -> IntStream.range(0, count).forEach((i) -> demo.add()));
        Thread t2 = new Thread(() -> IntStream.range(0, count).forEach((i) -> demo.add()));

        t1.start();
        t2.start();
        t1.join();
        t2.join();

        System.out.println(demo.value);
    }
}

上面的代码没有任何同步块,每个线程单独运行后,都会对 value 加 10 万,但执行之后,大概率不会输出 20 万。

我们使用 javap 命令看一下字节码:

  void add();
    descriptor: ()V
    flags:
    Code:
      stack=3, locals=1, args_size=1
         0: aload_0
         1: dup
         2: getfield      #2                  // Field value:I
         5: iconst_1
         6: iadd
         7: putfield      #2                  // Field value:I
        10: return
      LineNumberTable:
        line 9: 0
        line 10: 10

着重看一下 add 方法,可以看到一个简单的 i++ 操作,竟然有这么多的字节码,而它们都是按照顺序执行的。当它自己执行的时候不会有什么问题,但是如果放在多线程环境中,执行顺序就变得不可预料了。

图片

上图展示了这个乱序的过程。

  • 线程 A 和线程 B 并发执行相同的 代码块 add,执行的顺序如图中的标号,它们在线程中是有序的(1、2、5 或者 3、4、6),但整体顺序是不可预测的。
  • 线程 A 和 B 各自执行了一次加 1 操作,但在这种场景中,线程 B 的 putfield 指令直接覆盖了线程 A 的值,最终 value 的结果是 101。

上面的示例仅仅是字节码层面上的,更加复杂的是,CPU 和内存之间同样存在一致性问题。

很多人认为 CPU 是一个计算组件,并没有数据一致性的问题。但事实上,由于内存的发展速度跟不上 CPU 的更新,在 CPU 和内存之间,存在着多层的高速缓存。

原因就是由于多核所引起的,这些高速缓存,往往会有多层。如果一个线程的时间片跨越了多个 CPU,那么同样存在同步的问题。

另外,在执行过程中,CPU 可能也会对输入的代码进行乱序执行优化,Java 虚拟机的即时编译器也有类似的指令重排序优化。整个函数的执行步骤就分的更加细致,看起来非常的碎片化(比字节码指令要细很多)。

图片

不管是字节码的原因,还是硬件的原因,在粗粒度上简化来看,比较浅显且明显的因素,那就是线程 add 方法的操作并不是原子性的。

为了解决这个问题,我们可以在 add 方法上添加 synchronized 关键字,它不仅保证了内存上的同步,而且还保证了 CPU 的同步。这个时候,各个线程只能排队进入 add 方法,我们也能够得到期望的结果 102。

synchronized void add() {
    value++;
}

图片

讲到这里,Java 的内存模型就呼之欲出了。

JMM 是一个抽象的概念,它描述了一系列的规则或者规范,用来解决多线程的共享变量问题,比如 volatilesynchronized等关键字就是围绕 JMM 的语法。此处的变量与 Java 编程中所说的变量有所区别,它包括了实例字段、静态字段和构成数组对象的元素,但是不包括局部变量与方法参数,因为后者是线程私有的,不会被共享,自然就不会存在竞争问题。

JVM 试图定义一种统一的内存模型,能将各种底层硬件,以及操作系统的内存访问差异进行封装,使 Java 程序在不同硬件及操作系统上都能达到相同的并发效果。

2 八种内存交互操作

图片

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

如果要把一个变量从主内存拷贝到工作内存,那就要按顺序执行 readload 操作,如果要把变量从工作内存同步回主内存,就要按顺序执行 storewrite 操作。

注意,Java 内存模型只要求上述两个操作必须按顺序执行,但不要求是连续执行。也就是说 readload之间、storewrite之间是可插入其他指令的。如对主内存中的变量 a、b 进行访问时,一种可能出现的顺序是 read aread bload bload a

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

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

3 JMM 三大特征

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

3.1 原子性

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

我们来看下面的代码:

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

上面这几句代码能保证原子性吗?

  • 第一句是基本类型赋值操作,必定是原子性操作。
  • 第二句先读取 i 的值,再赋值到 j,两步操作,不能保证原子性。
  • 第三和第四句其实是等效的,先读取 i 的值,再 +1,最后赋值到 i,三步操作了,不能保证原子性。

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

3.2 可见性

可见性指当一个线程修改共享变量的值,其他线程能够立即知道被修改了。

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

除了 volatile 关键字之外,finalsynchronized也能实现可见性。

  • synchronized 的原理是,在执行完,进入 unlock 之前,必须将共享变量同步到主内存中。
  • final 修饰的字段,一旦初始化完成,如果没有对象逸出(指对象未初始化完成就可以被别的线程使用),那么对于其他线程都是可见的。

3.3 有序性

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

  • volatile关键字是使用内存屏障达到禁止指令重排序,以保证有序性。
  • synchronized的原理是,一个线程 lock 之后,必须 unlock后,其他线程才可以重新lock,使得被 synchronized包住的代码块在多线程之间是串行执行的。

4 volatile 关键字

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

  • 保证线程间变量的可见性
  • 禁止 CPU 进行指令重排序

4.1 可见性

volatile修饰的变量,当一个线程改变了该变量的值,其他线程是立即可见的。普通变量则需要重新读取才能获得最新值。

public class VolatileSafe {
    private volatile boolean shutDown;
    public void close() {
        shutDown=true;
    }

    public void doWork(){
        while (!shutDown){
            System.out.println("safe...");
        }
    }
}

在这个例子中,对 boolean 变量的修改是原子性的,因此对这个变量的修改对其他线程立即可见,保证了线程安全。

对 volatile 变量的修改为什么可以做到立即可见?

  • 当写一个 volatile 变量时,JMM 会把对该线程对应的工作内存中的共享变量值刷新到主内存中
  • 当读取一个 volatile 变量时,JMM 会把该线程对应的工作内存置为无效,使得线程只能从主内存中重新读取共享变量

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

图片

关于 volatile变量的可见性,经常会被误解,经常有人会误以为下面的描述是正确的:

  • volatile变量对所有线程是立即可见的,对 volatile变量所有的写操作都能立刻反映到其他线程之中。
  • 换句话说,volatile变量在各个线程中是一致的,所以基于 volatile变量的运算在并发下是线程安全的。

4.2 禁止指令重排序

假设我们写了一个 Java 程序,包含一系列的语句,我们会默认期望这些语句的实际运行顺序和写的代码顺序一致。

但实际上,编译器、JVM 或者 CPU 都有可能出于优化等目的,对于实际指令执行的顺序进行调整,这就是重排序。

重排序的好处:提高处理速度。

重排序的 3 种情况:

  • 编译器重排序
  • 指令级并行的重排序
  • 内存系统重排序

整个过程如下所示:

图片

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

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

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 是可见的。

下面我们分析一个带有隐患的常见的单例写法:

public class Singleton {
    private static Singleton instance;

    private Singleton() {
    }

    public static Singleton getInstance() {
        //第一次检测
        if (instance == null) {
            //同步
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

一个对象的初始化不是原子性的操作,可以分为 3 步:

  1. 分配内存空间
  2. 初始化对象
  3. 设置 instance 指向分配对象的内存地址

上述流程可能经过重排序,变为如下顺序:

  1. 分配内存空间
  2. 设置 instance 指向分配对象的内存地址,但是对象还没初始化,但此时 instance != null
  3. 初始化对象

我们假设,线程 A 先执行 getInstance() 方法:

  • 当执行完指令 2 时恰好发生了线程切换,切换到了线程 B 上;
  • 如果此时线程 B 也执行 getInstance() 方法,
  • 那么线程 B 在执行第一个判断时会发现 instance != null
  • 所以直接返回 instance,
  • 而此时的 instance 是没有初始化过的,
  • 如果我们这个时候访问 instance 的成员变量就可能触发空指针异常。

解决方法是使用 volatile 修饰 instance 变量,禁止指令重排序即可。

private volatile static Singleton instance;

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

Java 内存模型是通过内存屏障(memory barrier)来禁止重排序的。

对于即时编译器来说,它会针对前面提到的每一个 happens-before关系, 向正在编译的目标方法中插入相应的读读、读写、写读以及写写内存屏障。

这些内存屏障会限制即时编译器的重排序操作。

以 volatile 字段访问为例:

  • 所插入的内存屏障,将禁止 volatile 字段写操作之前的「内存访问」被重排序至其之后;
  • 也将不允许 volatile 字段读操作之后的「内存访问」被重排序至其之前。

内存屏障

内存屏障可分为读屏障写屏障,用于控制可见性。

常见的内存屏障包括:

  • Load-Load Barriers
  • Load-Store Barriers
  • Store-Store Barriers
  • Store-Load Barriers
Load-Load Barriers

保证 load1 数据的装载优先于 load2 以及所有后续装载指令的装载。对于 Load Barrier 来说,在指令前插入 Load Barrier,可以让高速缓存中的数据失效,强制重新从主内存加载数据。

load1
LoadLoad
load2
Load-Store Barriers

在 store2 及后续写入操作被刷新到内存前,保证 load1 要读取的数据被读取完毕。

load1
LoadStore
store2
Store-Store Barriers

在 store2 及后续写入操作执行前,保证 store1 的写入操作对其它处理器可见。对于 Store Barrier 来说,在指令后插入 Store Barrier,能让写入缓存中的最新数据更新写入主内存,让其他线程可见。

store1
StoreStore
store2
Store-Load Barriers

在 load2 及后续所有读取操作执行前,保证 store1 的写入对所有处理器可见。这条内存屏障指令是一个全能型的屏障,它同时具有其他 3 条屏障的效果,而且它的开销也是四种屏障中最大的一个。


4.3 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);//984
    }

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

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

**

很简单呀,可见性不能保证操作的原子性,前面说过了 count++ 不是原子性操作,会当做三步:

  1. 先读取 count 的值,
  2. 然后 +1,
  3. 最后赋值回去 count 变量。

需要保证线程安全的话,需要使用 synchronized关键字或者 lock 锁,给 count++ 这段代码上锁:

private static synchronized void add() {
    count++;
}

5 Happen-before

Happen-Before关系是用来解决可见性问题的:如果一个操作 happens-before 于另一个操作,那么我们说第一个操作对于第二个操作是可见的,也就是第二个操作在执行时就一定能保证看见第一个操作执行的结果。

下面我们来看一下 happens-before 关系包含哪些具体的规则 。

8 条 Happens-before 规则

(1)程序次序规则(Program Order Rule):在一个线程内,按照控制流顺序,书写在前面的操作先行发生(Happens-before)于书写在后面的操作。注意,这里说的是控制流顺序而不是程序代码顺序,因为要考虑分支、循环等结构。

(2)管程锁定规则(Monitor Lock Rule):一个 unlock 操作先行发生于后面对同一个锁的 lock 操作。这里必须强调的是 「同一个锁」,而 「后面」是指时间上的先后。

举个例子:

synchronized (this) { // 此处自动加锁
  if (x < 1) {
        x = 1;
    }      
} // 此处自动解锁

根据管程锁定规则,假设 x 的初始值是 10,线程 A 执行完代码块后 x 的值会变成 1,执行完自动释放锁,线程 B 进入代码块时,能够看到线程 A 对 x 的写操作,也就是线程 B 能够看到 x = 1。

(3)volatile 变量规则(Volatile Variable Rule):对一个 volatile 变量的写操作先行发生于后面对这个变量的读操作,这里的「后面」同样是指时间上的先后。

这就代表了如果变量被 volatile 修饰,那么每次修改之后,其他线程在读取这个变量的时候一定能读取到该变量最新的值。

我们之前介绍过 volatile 关键字,知道它能保证可见性,而这正是由本条规则所规定的。

(4)线程启动规则(Thread Start Rule):Thread 对象的 start() 方法先行发生于此线程的每一个动作。

(5)线程终止规则(Thread Termination Rule):线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过 Thread 对象的 join() 方法是否结束、Thread 对象的 isAlive() 的返回值等手段检测线程是否已经终止执行。

(6)线程中断规则(Thread Interruption Rule):对线程interrupt() 方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过 Thread 对象的 interrupt() 方法检测到是否有中断发生。

(7)对象终结规则(Finalizer Rule):一个对象的初始化完成(构造函数执行结束)先行发生于它的 finalize() 方法的开始。

(8)传递性(Transitivity):如果操作 A 先行发生于操作 B,操作 B 先行发生于操作 C,那就可以得出操作 A 先行发生于操作 C 的结论。

这个很好理解,符合我们的逻辑思维。比如下面的例子:

int a = 1;     // A
int b = 2;      // B
int c = a + b;  // C
上一篇:Java中使用ProcessBuilder执行命令
下一篇:没有了
网友评论