前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >JMM-重排序

JMM-重排序

原创
作者头像
林淮川
修改2021-09-09 14:50:26
3530
修改2021-09-09 14:50:26
举报
图片
图片

    java内存模型允许编译器和处理器对指令重排,目的减少流水线的中断,从而提高流水线运行效率。

    数据依赖不会重排序。在单线程下重排序可以保证最终执行的结果与程序顺序执行结果一致。

as-if-serial (仿佛是序列) 不管怎么样重排序,单线程程序的执行结果不能被改变,编译器,runtime 和处理器必须遵守 as-if-serial 语义

图片
图片

1、编译器优化排序:编译器在不改变单线程程序语义前提下,可以重新安排语句的执行顺序。

2、指令级并行的重排序:现在处理器采用了指令级并行技术来讲多条指令重叠执行,不存在数据依赖,处理器可以改变语句对应机器指令的执行顺序。

3、内存系统的重排序:由于处理器使用缓存和读/写缓存区,这使得 加在和 粗处 操作看上去可能是乱序执行

图片
图片

如果两个操作访问同一个变量,且这两个操作有一个写操作,两个操作之间存在数据依赖性

图片
图片

这里说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同的处理器之间和不同线程之间数据依赖性不被编译器和处理器考虑 一. 重排序现象

1. 重排序可能引发的单例模式严重问题

private static Singleton singleton;
public static Singleton getInstance() {
    if (singleton == null) {
        synchronized (Singleton.class) {
    if (singleton == null) {
            singleton = new Singleton();
          }
        }
      }
    return singleton;
}

原理:

sngleton = new Singleton()这句,这并非是⼀个原子操作,这并非是一个原子操作,事实上在 JVM 中这句话大概做了下面3 件事情。

  1. 给 singleton 分配堆内存(Single 对象)。
  2. 调用 Single 的构造函数来初始化成员变量,形成实例。
  3. 将 singleton 指针指向分配的内存空间(执行完这步 singleton 才是非 null 了)。

正常执行顺序:1->2->3,由于操作2和操作3没有依赖性(操作1和操作3有依赖性),可能发生指令重排,可能的执行顺序为:1->3->2。

回到代码,当操作1,3执行后,singleton 指针是不为 null 了,此时,另一个线程执行 if(singleton == null) 就会不成立,直接返回,而此时,Singleton的构造还可能未执行,会引发严重数据错误。

volatile关键字的一个作用是禁止指令重排,把singleton声明为volatile之后,对它的写操作就会有一个内存屏障,这样在它的赋值完成之前,就不用会调用读操作。

private volatile static Singleton  ; // 防止指令重排

2. 多线程写-读 重排序

public class Test {
  static int x = 0, y = 0;
  static int a = 0, b = 0;
  public static void main(String[] args) throws InterruptedException {
    while (true) {
      reSort();
    }
  }
  static void reSort() throws InterruptedException {
    Thread ta = new Thread(new Runnable() {
      public void run() {
        a = 1; //操作1
        x = b; //操作2
      }
    });
    Thread tb = new Thread(new Runnable() {
      public void run() {
        b = 1; //操作3
        y = a; //操作4
      }
    });
    ta.start();
    tb.start();
    ta.join();
    tb.join();
    if (x == 0 && y == 0) {
      System.out.println("(" + x + "," + y + ")");
    }
    x = 0; y = 0; a = 0; b = 0;
  }
}

原理:

处理器使用写缓存临时保存内存写入数据。写缓存区可以保证指令流水线持续运行,他可以避免由于处理器停顿下来等待想内存写入数据而产生延迟,同时通过以批处理的方式刷新缓存区,以及合并写缓存区对同一内存地址多次写,减少对内存总线的占用。虽然写缓存区有这么多好处,但每个处理器上的写缓存区仅仅对它所在的处理器可见。

图片
图片

处理器A和处理器B 同时把共享变量写入缓存区A1,B1, 然后从内存中读取另一个共享变量(A2,B2),最后才把自己写缓存区中保存的脏数据刷新到内存数据A3,B3 。

即先写再读 最后刷新

1、写 a=1,b=1;
2、读a=0, b=0; 
3、刷新

虽然处理器A执行内存操作的顺序为A1->A2, 内存操作世界发生顺序A2->A1。

由于写缓存区仅对自己处理器可见,会导致处理器操作顺序和内存实际执行顺序不一致,由于现在处理器会使用写缓存,因此先到处理器都允许对写-读操作进行重排序

图片
图片

常见的处理器都允许store-load重排序,都不允许对存在数据依赖的操作做重排序,sparc-TSO 和 X86拥有相对较强的处理器内存模型,仅允许对写-读操作做重排序(它们都是用写缓冲区)

图片
图片

为了保证内存可见性,java编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型处理器的重排序

内存屏障类型

StoreLoad Barriers是一个“全能型”的屏障,它同时具有其他3个屏障的效果。现代的多处理器大多支持该屏障(其他类型的屏障不一定被所有处理器⽀持)。执行该屏障开销会很昂贵:因为当前处理器通常要把写缓存区中的数据全部刷新到内存中(Buffer Fully Flush)

编译器和处理器-猜测执行

public class Recorder {
  public int m = 0;
  private boolean flag = false;
  /**
   * 操作1 和 操作2 没有依赖关系
   */
  public void writer() {
    // 操作1
    this.m = 1;
    // 操作2
    this.flag = true;
  }
  /**
   * 1、操作3和操作4存在控制依赖关系,会影响指令序列执行的并行度
   * 2、编译器和处理器会采取猜测(Speculation) 执行克服控制相关性对并行度影响
   * 3、以处理器的猜测执行为例,执行线程B的处理器可以提前读取并计算a*a,
   * 并把计算记过临时保存到一个名为重排序缓存的硬件缓存中,当操作3的条件判断为真时,就把该计算结果写入变量中
   */
  public void reader() {
    // 操作3
    if (flag) {
      // 操作4
      m = m * m;
    }
  }
}
图片
图片

若水 架构师一枚,现就职于小米小爱开放平台,一个才貌双全的美女码农,平常喜欢总结知识和刷算法题,经常参加LeetCode算法周赛。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档