MENU

深入理解Java内存模型(JMM)

May 8, 2019 • Read: 707 • Java

JMM(Java Memory Model),Java内存模型,它是一种Java虚拟机需要遵守的规范,定义了线程间如何在内存中正确地交互。JDK5以后的JMM规范在JSR-133中详细列出。

1. 内存模型

1.1 为什么需要内存模型

多线程编程的困难在于很难对程序进行调式,如果控制不好,就会产生意料之外的结果。对于传统的单核CPU来说,由于是并发执行,即同一时刻只有一个线程在执行,所以一般不会出现数据的访问冲突。这也不是绝对的,单核多线程场景下,如果允许抢占式调度,仍存在线程安全问题。当前的处理器架构大多是多核+多级缓存+主存的模式,这样在多线程场景下就存在数据竞争从而造成缓存不一致的问题。另外CPU可能会对程序进行优化,进行指令重排序,只要重排后程序的语义没有发生变化,指令重排就是有可能发生的(编译器和JVM也存在指令重排),但这有时会让多线程执行的结果出乎意料。

现代处理器架构

1.2 什么是内存模型

对处理器来说,内存模型定义了充分必要条件,以知道其他处理器对内存的写入对当前处理器可见,而当前处理器的写入对其他处理器可见。一些处理器使用强内存模型,即所有处理器在任何给定的内存位置上始终能看到完全相同的值,但这也不是绝对的,某些时候也需要使用特殊指令(称为内存屏障)来完成。其他处理器使用弱内存模型,需要内存屏障来刷新或使本地处理器缓存失效,以便查看其他处理器的写操作或使此处理器的写操作对其他处理器可见。这些内存屏障通常在lock和unlock时执行;对于使用高级语言的程序员来说,它们是不可见的。处理器的设计趋势是鼓励使用弱内存模型,因为它们的规范具有更强的可伸缩性。

1.3 其他语言有内存模型吗

大多数其他编程语言(如C和C ++)的设计并未直接支持多线程。 这些语言针对编译器和体系结构中发生的各种重排序提供的保护很大程度上取决于所使用的线程库(例如pthread),所使用的编译器以及运行代码的平台所提供的保证。

2.Java内存模型

2.1 简介

Java内存模型是建立在内存模型之上的,它回答了当读取一个确定的字段时什么样的值是可见的。它将一个Java程序分解成若干动作(actions)并且为这些动作分配一个顺序。如果分配的这些顺序中能在对一个字段的写操作(write actions)和读操作(read actions)间形成一个叫happens-before的关系,那么Java内存模型保证了读操作将返回一个正确的值。

JMM规定所有实例域、静态域和数组元素存储在JVM内存模型的的堆中,堆内存在线程间是共享的。局部变量和异常处理器参数不会共享,他们不存在内存可见性问题。每个线程创建时JVM都会为其创建一个工作内存(栈空间),工作内存是每个线程的私有数据区域,线程对变量的操作必须在工作内存中进行,首先要将变量从主内存拷贝到自己的工作内存空间,然后对变量进行操作,操作完成后再将变量写回主内存`,不能直接操作主内存中的变量,各个线程中的工作内存中存储着主内存中的变量副本拷贝,因此线程间的通信必须通过主内存来完成。

2.2 代码优化问题

上面一段代码,模拟了两个线程。期望可能是thread1执行一次count++,thread2修改flag的值,然后thread1退出循环。但是在未做同步控制的情况下多线程的执行情况是无法预料的。还存在一个很重要的问题,那就是编译器优化(这里编译器可以是Java编译器如JIT,JVM,CPU)。

  • 对于thread1,没有对flag的写操作,所以编译器认为flag的值总是true,就将flag直接改为true来提高程序运行速度,这种优化是被允许的,因为对于它本身而言没有改变程序语义。
  • 对于thread2,没有要求对flag的值要刷回主存,编译器就可能优化为忽略对flag的写指令,因为不刷回主存的值改变只有线程自己可见。

2.3 指令重排序问题

对上图中三条指令,我们期望是顺序执行,但某些编译器为了提高速度,很可能对指令重排序变成下面一种执行顺序。

再来看看下面的例子

处理器A处理器B
a = 1; // 写操作A1b =2; //写操作B1
x = b; //读操作A2y = a; //读操作B2
初始状态 a = b = 0结果 x = y = 0

之所以会出现以上结果,是因为处理器对写读指令进行了重排序,如将顺序A1 -> A2重排成A2 -> A1。对写读的重排序在x86架构下是被允许的。下图是不同架构下支持的重排序类型,这解释了为什么相同的程序在不同的架构系统下会产生不同的结果,因为编译器可能对你的代码进行了不同的重排序。

另外重排序需要考虑到数据之间的依赖性,比如下面3条指令,3是不会排到指令1之前的,因为指令3依赖于指令1的数据x。

int x = 1; //1
int y = 2; //2
y = x * x; //3

2.4 可见性问题

观察以上代码,写线程在自己的工作内存中改变了x的值却并未来得及刷回主内存,这样读线程读取到的值仍然是旧值,读线程此时对写线程的操作不可见。Java为此提供了volatile关键字解决方案:只要用volatile修饰变量x,对x进行原子操作后,x的值将立马刷回主内存,这样保证了读线程对写线程的可见性。

2.5 原子性问题

Java中long型占8字节,也就是64位,如果在64位操作系统中执行以上代码不存在原子性问题,对foo的写操作一步完成,但是在32位操作系统中这种写操作就失去了原子性。32位操作系统中对foo的写操作分两步进行-分别对高32位和低32位进行写操作。在这种情况下就可能产生如下结果

2.6 Happens-before规则

Happens-before表示动作上的偏序关系,官方文档对于该规则的定义如下

大致翻译一下就是:

两个动作可以由happends-before关系排序,如果一个动作happens-before另一个动作,那么第一个动作的执行结果对后一个动作可见。两个操作之间存在happens-before关系,并不意味着必须要按照happens-before关系指定的顺序来执行。如果重排序之后的执行结果,与按happens-before关系来执行的结果一致,那么这种重排序并不非法。例如,在线程构造的对象的每个字段中写入默认值不需要在该线程的开始之前发生,只要没有读取操作就会观察到该事实。另外,当两个动作存在于不同的线程中时,也存在这种关系,此种情况下两者之间会存在一个消息传递机制。

happens-before的8条规则如下:

  • 程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。
  • 监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。
  • volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。
  • 传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。
  • start()规则:如果线程A执行操作ThreadB.start()(启动线程B),那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作。
  • join()规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回。
  • 程序中断规则:对线程interrupted()方法的调用先行于被中断线程的代码检测到中断时间的发生。
  • 对象finalize规则:一个对象的初始化完成(构造函数执行结束)先行于发生它的finalize()方法的开始

2.7 实现

字段域方法域
finalsynchronized(method/block)
volatilejava.util.concurrent.*

volatile

public class VolatileFieldsVisivility{
    int a = 0;
    volatile int x = 0;
    public void writeThread(){
        a = 1; //1
        x = 1; //2
    }
    public void readThread(){
        int r2 = x; //3
        int d1 = a; //4
    }
}

假设写线程执行完后,问读线程读变量a的值是1还是0还是不确定?答案是确定的1,即使变量a未用volatile修饰。由上面给出的happens-before规则可推得:1 happens-before 2, 2 happens-before 3 , 3 happens-before 4 --> 1 happens-before 4(传递性),即读线程读a的时候一定能看到写线程的执行结果,简短来说就是当一个线程对volatile修饰的变量写入,并且读取时也是此变量时在他之前的所有写操作被保证对其他线程是可见的。值得注意的是,写读操作必须是原子性的,如果被volatile修饰的是long或者double,那么这个64位的变量不能被拆分存储。也就是说volatile保证了可见性和有序性,但不保证原子性。

由于篇幅过长,其他方式的实现我将在其他文章中单独抽出来分析。

3. 总结

Java内存模型就是Java语言在内存模型上的实现,它是为了保证多线程场景下的原子性、可见性和有序性提出的规范。Java语言提供了volatilesynchronizedfinal关键字和java.util.concurrent.*并发编程包来实现这些规范,这些提供给程序员的原语和包屏蔽了和底层交互的细节,让程序员可以更方便快捷地编程。

Last Modified: October 19, 2020
Leave a Comment