JMM内存模型

JMM内存模型

JMM与happen-before
重排序与内存可见性的关系
  • 可见性:一个线程对共享变量值的修改,能够及时被其他线程看到。
  • 重排序:重排序规则就是cpu认为你没有先后依赖顺序;他就给你以最优的方式执行。

Store Buffer的延迟写入是重排序的一种,称为内存重排序(Memory Ordering)。除此之外,还有编译器和CPU的指令重排序。
重排序类型:

  1. 编译器重排序。
    对于没有先后依赖关系的语句,编译器可以重新调整语句的执行顺序。
  2. CPU指令重排序。
    在指令级别,让没有依赖关系的多条指令并行。
  3. CPU内存重排序。
    CPU有自己的缓存,指令的执行顺序和写入主内存的顺序不完全一致。

在三种重排序中,第三类就是造成“内存可见性”问题的主因,如下案例:

线程1:
X=1
a=Y

线程2:
Y=1
b=X

假设X、Y是两个全局变量,初始的时候,X=0,Y=0。请问,这两个线程执行完毕之后,a、b的正确结果应该是什么?

很显然,线程1和线程2的执行先后顺序是不确定的,可能顺序执行,也可能交叉执行,最终正确的
结果可能是:

  1. a=0,b=1
  2. a=1,b=0
  3. a=1,b=1

也就是不管谁先谁后,执行结果应该是这三种场景中的一种。但实际可能是a=0,b=0。

两个线程的指令都没有重排序,执行顺序就是代码的顺序,但仍然可能出现a=0,b=0。原因是线程1先执行X=1,后执行a=Y,但此时X=1还在自己的StoreBuffer里面,没有及时写入主内存中。所以,线程2看到的X还是0。线程2的道理与此相同。

虽然线程1觉得自己是按代码顺序正常执行的,但在线程2看来,a=Y和X=1顺序却是颠倒的。指令没有重排序,是写入内存的操作被延迟了,也就是内存被重排序了,这就造成内存可见性问题。

内存屏障

为了禁止编译器重排序和 CPU 重排序,在编译器和 CPU 层面都有对应的指令,也就是内存屏障(MemoryBarrier)。这也正是JMM和happen-before规则的底层实现原理。

编译器的内存屏障,只是为了告诉编译器不要对指令进行重排序。当编译完成之后,这种内存屏障就消失了,CPU并不会感知到编译器中内存屏障的存在。

而CPU的内存屏障是CPU提供的指令,可以由开发者显示调用。
内存屏障是很底层的概念,对于 Java 开发者来说,一般用 volatile 关键字就足够了。但从JDK 8开始,Java在Unsafe类中提供了三个内存屏障函数。

public final class Unsafe {
  // ...
  public native void loadFence();
  public native void storeFence();
  public native void fullFence();
  // ...
}
as-if-serial语义

1.单线程程序的重排序规则
无论什么语言,站在编译器和CPU的角度来说,不管怎么重排序,单线程程序的执行结果不能改变,这就是单线程程序的重排序规则。

即只要操作之间没有数据依赖性,编译器和CPU都可以任意重排序,因为执行结果不会改变,代码看起来就像是完全串行地一行行从头执行到尾,这也就是as-if-serial语义。

对于单线程程序来说,编译器和CPU可能做了重排序,但开发者感知不到,也不存在内存可见性问题。

2.多线程程序的重排序规则
编译器和CPU的这一行为对于单线程程序没有影响,但对多线程程序却有影响。

对于多线程程序来说,线程之间的数据依赖性太复杂,编译器和CPU没有办法完全理解这种依赖性并据此做出最合理的优化。

编译器和CPU只能保证每个线程的as-if-serial语义。
线程之间的数据依赖和相互影响,需要编译器和CPU的上层来确定。

上层要告知编译器和CPU在多线程场景下什么时候可以重排序,什么时候不能重排序。

happen-before是什么

java内存模型(JMM)是一套规范,在多线程中,一方面,要让编译器和CPU可以灵活地重排序;另一方面,要对开发者做一些承诺,明确告知开发者不需要感知什么样的重排序,需要感知什么样的重排序。然后,根据需要决定这种重排序对程序是否有影响。如果有影响,就需要开发者显示地通过volatile、synchronized等线程同步机制来禁止重排序。
基于happen-before的这种描述方法,JMM对开发者做出了一系列承诺:

  1. 单线程中的每个操作,happen-before 对应该线程中任意后续操作(也就是 as-if-serial语义保
    证)。
  2. 对volatile变量的写入,happen-before对应后续对这个变量的读取。
  3. 对synchronized的解锁,happen-before对应后续对这个锁的加锁。

JMM对编译器和CPU 来说,volatile 变量不能重排序;非 volatile 变量可以任意重排序。

volatile关键字
64位写入的原子性

对于一个long类型变量的取值和赋值而言,在多线程场景下。

public class MyClass {
  private long a = 0;
  // 线程A调用set(100)
  public void set(long a) {
    this.a = a;
 }
 
  // 线程B调用get(),返回值一定是100吗?
  public long get() {
    return this.a;
 }
}

在多线程情况下返回不一定是100,因为JVM的规范并没有要求64位的long或者double的写入是原子的。在32位的机器上,一个64位变
量的写入可能被拆分成两个32位的写操作来执行。这样一来,读取的线程就可能读到“一半的值”。解决
办法也很简单,在long前面加上volatile关键字。

重排序: DCL问题

单例模式的线程安全的写法不止一种,常用写法为DCL(Double Checking Locking),如下所示:

public class Singleton {
  private static Singleton instance;
  public static Singleton getInstance() {
    if (instance == null) {
      synchronized(Singleton.class) {
        if (instance == null) {
          // 此处代码有问题
          instance = new Singleton();
       }
     }
   }
    return instance;
 }
}

上述的 instance = new Singleton(); 代码有问题:其底层会分为三个操作:

  1. 分配一块内存。
  2. 在内存上初始化成员变量。
  3. 把instance引用指向内存。
    在这三个操作中,操作2和操作3可能重排序,即先把instance指向内存,再初始化成员变量,因为
    二者并没有先后的依赖关系。此时,另外一个线程可能拿到一个未完全初始化的对象。这时,直接访问
    里面的成员变量,就可能出错。这就是典型的“构造方法溢出”问题。
    解决办法也很简单,就是为instance变量加上volatile修饰。
    volatile的三重功效:64位写入的原子性、内存可见性和禁止重排序。
final关键字
构造方法溢出问题
public class MyClass {
  private int num1;
  private int num2;
  private static MyClass myClass;
 
  public MyClass() {
    num1 = 1;
    num2 = 2;
 }
 
  /**
   * 线程A先执行write()
   */
  public static void write() {
    myClass = new MyClass();
 }
 
  /**
   * 线程B接着执行write()
   */
  public static void read() {
    if (myClass != null) {
      int num3 = myClass.num1;
      int num4 = myClass.num2;
   }
 }
}

num3和num4的值是否一定是1和2?
num3、num4不见得一定等于1,2。和DCL的例子类似,也就是构造方法溢出问题。
myClass = new MyClass()这行代码,分解成三个操作:

  1. 分配一块内存;
  2. 在内存上初始化i=1,j=2;
  3. 把myClass指向这块内存。
    操作2和操作3可能重排序,因此线程B可能看到未正确初始化的值。对于构造方法溢出,就是一个
    对象的构造并不是“原子的”,当一个线程正在构造对象时,另外一个线程却可以读到未构造好的“一半对
    象”。
final的happen-before语义

要解决这个问题,不止有一种办法。
办法1:给num1,num2加上volatile关键字。
办法2:为read/write方法都加上synchronized关键字。
如果num1,num2只需要初始化一次,还可以使用final关键字。
之所以能解决问题,是因为同volatile一样,final关键字也有相应的happen-before语义:

  1. 对final域的写(构造方法内部),happen-before于后续对final域所在对象的读。
  2. 对final域所在对象的读,happen-before于后续对final域的读。
    通过这种happen-before语义的限定,保证了final域的赋值,一定在构造方法之前完成,不会出现
    另外一个线程读取到了对象,但对象里面的变量却还没有初始化的情形,避免出现构造方法溢出的问
    题。
happen-before规则总结
  1. 单线程中的每个操作,happen-before于该线程中任意后续操作。
  2. 对volatile变量的写,happen-before于后续对这个变量的读。
  3. 对synchronized的解锁,happen-before于后续对这个锁的加锁。
  4. 对final变量的写,happen-before于final域对象的读,happen-before于后续对final变量的
    读。
    四个基本规则再加上happen-before的传递性,就构成JMM对开发者的整个承诺。在这个承诺以外
    的部分,程序都可能被重排序,都需要开发者小心地处理内存可见性问题。
  • 开发者层面(volatile->final->synchronized)
  • JVM层面 (JMM)
  • CPU层面 (CPU缓存体系 CPU内存重排序 内存屏障)
  • 2
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 3
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值