概述

Java内存模型(Java Memory Model,简称 JMM)定义了线程如何通过内存进行交互,换句话说就是,Java内存模型规范了不同线程如何以及何时可以看到其他线程写入共享变量的值,以及在必要时如何同步对共享变量的访问。

注意跟Java内存管理的区别,这个后面会再单独开一篇去说。

JMM 要解决的问题

有了多线程后,相比单核单线程的情况就发生了不小的变化。从逻辑上划分可分为线程工作内存主内存,如图:

jmm抽象

线程之间变量的可见性问题

JVM 内有一个主内存,JMM规范规定了所有的的变量都分配在主内存中;每个线程都有自己的内存空间,线程自己用到的变量会拷贝一份副本到自身的工作内存中。

线程之间的变量相互之间不可见,如果想要通信(更新数据),必须要将更新的变量的新值写到主内存上。

那么问题来了,当线程1修改了变量V的时候,线程2如果没有感知到变化,那么看到的数据仍然是自己拷贝的那一份,这就导致了两个线程看到的数据不一致。

指令重排序问题

Java 中重排序通常是编译器或运行时环境为了优化程序性能而采取的对指令进行重新排序执行的一种手段。

重排序分为两类:编译期重排序和运行期重排序,分别对应编译时和运行时环境。

JVM 屏蔽了CPU异构导致的各种问题,比如重排序。

指令重排序同样也会导致线程之间的问题,如果发生了指令重排序,可能会导致线程之间的的逻辑出错,比如:

1
2
3
// 详细示例参见下文
int[] bigArray = new int[1000*1000*1000];
boolean flag = true;

在执行的时候,bigArray 可能会因为分配时间长,导致 flag=false;先执行。如果这两个变量在两个线程中共享,如果要根据flag进行逻辑判断,那么重排序后和预期就不一样了。

JMM如何解决问题

规范操作

JMM定义了8种基本操作来防止出现上面的问题(了解就好):

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

操作同步的规则

  1. 如果要把一个变量从主内存中复制到工作内存,就需要按顺序的执行 read 和 load 操作,如果把变量从工作内存中同步回主内存中,就要按顺序的执行 store 和 write 操作。
    但 Java 内存模型只要求上述操作必须按顺序执行,而没有保证必须是连续执行。
  2. 不允许 read 和 load、store 和 write 操作之一单独出现。
  3. 不允许一个线程丢弃它的最近 assign 的操作,即变量在工作内存中改变了之后必须同步到主内存中。
  4. 不允许一个线程无原因的(没有发生过任何 assign 操作)把数据从工作内存同步回主内存中。
  5. 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load 或 assign )的变量。
    即对一个变量实施 use 和 store 操作之前,必须先执行过了 load 或 assign 操作。
  6. 一个变量在同一个时刻只允许一条线程对其进行 lock 操作,但 lock 操作可以被同一条线程重复执行多次,多次执行 lock 后,只有执行相同次数的 unlock 操作,变量才会被解锁。所以 lock 和 unlock 必须成对出现。
  7. 如果对一个变量执行 lock 操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行 load 或 assign 操作初始化变量的值。
  8. 如果一个变量事先没有被 lock 操作锁定,则不允许对它执行 unlock 操作;也不允许去 unlock 一个被其他线程锁定的变量。
  9. 对一个变量执行 unlock 操作之前,必须先把此变量同步到主内存中(执行 store 和 write 操作)。

规范运行规则

  • 原子性(Atomicity):

    原子性,即一个操作或者多个操作要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。

    即使在多个线程一起执行的时候,一个操作一旦开始,就不会被其他线程所干扰。

  • 可见性(Visibility)

    可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。

  • 有序性(Ordering)

    线程内,从某个线程的角度看方法的执行,指令会按照一种叫“串行”(as-if-serial)的方式执行,此种方式已经应用于顺序编程语言。

线程间,这个线程“观察”到其他线程并发地执行非同步的代码时,由于指令重排序优化,任何代码都有可能交叉执行。
唯一起作用的约束是:对于同步方法,同步块(synchronized 关键字修饰)以及 volatile 字段的操作仍维持相对有序。

JMM 具体实现

关于JMM呢,个人理解就是一个规范,既然是规范那么就是一个规定,一套逻辑,具体实现要看每个JVM自己的实现方式,所以,这里我把 内存屏障归结于是JMM的具体的实现方式。广泛意义上来说也可以认为是JMM规范。

  • 内存屏障

    内存屏障(Memory Barrier,或有时叫做内存栅栏,Memory Fence)是一种CPU指令,用于控制特定条件下的重排序和内存可见性问题。

总结

总得来说,JMM就是一系列规范,来规范怎么操作内存才能保证变量的可见性,一致性,最终还是保证程序在多线程运行的情况下运行的结果是正确无误的。至于JMM到底要怎么简要描述,这个我看来没有明确的说法,了解是个什么东西就好。如果真的要研究,那么必然要研究 Java的规范,这个对于大部分人来说也不是必要的,重要的是了解其中的一些思想。

附录

指令重排序java测试代码

代码出处

原文代码运行的时候确实会出现打印的情况,但我认为这不足矣说明一定是产生了指令重排,如图所示,因为 变量 flag 和 a 是全局变量,在2个线程之间共享,并且是没有加锁的,很有可能是因为线程之间不可见导致,也就是主缓存更新了,但是线程内的副本没有及时更新导致。

另外线程之间启动运行的时候不一定按照启动的顺序执行,也可能是后面的先执行,这个跟CPU调度有关。

所以我就改了一下,这个很难测试出是否在运行的时候发生了重排序。如果真的发生重排序,那么 flag = true; 是可能被先执行的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public class SimpleHappenBefore {
/** 这是一个验证结果的变量 */
private static int a = 0;
/** 这是一个标志位 */
private static boolean flag = false;

public static void main(String[] args) throws InterruptedException {
ThreadA threadA = new ThreadA();
ThreadB threadB = new ThreadB();
threadA.start();
threadB.start();
threadA.join();
threadB.join();
}

static class ThreadA extends Thread {
public void run() {
a = 1;
flag = true;
}
}

static class ThreadB extends Thread {
public void run() {
if (a == 0) {
System.out.println("ha,a==0");
}
}
}
}

参考