Java并发编程之内存模型

JMM

Java线程之间的通信由Java内存模型(本文简称为JMM)控制,JMM决定一个线程对共享变量的写入何时对另一个线程可见。线程之间的共享变量存储在主内存(Main Memory)中,每个线程都有一个私有的本地内存(Local Memory),本地内存中存储了该线程以读/写共享变量的副本。

JMM通过控制主内存与每个线程的本地内存之间的交互,来为Java程序员提供内存可见性保证。

重排序

重排序是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段。

在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序。重排序分3种类型。

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

2)指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。

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

重排序规则:

扫描二维码关注公众号,回复: 4838274 查看本文章

内存屏障类型表:

 

依赖性:编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。

3中数据依赖类型表

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

double pi = 3.14; // A

double r = 1.0; // B

double area = pi * r * r; // C

3个操作之间的依赖关系

 

A和C之间存在数据依赖关系,同时B和C之间也存在数据依赖关系。因此在最终执行的指令序列中,C不能被重排序到A和B的前面。但A和B之间没有数据依赖关系,编译器和处理器可以重排序A和B之间的执行顺序。

程序的两种执行顺序

同步程序的顺序一致性效果

class SynchronizedExample {
		int a = 0;
		boolean flag = false;

		public synchronized void writer() { // 获取锁
			a = 1;
			flag = true;
		} // 释放锁

		public synchronized void reader() { // 获取锁
			if (flag) {
			int i = a;
			} // 释放锁
		}
	}

 

经过重排序可能形成的执行对比

happens-before简介

定义:

1)如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。

2)两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须要按照happens-before关系指定的顺序来执行。如果重排序之后的执行结果,与按happens-before关系来执行的结果一致,那么这种重排序并不非法(也就是说,JMM允许这种重排序)。

原则:

1、程序顺序规则:一个线程中的每个操作happens before于随后该线程中的任意后续操作

2、锁定规则:对一个锁的解锁happens before于随后对这个锁的获取

3、volatile变量规则:对一个volatile域的写happens before于对这个变量的读

4、线程启动规则:如果线程A执行线程B的start方法,那么线程A的B.start()happens before于线程B的任意操作

5、线程结束规则:如果线程A执行线程B的join方法,那么线程B的任意操作happens before于线程A从B.join()方法成功返回

6、中断规则:对线程interrupt()方法的调用happen—before发生于被中断线程的代码检测到中断时事件的发生

7、终结器规则:一个对象的初始化完成(构造函数执行结束)happen—before它的finalize()方法的开始

8、传递性规则:如果操作A happens before操作B,操作B happens before操作C,那么可以得出A happens before操作C

 

两个操作之间具有happens-before关系,并不意味着前一个操作必须要在后一个操作之前执行!happens-before仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前(the first is visible to and ordered before the second)。

happens-before与JMM的关系

Happens-before (程序顺序规则)案例:

1)A happens-before B。

2)B happens-before C。

3)A happens-before C。

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

多线程下重排序:

class ReorderExample {

    int     a    = 0;

    boolean flag = false;


    public void writer() {

        a = 1; //1

        flag = true; //2

    }


    public void reader() {

        if (flag) { //3

            int i = a * a; //4

            //s……

        }

    }

}

A B两个线程执行writer和reader方法,A先于B ,线程B在执行操作4时,不一定能看到线程A在操作1对共享变量a的写入

由于操作1和操作2没有数据依赖关系,编译器和处理器可以对这两个操作重排序;同样,操作3和操作4没有数据依赖关系,编译器和处理器也可以对这两个操作重排序。

程序运行时序图

as-if-serial语义只能保证单线程下最终执行结果不会被改变,在这里多线程语义被重排序破坏。

操作3和操作4重排序:

在程序中,操作3和操作4存在控制依赖关系。当代码中存在控制依赖性时,会影响指令序列执行的并行度。为此,编译器和处理器会采用猜测(Speculation)执行来克服控制相关性对并行度的影响。以处理器的猜测执行为例,执行线程B的处理器可以提前读取并计算a*a,然后把计算结果临时保存到一个名为重排序缓冲(Reorder Buffer,ROB)的硬件缓存中。当操作3的条件判断为真时,就把该计算结果写入变量i中。

数据竞争与顺序一致性

Java内存模型规范对数据竞争的定义

在一个线程中写一个变量,在另一个线程读同一个变量,而且写和读没有通过同步来排序。

当代码中包含数据竞争时,程序的执行往往产生违反直觉的结果。如果一个多线程程序能正确同步,这个程序将是一个没有数据竞争的程序。

JMM对正确同步的多线程程序的内存一致性做了如下保证

如果程序是正确同步的,程序的执行将具有顺序一致性(Sequentially Consistent)——即程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同。马上我们就会看到,这对

于程序员来说是一个极强的保证。这里的同步是指广义上的同步,包括对常用同步原语(synchronized、volatile和final)的正确使用。

顺序一致性内存模型

顺序一致性内存模型是一个被计算机科学家理想化了的理论参考模型,它为程序员提供了极强的内存可见性保证。顺序一致性内存模型有两大特性。

1)一个线程中的所有操作必须按照程序的顺序来执行。

2)(不管程序是否同步)所有线程都只能看到一个单一的操作执行顺序。在顺序一致性内存模型中,每个操作都必须原子执行且立刻对所有线程可见。

在概念上,顺序一致性模型有一个单一的全局内存,这个内存通过一个左右摆动的开关可以连接到任意一个线程,同时每一个线程必须按照程序的顺序来执行内存读/写操作。在任意时间点最多只能有一个线程可以连接到内存。当多个线程并发执行时,所有线程的内存读/写操作是串行化的。

在JMM中,临界区内的代码可以重排序(但JMM不允许临界区内的代码“逸出”到临界区之外,那样会破坏监视器的语义)。

 

参阅书籍:

《Java并发编程艺术》

《Java多线程编程》

class SynchronizedExample {

    int     a    = 0;

    boolean flag = false;


    public synchronized void writer() { //获取锁

        a = 1;

        flag = true;

    } //释放锁


    public synchronized void reader() { //获取锁

        if (flag) {

            int i = a;

            //……

        } //释放锁

    }

}

JMM不保证对64位的long型和double型变量的写操作具有原子性,而顺序一致性模型保证对所有的内存读/写操作都具有原子性。

 

处理器和内存之间的数据传递都是通过一系列步骤来完成的,这一系列步骤称之为总线事务

总线事务包括读事务(Read Transaction)和写事务(Write Transaction)。读事务从内存传送数据到处理器,写事务从处理器传送数据到内存,每个事务会读/写内存中一个或多个物理上连续的字

在一个处理器执行总线事务期间,总线会禁止其他的处理器和I/O设备执行内存的读/写。

在32位的处理器可能会把一个64位long/double型变量的写操作拆分为两个32位的写操作来执行。

这两个32位的写操作可能会被分配到不同的总线事务中执行,此时对这个64位变量的写操作将不具有原子性。

Long型变量的高低位在不同的事务中,处理器B可能会读到无效的高32位,不能读全。

final域的内存语义

重排序规则

1)在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。

2)初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作之间不能重排序。

public class FinalExample {

    int                 i;  //普通变量

    final int           j;  //final变量

    static FinalExample obj;

    public FinalExample() { //构造函数

        i = 1; //写普通域

        j = 2; //写final域

    }

    public static void writer() { //写线程A执行

        obj = new FinalExample();

    }

    public static void reader() { //读线程B执行

        FinalExample object = obj; //读对象引用

        int a = object.i; //读普通域

        int b = object.j; //读final域

    }

}

重排序规则

1)JMM禁止编译器把final域的写重排序到构造函数之外。

2)编译器会在final域的写之后,构造函数return之前,插入一个StoreStore屏障。这个屏障禁止处理器把final域的写重排序到构造函数之外。

写final域的重排序规则可以确保:在对象引用为任意线程可见之前,对象的final域已经被正确初始化过了,而普通域不具有这个保障。

重排序规则

在一个线程中,初次读对象引用与初次读该对象包含的finals域,JMM禁止处理器重排序这两个操作(注意,这个规则仅仅针对处理器)。编译器会在读final域操作的前面插入一个LoadLoad屏障。

初次读对象引用与初次读该对象包含的final域,这两个操作之间存在间接依赖关系。由于编译器遵守间接依赖关系,因此编译器不会重排序这两个操作。

读对象的普通域的操作被处理器重排序到读对象引用之前。读普通域时,该域还没有被写线程A写入,这是一个错误的读取操作。而读final域的重排序规则会把读对象final域的操作“限定”在读对象引用之后,此时该final域已经被A线程初始化过了,这是一个正确的读取操作。

 

写final域的重排序规则对编译器和处理器增加了如下约束

public class FinalReferenceExample {

    final int[]                  intArray; //final是引用类型

    static FinalReferenceExample obj;


    public FinalReferenceExample() { //构造函数

        intArray = new int[1]; //1

        intArray[0] = 1; //2

    }


    public static void writerOne() { //写线程A执行

        obj = new FinalReferenceExample(); //3

    }


    public static void writerTwo() { //写线程B执行

        obj.intArray[0] = 2; //4

    }


    public static void reader() { //读线程C执行

        if (obj != null) { //5

            int temp1 = obj.intArray[0]; //6

        }

    }

}

写final域的重排序规则对编译器和处理器增加了如下约束:在构造函数内对一个final引用的对象的成员域的写入,与随后在构造函数外把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。

1是对final域的写入,2是对这个final域引用的对象的成员域的写入,3是把被构造的对象的引用赋值给某个引用变量。这里除了前面提到的1不能和3重排序外,2和3也不能重排序。

如果想要确保读线程C看到写线程B对数组元素的写入,写线程B和读线程C之间需要使用同步原语(lock或volatile)来确保内存可见性。

 

Final域“溢出”实现:

public class FinalReferenceEscapeExample {

    final int   i;

    static FinalReferenceEscapeExample obj;


    public FinalReferenceEscapeExample() {

        i = 1; //1写final域

        obj = this; //2 this引用在此“逸出”

    }


    public static void writer() {

        new FinalReferenceEscapeExample();

    }


    public static void reader() {

        if (obj != null) { //3

            int temp = obj.i; //4

        }

    }

}

重排序下可能出现结果:

 

JMM的内存可见性保证

  1. 单线程程序

单线程程序不会出现内存可见性问题。编译器、runtime和处理器会共同确保单线程程序的执行结果与该程序在顺序一致性模型中的执行结果相同。

  1. 正确同步的多线程程序

正确同步的多线程程序的执行将具有顺序一致性(程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同)。这是JMM关注的重点,JMM通过限制编译器和处理器的重排序来为程序员提供内存可见性保证。

  1. 未同步/未正确同步的多线程程序

JMM为它们提供了最小安全性保障:线程执行时读取到的值,要么是之前某个线程写入的值,要么是默认值(0、null、false)。

猜你喜欢

转载自blog.csdn.net/weixin_44416039/article/details/86070610