Java并发与多线程(1)——指令重排与JMM


如果觉得有用可以关注一下公众号:求赞求关注
在这里插入图片描述

一、指令重排与JMM

参考文章:

1.深入理解 Java 内存模型(二)——重排序

2.Java并发编程系列03|重排序-可见性和有序性问题根源

3.深入分析:volatile内存屏障+实现原理(JMM和MESI)

1.1 数据依赖性

如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。数据依赖分下列三种类型:

名称代码示例说明
写后读a = 1;b = a;写一个变量之后,再读这个位置
写后写a = 1;a = 2;写一个变量之后,再写这个变量。
读后写a = b;b = 1;读一个变量之后,再写这个变量。

上面三种情况,只要重排序两个操作的执行顺序,程序的执行结果将会被改变。

前面提到过,编译器和处理器可能会对操作做重排序。编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。注意,这里所说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑。

1.2 as-if-serial语义

as-if-serial语义的意思指:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器,runtime 和处理器都必须遵守as-if-serial语义。

为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作可能被编译器和处理器重排序。为了具体说明,请看下面计算圆面积的代码示例:

double pi  = 3.14;    //A  
double r   = 1.0;     //B  
double area = pi * r * r; //C  

上面三个操作的数据依赖关系如下图所示:

如上图所示,A和C之间存在数据依赖关系,同时B和C之间也存在数据依赖关系。因此在最终执行的指令序列中,C不能被重排序到A和B的前面(C排到A和B的前面,程序的结果将会被改变)。但A和B之间没有数据依赖关系,编译器和处理器可以重排序A和B之间的执行顺序。下图是该程序的两种执行顺序:

as-if-serial语义把单线程程序保护了起来,遵守as-if-serial语义的编译器,runtime 和处理器共同为编写单线程程序的程序员创建了一个幻觉:单线程程序是按程序的顺序来执行的。as-if-serial语义使单线程程序员无需担心重排序会干扰他们,也无需担心内存可见性问题。

1.3 重排序概念

从java源代码到最终实际执行的指令序列,会分别经历下面三种重排序:

)

  • 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
  • 指令级并行的重排序。处理器将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
  • 内存系统的重排序。处理器使用缓存和读/写缓冲区,使得加载和存储操作看上去可能是在乱序执行。

重排序遵守数据依赖性as-if-serial 语义

1.4 重排序对多线程的影响

并发编程的三大问题:原子性、可见性、有序性。重排序可以提高程序执行的性能,但是代码的执行顺序改变,可能会导致多线程程序出现可见性问题和有序性问题

现在让我们来看看,重排序是否会改变多线程程序的执行结果。请看下面的示例代码:

  • Reorder类
package org.numb.concurrency.chapter01;

public class Reorder {

    public static int a = 0;
    public static int b = 0;
    public static int x = 0;
    public static int y = 0;

    private static int times = 0;

    public static void init() {
        a = 0;
        b = 0;
        x = 0;
        y = 0;
    }

    public static boolean reorder() throws InterruptedException {
        Thread thread1 = new Thread(() -> {
            a = 1;
            x = b;
        });

        Thread thread2 = new Thread(() -> {
            b = 1;
            y = a;
        });
        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();
        times++;
        System.out.println("第 "+ times + " 结果:" + "(x, y) = " + x + ", " + y);
        return x == 0 && y == 0;
    }

}
  • 主函数
package org.numb.concurrency.chapter01;

public class StartUp {
    public static void main(String[] args) throws InterruptedException {
        do {
            Reorder.init();
        }while (!Reorder.reorder());
    }
}
  • 结果

正常情况下,比较容易想象程序输出(x,y)=(1,0)或者(0,1)或者(1,1):

  • thread1在thread2开始之前就执行完成
  • thread2在thread1开始之前就执行完成
  • thread1与thread2交替执行

但是在12431次运行后输出(0,0),因为四个变量a,b,x,y之间不存在数据依赖性,因此指令重排后就可以产生这种结果。

1.5 Java内存模型(JMM)

JMM全称是Java Memory Model(Java内存模型)。Java内存模型的通过各种操作来定义的,包括对变量的读/写操作,监视器的加锁和释放操作,以及线程的启动和合并操作。

JMM属于语言级别的抽象内存模型,可以简单理解为对硬件模型的抽象,它定义了共享内存中多线程程序读写操作的行为规范,通过这些规则来规范对内存的读写操作从而保证指令的正确性,它解决了CPU 多级缓存、处理器优化、指令重排序导致的内存访问问题,保证了并发场景下的可见性。

1.5.1 JMM抽象模型结构

JMM 抽象模型分为主内存、工作内存;主内存是所有线程共享的,一般是实例对象、静态字段、数组对象等存储在堆内存中的变量。工作内存是每个线程独占的,线程对变量的所有操作都必须在工作内存中进行,不能直接读写主内存中的变量,线程之间的共享变量值的传递都是基于主内存来完成。

1.5.2 Happens-Before规则

JMM为程序中所有的操作定义了一个偏序关系称之为Happens-Before规则。要想保证执行操作B的线程看到操作A的结果(无论A和B是否在同一个线程中执行),那么在A和B之间就必须满足Happens-Before关系。如果两个操作之间缺乏Happens-Before关系,那么JVM可以对它们任意地重排序。

Happens-Before规则的部分规则如下:

  • 程序顺序规则:如果程序中操作A在操作B之前,那么线程中A操作将在B操作之前执行
  • 监视器锁规则:在监视器锁上的解锁操作必须在同一监视器锁上的加锁操作之前执行
  • volatile变量规则:对volatile变量的写入操作必须对该变量的读操作之前执行
  • 线程启动规则:Thread.Start的调用必须在该线程中执行任何操作之前执行
  • 线程结束规则:线程中任何操作都必须在其他线程检测到该线程已经结束之前执行,如从Thread.join中成功返回,或者在调用Thread.isAlive时返回false。
  • 中断规则:当一个线程在另一个线程上调用interrupt时,必须在被中断线程检测到interrupt调用之前执行(通过抛出InterruptedException,或者调用isInterrupted和interrupted)。
  • 终结器规则:对象的构造函数必须在启动该对象的终结器之前执行完成
  • 传递性:如果操作A在操作B之前完成,并且操作B在操作C之前执行,那么操作A必须在操作C之前执行

当一个变量被多个线程读取并且至少被一个线程写入时,如果在读操作和写操作之间没有依照Happens-Before来排序,那就会产生数据竞争问题。

根据happens- before的程序顺序规则,上面计算圆的面积的示例代码存在三个happens- before关系:

A happens- before B;
B happens- before C;
A happens- before C;
这里的第3个happens- before关系,是根据happens-before的传递性推导出来的。

这里A happens- before B,但实际执行时B却可以排在A之前执行(看上面的重排序后的执行顺序)。在第一章提到过,如果A happens- before B,JMM并不要求A一定要在B之前执行。JMM仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前。这里操作A的执行结果不需要对操作B可见;而且重排序操作A和操作B后的执行结果,与操作A和操作B按happens- before顺序执行的结果一致。在这种情况下,JMM会认为这种重排序并不非法(not illegal),JMM允许这种重排序。

在计算机中,软件技术和硬件技术有一个共同的目标:在不改变程序执行结果的前提下,尽可能的开发并行度。编译器和处理器遵从这一目标,从happens- before的定义我们可以看出,JMM同样遵从这一目标。

1.5.3 JMM如何解决重排序问题

JMM处理重排序问题:

1)对于编译器,JMM的编译器重排序规则会禁止特定类型的编译器重排序(不是所有的编译器重排序都要禁止)。

2)对于处理器重排序,JMM的处理器重排序规则会要求Java编译器在生成指令序列时,插入特定类型的内存屏障指令,来禁止特定类型的处理器重排序。

3)JMM根据代码中的关键字(如:synchronized、volatile)和J.U.C包下的一些具体类来插入内存屏障。

JMM把内存屏障指令分为下列四类:

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

1.5.4 volidate关键字

volatile通过内存屏障禁止指令重排序,主要遵循以下三个规则:

  1. 当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。这个规则确保volatile写之前的操作不会被编译器重排序到volatile写之后。
  2. 当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。这个规则确保volatile读之后的操作不会被编译器重排序到volatile读之前。
  3. 当第一个操作是volatile写,第二个操作是volatile读时,不能重排序。

为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎不可能。为此,JMM采取保守策略。下面是基于保守策略的JMM内存屏障插入策略:

  • 在每个volatile写操作的前面插入一个StoreStore屏障
  • 在每个volatile写操作的后面插入一个StoreLoad屏障
  • 在每个volatile读操作的后面插入一个LoadLoad屏障
  • 在每个volatile读操作的后面插入一个LoadStore屏障
  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值