深入理解Java并发内存模型

Java内存模型是什么

Java 内存模型翻译自Java Memory Model,也称Java多线程内存模型,简称:JMM,它是为了解决Java多线程并发、CPU 高速缓存等内容而引入的一套规则,这里需要注意不要将它与 JVM 内存结构混淆。

为什么要有Java内存模型

在介绍 JMM 时,先看《深入理解 Java 虚拟机》中的一张图,如下:


jmm.png

上图描述的意思是,在每一个线程中,都会有一块内部的工作内存(working memory)。这块工作内存保存了主内存共享数据的拷贝副本。深入理解 JVM 内存分配模型中,我们知道在 JVM 内存结构中有一块线程独享的内存空间虚拟机栈,所以这里我们会自然而然的将线程工作内存理解为虚拟机栈

实际上,这种理解是不准确的!虚拟机栈和线程的工作内存并不是一个概念。虚拟机栈是JVM中描述Java方法执行的模型,而线程工作内存,则是对 CPU 高速缓存的抽象描述

在 Java 线程中并不存在所谓的工作内存(working memory),它只是对 CPU 寄存器和高速缓存的抽象描述

CPU高速缓存、MESI协议

我们知道进程是操作系统分配资源(内存、CPU和磁盘IO)最小单位,而线程是 CPU 调度的最小单位,线程是共享进程中资源,线程中的字节码指令最终都是在 CPU 中执行的,而虚拟机栈是Java方法执行的模型。
CPU在执行的时候,免不了要和各种数据打交道,而 Java 中所有数据都是存放在主内存(RAM)当中的,这一过程可以参考下图:

cpu.png

由于CPU对主内存的读写数据和CPU的执行指令速度慢,也就是上图中箭头部分。CPU 对主内存的访问需要等待较长的时间,这样就体现不出 CPU 超强运算能力的优势了。

因此,为了达到高并发的效果,在 CPU 中添加了高速缓存 (cache)来作为缓冲。

cpu1.png

在CPU执行任务时,CPU 会先将运算所需的数据复制到高速缓存中,CPU设计不是每次读取一个内存地址,而是每次读取相邻的多个内存地址,让运算能够快速进行,当运算完成之后,再将缓存中的结果刷回(flush back)主内存,这样 CPU 就不用等待主内存的读写操作了。

但是每个处理器都有自己独立的高速缓存,同时又共同操作同一块主内存(RAM),当多个处理器同时操作主内存时,可能导致数据不一致,这就是缓存一致性问题。其实也就是多线程并发安全的可见性问题。

缓存一致性问题

现在市面上的计算机通常有两个或者多个 CPU,其中一些 CPU 还有多核。每个 CPU 在某一时刻都能运行一个线程或多个线程(超线程技术,实际上就是逻辑核心数),这就意味着,如果你的 Java 程序是多线程的,那么就有可能存在多个线程在同一时刻被不同的CPU执行的情况,即:并行执行程序。

如下面代码:

 private int x = 0;
 private int y = 0;
 Thread p1 = new Thread() {
        @Override
        public void run() {
            int r1 = x;
            y = 1;
        }
    };


    Thread p2 = new Thread() {
        @Override
        public void run() {
            int r2 = y;
            x = 2;
        }
    };
    p1.start();
    p2.start();

1、定义两个变量分别设计x 和 y ,初始值都为 0。
2、在线程 p1 中,将 x 赋值给局部变量 r1,然后将 y 重新设为 1 。
3、在线程 p2 中,将 y 赋值给局部变量 r2,然后将 x 重新设为 2。

假设我们的设备上有 2 个 CPU,分别为 C1 和 C2,我们将上面这段代码执行在这台设备上,最后打印出的 r1 和 r2 值分别是多少? 答案是不确定的。

1、假设 p1 先在 C1 中执行完毕,并成功刷新回主内存中
  • 假设 p1 先在 C1 中执行完毕,C1缓存此时x = 0, y = 1,并成功刷新回主内存中,主内存此时x = 0, y = 1
  • 然后 p2 在 C2 中执行,从主内存中加载 y = 1 并赋值给 r2,C2缓存此时x = 2, y = 1,并成功刷新回主内存中,主内存此时x = 2, y = 1
1.1、假设 p1 先在 C1 中执行完毕,并未刷新回主内存中
  • 假设 p1 先在 C1 中执行完毕,并未刷新回主内存中,C1缓存此时r1 = 0, x = 0, y = 1,主内存x = 0, y = 0
  • 然后 p2 在 C2 中执行,从主内存中加载 y = 0并赋值给 r2,C2缓存此时r2 =y= 0, x = 2,,并成功刷新回主内存中,主内存x =2, y = 0
2、假设 p2 先在 C1 中执行完毕,并成功刷新回主内存中
  • 假设 p2 先在 C1 中执行完毕,C1缓存此时r2 = y=0, x = 2,并成功刷新回主内存中,主内存此时x = 2, y = 0
  • 然后 p1 在 C2 中执行,从主内存中加载 x= 2 并赋值给 r1,C1缓存此时r1 = x = 2, y = 1,并成功刷新回主内存中x = 2, y = 1
2.2、假设 p2 先在 C1 中执行完毕,并未刷新回主内存中
  • 假设 p2 先在 C1 中执行完毕,C1缓存此时r2 = y=0, x = 2,并未成功刷新回主内存中,主内存此时x = 0, y = 0
  • 然后 p1 在 C2 中执行,从主内存中加载 x= 0 并赋值给 r1,C1缓存此时r1 = x = 0, y = 1,并成功刷新回主内存中x = 0, y = 1
3、x 和 y 的值分别缓存在 C1 和 C2 的缓存中
  • 首先 p1 在 C1 中执行完毕,C1缓存此时r2 = x=0, y = 1,但是并未将结果刷新回主内存中,此时主内存中的 x = 0,y = 0
  • 然后 p2 在 C2 中执行,C2缓存此时 r2 =y= 0, x = 2,并未将结果刷新回主内存中此时主内存中的 x = 0,y = 0
    如下图所示:
    cache.png

可以看出,虽然在 C1 和 C2 的缓存中分别修改了 x 和 y 的值,但是并未将它们刷新回主内存中,这就是缓存一致性问题。

在举一个例子:

public class CacheCoherency {

private static boolean initFlag;


private static class UseCPUCacheThread extends Thread {
    @Override
    public void run() {
        System.out.println("Waiting data...............");
        while (!initFlag) {
        }
        System.out.println("Waiting data success....");
    }
}

private static class UseCPUCacheThread2 extends Thread {
    @Override
    public void run() {
        System.out.println("Prepare data.....");
        initFlag = true;
        System.out.println("Prepare data end.....");
    }
}

public static void main(String[] args) throws InterruptedException {
    UseCPUCacheThread useCPUCacheThread = new UseCPUCacheThread();
    useCPUCacheThread.start();

    Thread.sleep(2000);

    UseCPUCacheThread2 useCPUCacheThread2 = new UseCPUCacheThread2();
    useCPUCacheThread2.start();
}
}

这是最典型的CPU缓存一致性问题造成,如下图:


jmm (1).png

在CPU和主内存之间增加高速缓存,在多线程场景下就可能存在缓存一致性问题

这些高速缓存一般都是独属于CPU内部的,对其他CPU不可见,此时又会出现缓存和主存的数据不一致现象,CPU的解决方案有两种:

  • 总线锁定:当某个CPU处理数据时,通过锁定系统总线或者是内存总线,让其他CPU不具备访问内存的访问权限,从而保证了缓存的一致性。

  • 缓存一致性协议(MESI):多个CPU从主内存读取同一个数据到各自的高速缓存,当其中某个CPU修改了缓存里的数据,会马上同步会主内存中,其他CPU通过总线嗅探技术可以感知到数据的变化从而将自己的缓存数据失效。

  • 缓存数据状态有如下四种(MESI)
    1、M(Modifed):在缓存行中被标记为Modified的值,与主存的值不同,这个值将会在它被其他CPU读取之前写入内存,并设置为Shared。
    2、E(Exclusive):该缓存行对应的主存内容只被该CPU缓存,值和主存一致,被其他CPU读取时置为Shared,被其他CPU写时置为Modified。
    3、S(Share):该值也可能存在其他CPU缓存中,但是它的值和主存一致。
    4、I(Invalid):该缓存行数据无效,需要时需重新从主存载入。

编译器优化和CPU并行执行指令重排序

上面提到在在CPU和主存之间增加缓存,在多线程场景下会存在缓存一致性问题。除了这种情况,还有编译器优化和处理器指令集并行执行重排序。

  • CPU并行执行指令重排序: 使CPU内部的运算单元(加法、减法)能够尽量的被充分利用,处理器会对输入的指令进行乱序并行执行处理
  • 编译器优化重排序:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序,在不改变程序语义的前提下,尽可能减少寄存器的读取、存储次数,充分复用寄存器的存储值

特别注意:编译器和处理器可能会对操作做重排序,但是要·遵守数据依赖关系,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。

可想而知,如果任由处理器优化和编译器对指令重排的话,就可能导致各种各样的问题。详情请看内存模型之重排序

并发编程的问题

CPU缓存一致性问题 、编译器优化重排序和 指令级并行重排序 这也是并发编程常见的:可见性、有序性和原子性等并发数据安全问题。

  • 原子性是指在一个操作或多个操作CPU不能在中途暂停然后再调度,既:CPU不能中断操作,要不执行完成,要么就不执行。
  • 可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
  • 有序性即程序执行的顺序按照代码的先后顺序执行。

什么是内存模型

前面提到的,缓存一致性问题、处理器器优化的指令重排问题是硬件的不断升级导致的。那么,有没有什么机制可以很好的解决上面的这些问题呢?

为了保证并发编程中可以满足原子性、可见性及有序性。有一个重要的概念,那就是——内存模型。

内存模型是一套共享内存系统中多线程读写操作行为的规范,这套规范屏蔽了底层各种硬件和操作系统的内存访问差异,解决了 CPU 多级缓存、CPU 优化、指令重排等导致的内存访问问题,从而保证程序(尤其是多线程程序)在各种平台下对内存的访问效果一致。

内存模型解决并发问题主要采用两种方式:限制处理器优化和使用内存屏障。

什么是Java内存模型

前面介绍过了计算机内存模型,这是解决多线程场景下并发问题的一个重要规范。那么具体的实现是如何的呢,不同的编程语言,在实现上可能有所不同。

在 Java 内存模型中,我们统一用工作内存(working memory)来当作 CPU 中寄存器或高速缓存的抽象。线程之间的共享变量存储在主内存(main memory)中,每个线程都有一个私有工作内存(类比 CPU 中的寄存器或者高速缓存),本地工作内存中存储了该线程读/写共享变量的副本。

Java 内存模型目的是解决由于多线程通过共享内存进行通信时,存在的本地内存数据不一致、编译器会对代码指令重排序、处理器会对代码乱序执行等带来的问题。

在这套规范中,有一个非常重要的规则——happens-before。

happens-before 先行发生原则

happens-before 用于描述两个操作的可见性、原子性和有序性,通过保证可见性的机制可以让应用程序在多线程程序中免于数据竞争干扰。它的定义如下:

如果一个操作 A happens-before 另一个操作 B,那么操作 A 的执行结果将对操作 B 可见。

上述定义我们也可以反过来理解:如果操作 A 的结果需要对另外一个操作 B 可见,那么操作 A 必须 happens-before 操作 B。

用以下代码来举例:

private int value;

public int getValue() {
    return value;
}

public void setValue(int value) {
    this.value = value;
}

假设 setValue 就是操作 A,getValue 就是操作 B。如果我们先后在两个线程中调用 A 和 B,那最后在 B 操作中返回的 value 值是多少呢?有以下两种情况:

如果 A happens-before B 不成立

也就是说当线程调用操作 B(getValue)时,即使操作 A(setValue)已经在其他线程中被调用过,并且 value 也被成功设置为 1,但这个修改对于操作 B(getValue)仍然是不可见的。根据之前我们介绍的 CPU 缓存,value 值有可能返回 0,也有可能返回 1。

如果 A happens-before B 成立

根据 happens-before 的定义,先行发生动作的结果,对后续发生动作是可见的。也就是说如果我们先在一个线程中调用了操作 A(setValue)方法,那么这个修改后的结果对后续的操作 B(getValue)始终可见。因此如果先调用 setValue 将 value 赋值为 1 后,后续在其他线程中调用 getValue 的值一定是 1。

那在 Java 中的两个操作如何就算符合 happens-before 规则了呢? JMM 中定义了以下几种情况是自动符合 happens-before 规则的:

程序次序规则

单线程内部,如果一段代码的字节码顺序也隐式符合 happens-before 原则,那么逻辑顺序靠前的字节码执行结果一定是对后续逻辑字节码可见,只是后续逻辑中不一定用到而已。比如以下代码:

int a = 2;          A
int b = 10;         B
int c = a + 1;      C

当代码执行到 C 处时,a=2、b = 10 这个结果已经是公之于众的,至于用没用到a和b的结果则不一定。比如上面代码就没有用到 b= 10 的结果,说明c对b的结果没有依赖,但是对a的结果有依赖,前面说过 JMM 实际上是遵守这样的一条原则:只要不改变程序的执行结果(指的是单线程程序和正确同步的多线程程序),编译器和处理器怎么优化都行,所以这段代码就有可能发生指令重排:ABC ===>ACB。

但是如果将代码改为如下则不会发生指令重排优化:

int a = 2;          A
int b = a;         B
int c = b + 1;      C
锁定规则

无论是在单线程环境还是多线程环境,一个锁如果处于被锁定状态,那么必须先执行 unlock 操作后才能进行 lock 操作。
举个例子:

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

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

volatile 变量规则

volatile 保证了线程可见性。通俗讲就是如果一个线程先写了一个 volatile 变量,然后另外一个线程去读这个变量,那么这个写操作一定是 happens-before 读操作的。

public class CacheCoherency {
private volatile static boolean initFlag;
private static class UseCPUCacheThread extends Thread {
    @Override
    public void run() {
        System.out.println("Waiting data...............");
        while (!initFlag) {
        }
        System.out.println("Waiting data success....");
    }
}

private static class UseCPUCacheThread2 extends Thread {
    @Override
    public void run() {
        System.out.println("Prepare data.....");
        initFlag = true;
        System.out.println("Prepare data end.....");
    }
}

public static void main(String[] args) throws InterruptedException {
    UseCPUCacheThread useCPUCacheThread = new UseCPUCacheThread();
    useCPUCacheThread.start();

    Thread.sleep(2000);

    UseCPUCacheThread2 useCPUCacheThread2 = new UseCPUCacheThread2();
    useCPUCacheThread2.start();
}
}

这个例子就是典型的Volatile案例,也就是缓存一致性问题。

线程启动规则

Thread 对象的 start() 方法happens-before于此线程的每一个动作。假定线程 A 在执行过程中,通过执行 ThreadB.start() 来启动线程 B,那么线程 A 对共享变量的修改在线程 B 开始执行后确保对线程 B 可见。

线程中断规则

对线程 interrupt() 方法的调用happens-before于被中断线程的代码检测,直到中断事件的发生。如:Thread对象的 interrupted和isInterrupted 方法检测到是否有中断发生。

线程终结规则

线程中所有的操作都happens-before在对此线程的终止检测之前,我们可以通过 Thread.join() 方法结束、Thread.isAlive() 的返回值等方法检测线程是否终止执行。假定线程 A 在执行的过程中,通过调用 ThreadB.join() 等待线程 B 终止,那么线程 B 在终止之前对共享变量的修改在线程 A 等待返回后可见。

对象终结规则

一个对象的初始化完成 happens-before在它的 finalize() 方法开始前。

传递性规则

如果操作 A happens-before 操作 B,而操作 B happens-before 操作 C,则操作 A 一定 happens-before 操作 C。

需要注意:只要我们的代码不符合上述8大原则说明代码是线程不安全的, 当然其实我们开发人员平时处理的最多的就是 volatile 变量规则锁定规则

举个例子:

private int value = 0;

// 线程 B 调用
public int getValue() {
    return value;
}
// 线程 A 调用
public void setValue(int value) {
    this.value = value;
}

假设存在线程 A 和 B,线程 A 先(时间上的先后)调用了 setValue(1),然后线程 B 调用了同一个对象的 getValue() ,那么线程 B 收到的返回值是什么?

我们根据上述 Happens-before 的 8 大规则依次分析一下:

1、由于两个方法分别由线程 A 和 B 调用,不在同一个线程中,所以程序次序规则在这里不适用;
2、由于没有锁(synchronized,Lock),自然就不会发生 lock 和 unlock 操作,所以锁定规则在这里不适用;
3、由于没有volatile ,所以volatile 变量规则在这里不适用;
4、由于线程 A 执行过程中并没有启动线程B,所以线程启动规则在这里不适用;
5、由于线程 A 和 B 并没有进行终止检测,所以线程终结规则在这里不适用;
6、对象终结规则线程中断规则传递性规则没有任何关系。

因此我们可以判定,尽管线程 A 在操作时间上来看是先于线程 B 的,但是并不能说 A Happens-before B,也就是 A 线程操作的结果 B 不一定能看到。所以,这段代码是线程不安全的。

想要修复这个问题也很简单?既然不满足 Happens-before 原则,那我修改下让它满足不就行了。比如说把 Getter/Setter 方法都用 synchronized 修饰,这样就可以套用管程锁定规则;再比如把 value 定义为 volatile 变量,这样就可以套用 volatile 变量规则等。

这说明了一个操作 “时间上的先发生” 不代表这个操作会是 “先行发生(Happens-before)”。

再来看一个例子:

 int i = 1;
 int j = 2;

假设这段代码中的两条赋值语句在同一个线程之中,那么根据程序次序规则int i = 1的操作先行发生(Happens-before)于 int j = 2,但是,还记得 Happens-before 的第 2 条定义吗?还记得上文说过 JMM 实际上是遵守这样的一条原则:只要不改变程序的执行结果(指的是单线程程序和正确同步的多线程程序),编译器和处理器怎么优化都行。

所以,int j=2 这句代码完全可能优先被处理器执行,因为这并不影响程序的最终运行结果。

结论:Happens-before 原则与时间先后顺序之间基本没有因果关系,所以我们在衡量并发安全问题的时候,尽量不要受时间顺序的干扰,一切必须以 Happens-before 原则为准。

Java 内存模型应用

上面介绍的 happens-before 原则非常重要,它是判断数据是否存在竞争线程是否安全的主要依据,根据这个原则,我们能够解决在并发环境下操作之间是否可能存在冲突的所有问题。在此基础上,我们可以通过 Java 提供的一系列关键字,将我们自己实现的多线程操作“happens-before 化”。

"happens-before 化”就是将本来不符合 happens-before 原则的某些操作,通过某种手段使它们符合 happens-before 原则。

使用 volatile 修饰 value
private volatile int value;

public int getValue() {
    return value;
}

public void setValue(int value) {
    this.value = value;
}
使用synchronized关键字修饰操作
private int value;

public int getValue() {
    synchronized (this) {
        return value;
    }
}

public void setValue(int value) {
    synchronized (this) {
        this.value = value;
    }
}

通过以上两种方式,都可以使 setValue 和 getValue 符合 happens-before 原则——当在某一线程中调用 setValue 后,再在其他线程中调用 getValue 获取的值一定是正确设置的值。

总结

JMM 的来源:主要是因为 CPU 缓存和指令重排等优化会造成多线程程序结果不可控。
JMM是什么:本质上它就是一套规范,在这套规范中有一条最重要的 happens-before 原则。

你可能感兴趣的:(深入理解Java并发内存模型)