Java内存模型JMM学习

今天任务:Java内存模型的学习,看这一篇就够了

一、基础

通信:是指线程之间以何种机制交换信息。

  • 共享内存 的并发模型中:线程之间共享程序的公共状态,通过 读 - 写 内存中的公共状态 ,进行隐式通信。
  • 消息传递 的并发模型中:线程之间没有公共状态,线程之间必须通过发送消息来显示进行通信。

同步:是指程序中用于控制不同线程间操作发生相对顺序的机制。

  • 在共享内存并发模型中:同步是显示进行的,即程序员需要通过代码实现线程之间的互斥;
  • 在消息传递的并发模型中,由于消息的发送必须在消息的接收之前,因此,同步是隐式的。
    Java并发采用的是共享内存模型,Java线程之间的通信总是隐式进行的,整个通信过程对于程序员是完全透明的。
Java内存模型的抽象结构

Java线程之间的通信由Java内存模型(JMM)控制,JMM决定了一个线程对共享变量的写入何时对另外一个线程可见。JMM定义了线程主内存之间的抽象关系:线程之间的共享变量存储在主内存中,每个线程都有一个私有的本地内存,本地内存中存储了该线程以读/写共享变量的 副本。本地内存是JMM的一个抽象概念,并不真实存在。它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译优化。内存模型抽象示意图如图所示:
Alt
从图可以看出:如果线程A与线程B进行通信,需要经过两个步骤。

  • 线程A把本地内存中更新过的共享变量刷新到主存中去。
  • 线程B到主存中读取线程A之前更新过的共享变量。

线程之间的通信图:
在这里插入图片描述
假设初始这三个内存中都是 x = 0,线程A执行时,将x的值改为1,临时存放在自己的本地内存中,当两个线程需要通信时,线程A首先会将本地内存中的值刷新到主内存中,此时主内存中的值也为x=1,随后线程B读取值也是为1。
从整体上看,线程A向线程B
发送消息,而这个过程必须经过主内存。JMM通过控制主内存与每个线程的本地内存之间的交互,来为Java程序员提供内存可见性保证。

从源代码到指令序列的重排序

在执行指令时,为了提高性能,编译器和处理器会对指令做重排序。主要由3类重排序:
1)编译器优化指令排序。编译器在不改变单线程程序语义前提下,可以重新安排指令的执行顺序。
2)指令级并行的重排序。现代处理器采用了指令级并行技术(ILP),将多条指令重叠执行。如果不存在数据的依赖,处理器可以改变指令的执行顺序。常见的处理器都不允许对存在数据依赖的操作做重排序。
3)内存系统的重排序。由于处理器使用缓存和读/写缓冲区,使得加载和存储操作看上去可能是在乱序执行。
因此,从Java源代码到最终实际指令的执行序列,会经过下面的3种重排序:
在这里插入图片描述
对于编译器,JMM的编译器重排序会禁止特定类型的编译器重排序(不是所有的编译器重排序都要禁止)。对于处理器重排序,JMM的处理器重排序规则会 要求Java编译器在生成指令序列时插入特定类型的内存屏障指令,通过内存屏障指令来禁止特定类型的处理器重排序
JMM属于语言级的内存模型,它确保在不同的编译器和不同平台的处理器平台之上,通过禁止特定类型的编译器重排序处理器重排序,为程序员提供一致的内存可见性保证。

为了保证内存可见性,Java编译器生成指令的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序。JMM将内存屏障指令分为4种:
在这里插入图片描述
StoreLoad Barriers是一个“全能型”的屏障,它同时具有其他3个屏障的效果,现代处理器大多支持该屏障(其他屏障指令不一定被所有处理器支持),执行该屏障的开销会很大,因为它会将缓冲区中的数据全部刷新到内存中。

happens-before简介

在后面会重点讲解happens-before的概念,现在只是一个基本的介绍。
JMM中如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在happens-before关系。这里提到的两个操作既可以是在一个线程之内,也可以在不同的线程之间。

  • 程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。
  • 监视器锁规则:对于一个锁的解锁,happens-before于随后对于这个锁的加锁。
  • volatile变量规则:对于一个volatile的写,happens-before于任意后续对这个volatile域的读。
  • 传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。

happens-before与JMM的关系图:
在这里插入图片描述
从图可以看出,一个happens-before规则对应一个或则多个编译器和处理器重排序规则。对于Java程序员来说,happens-before规则简单易懂,它避免了Java程序员在编程中对于JMM提供的内存可见性保证的学习。

二、重排序

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

数据依赖性

如果两个操作访问同一个变量,而且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性,数据依赖类型表如表所示。
在这里插入图片描述
上面的3种情况,只要重排序两个操作的执行顺序,程序的执行结果就会改变。
编译器和处理器在重排序时,会遵循数据依赖性,编译器和处理器不会改变数据依赖关系的两个操作的执行顺序。 这就是数据依赖性禁止的重排序。

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

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、B之间不存在数据依赖性,所以两者之间如果进行重排序不会对程序的结果产生影响。
在这里插入图片描述as-if-serial语义将单线程程序保护了起来,给人一种错觉:单线程程序时按照程序的顺序来执行的。as-if-serial语义时单线程程序员无须担心重排序会干扰它们,也无须担心内存可见性。

重排序对多线程的影响

其实在没有说明之前,我们应该都会知道,重排序对多线程肯定是由影响的,具体有什么影响,请看下文:
假设一个程序:

 class ReorderExample{
	int i = 0;
	boolean flag = false;
	public void writer(){
		a = 1;                 //1
		flag = true;           //2
	}
	public void reader(){
		if(flag){              //3
			int i = a * a;     //4
			...
		}
	}
}

在这个程序中,flag是用做标记,用来标记a是否已经被写入,假设有两个线程A和B,A先执行writer(),随后B执行reader(),能否在执行4时,看到操作1的写入?答案是不一定
因为,操作1和操作2之间没有依赖关系,所以可能会发生重排序;操作3和操作4之间也没有依赖性,编译器或处理器也可能发生重排序,所以会产生下面两种情况:
在这里插入图片描述
情形一:操作1和操作2之间进行了重排序,先执行操作2时,线程B进来了,导致读取a的值存在错误。
情形二:在程序中,操作3和操作4存在控制依赖关系。当代码中存在控制依赖性时,会影响指令序列执行的并行度。为此,编译器和处理器会采用猜想执行来客服控制相关性对并行度的影响。所以会提前计算 a*a的值赋值给临时变量temp,随后再将这个值赋值给 i ,其实这个时候程序已经出错了,程序的重排序在这里打破了多线程程序的语义。
因此,在单线程中,对程序进行重排序不会破坏程序的语义,(遵守as-if-serial)但是在对线程中,对存在依赖性控制关系的操作做重排序,可能会造成程序执行结果错误。

三、顺序一致性

顺序一致性内存模型是一个参考模型,在设计的时候,处理器内存模型和编程语言的内存模型都会以顺序一致性内存模型进行参考。

顺序一致性内存模型

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

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

顺序一致性内存模型
Alt
顺序一致性有一个单一的全局内存,这个内存通过一个左右摆动的开关可以连接到任意一个线程,同时每一个线程必须按照程序的顺序来执行内存的读/写操作。从上面的图可以看出,在任意时间点最多只能一个线程可以连接到内存。当多个线程并发执行时,图中的开关装置能把所有的线程内容读/写操作串行化(即在顺序一致性模型中,所有操作之间具有全序关系)。

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

代码如下:

 class ReorderExample{
	int i = 0;
	boolean flag = false;
	public synchronized void writer(){
		a = 1;                 //1
		flag = true;           //2
	}
	public synchronized void reader(){
		if(flag){              //3
			int i = a * a;     //4
			...
		}
	}
}

在上面的代码中,线程A执行writer()方法,线程B执行reader()方法。这是一个正确同步的多线程程序。根据JMM规范,该程序的执行结果将与该程序在顺序一致性模型中的执行结果相同。两者的对比如下:
Alt
顺序一致性模型中,所有的操作完全按照程序的顺序串行执行,而在JMM中,临界区内的代码可以重排序(但不允许临界区内的代码“逸出”到临界区之外),JMM会在退出临界区和进入临界区这两个关键时间点做一些特别处理(执行内存屏障指令),使得程序运行结果正常。从这了我们可以看出:JMM在具体实现上的基本仿真为:在不改变(正确同步的)程序执行结果的前提下,尽可能地为编译器和处理器做出优化。

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

对于未同步或未正确同步的多线程程序,JMM只提供最小安全性:执行线程读取到的值要么是之前某个线程写入的值,要么是默认值(0,Null,False),JMM保证线程读取到的值不会无中生有的冒出来。为了实现最小安全性,JVM在堆上分配对象时,首先会对内存空间进行清零,然后才在上面分配对象。JMM不保证未同步的程序的执行结果与顺序一致性模型中执行结果一致。

  • 顺序一致性模型保证单线程内的操作会按程序的顺序执行,JMM不保证单线程内的操作会按照程序的顺序执行(比如上面正确同步的多线程程序在临界区内的重排序)
  • 顺序一致性模型保证所有线程只能看到一致的操作执行顺序,而JMM不保证所有线程能看到一致的操作执行顺序(因为内部可能重排序)
  • JMM不保证对64位的long型和double型变量的写操作具有原子性,而顺序一致性模型保证对所有的内存读/写具有原子性。

针对第三点,因为在一些32位的处理器上,如果要求对64位数据的写操作具有原子性,会有比较大的开销。为了照顾这种处理器,Java语言规范不强求JVM对64位的long型变量和double类型的变量写操作具有原子性,当JVM在这种处理器上运行时,可能会把一个64位的变量写操作分为两个32位的写操作来完成,这两个32位的写操作可能会被分配到不同的总线事物中执行,此时这个64位的变量写操作不具备原子性。如:
Alt
注意:JSR-133之前的就内存模型中,一个64位long/double型变量的读/写操作可以被分为两个32位的读/写操作执行。从JSR-33内存模型开始(即JDK1.5开始),64位写操作可以分为两个32位写,但是读操作必须要在一个事物中完成。

四、volatile内存语义

当声明共享变量位volatile之后,这个变量的读写会很特别。对于一个volatile变量的读,总是能看到(任意线程)对这个volatile变量的最后写入。锁的语义决定了临界区代码的执行具有原子性。意味着,即使64位的long/double型,只要它是volatile变量,对该变量的读写具有原子性。如果是多个volatile操作或类似于volatile++复合操,不具备原子性。

volatile写读的内存语义

volatile写的内存语义:
当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存中。
还是那拿之前的例子:

class VolatileExample {

    int a = 0;

    volatile boolean flag = false;
    
    public void writer() {
        a = 1;                   //1
        flag = true;               //2
    }
    public void reader() {
        if (flag) {                //3
            int i =  a;           //4   
        }
    }
}

假设线程 A 首先执行 writer() 方法,随后线程 B 执行 reader() 方法,初始时两个线程的本地内存中的 flag 和 a 都是初始状态。下图是线程 A 执行 volatile 写后,共享变量的状态示意图:
Alt
线程 A 在写 flag 变量后,本地内存 A 中被线程 A 更新过的两个共享变量的值被刷新到主内存中。此时,本地内存 A 和主内存中的共享变量的值是一致的。

volatile读的内存语义:
当读一个 volatile 变量时,JMM 会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量。
在这里插入图片描述如果我们把 volatile 写和 volatile 读这两个步骤综合起来看的话,在读线程 B 读一个 volatile 变量后,写线程 A 在写这个 volatile 变量之前所有可见的共享变量的值都将立即变得对读线程 B 可见。

下面对于voatile的写和读的总结:

  • 线程 A 写一个 volatile 变量,实质上是线程 A 向接下来将要读这个 volatile 变量的某个线程发出了(其对共享变量所在修改的)消息。
  • 线程 B 读一个 volatile 变量,实质上是线程 B 接收了之前某个线程发出的(在写这个 volatile 变量之前对共享变量所做修改的)消息。
  • 线程 A 写一个 volatile 变量,随后线程 B 读这个 volatile 变量,这个过程实质上是线程 A 通过主内存向线程 B 发送消息。
JSR-133为什么要增强volatile的内存语义

在 JSR-133 之前的旧 Java 内存模型中,虽然不允许 volatile 变量之间重排序,但旧的 Java 内存模型允许 volatile 变量与普通变量之间重排序。在旧的内存模型中,VolatileExample 示例程序可能被重排序成下列时序来执行:
在这里插入图片描述在旧的内存模型中,当 1 和 2 之间没有数据依赖关系时,1 和 2 之间就可能被重排序(3 和 4 类似)。其结果就是:读线程 B 执行 4 时,不一定能看到写线程 A 在执行 1 时对共享变量的修改。
因此在旧的内存模型中 ,volatile 的写 - 读没有监视器的释放 - 获所具有的内存语义。为了提供一种比监视器锁更轻量级的线程之间通信的机制,JSR-133 专家组决定增强 volatile 的内存语义严格限制编译器和处理器对 volatile 变量与普通变量的重排序,确保 volatile 的写 - 读和监视器的释放 - 获取一样,具有相同的内存语义。从编译器重排序规则和处理器内存屏障插入策略来看,只要 volatile 变量与普通变量之间的重排序可能会破坏 volatile 的内存语意,这种重排序就会被编译器重排序规则和处理器内存屏障插入策略禁止。

五、锁的内存语义

锁的 释放-获取 建立的happens-before关系

锁是Java并发编程中最重要的同步机制。锁除了让临界区互斥执行外,还可以让释放锁的线程向获取同一锁的线程发出消息。
下面是锁释放 - 获取的示例代码:

class MonitorExample {

    int a = 0;
    public synchronized void writer() {  //1
        a++;                             //2
    }                                    //3
    public synchronized void reader() {  //4
        int i = a;                       //5
    }                                    //6
}

假设线程 A 执行 writer() 方法,随后线程 B 执行 reader() 方法。根据 happens before 规则,这个过程包含的 happens before 关系可以分为两类:

  • 根据程序次序规则,1 happens before 2, 2 happens before 3; 4 happens before 5, 5 happens before 6。
  • 根据监视器锁规则,3 happens before 4。
  • 根据 happens before 的传递性,2 happens before 5。
    在这里插入图片描述上图表示在线程 A 释放了锁之后,随后线程 B 获取同一个锁。在上图中,2 happens before 5。因此,线程 A 在释放锁之前所有可见的共享变量,在线程 B 获取同一个锁之后,将立刻变得对 B 线程可见。

六、happens-before

happens-before是JMM最核心的概念。对应java程序员来说,理解happens-before是理解JMM的关键。

JMM的设计

对于JMM的设计,需要考虑不同的关键因素:

  • 程序员对内存模型的使用。程序员希望内存模型已于理解、易于编程。程序员希望基于强内存模型来编写代码。
  • 编译器和处理器内存模型的实现。编译器和处理器希望内存模型对于它们的约束越少越好,这样可以很灵活,提高性能。所以编译器和处理器希望一个弱内存模型

因此,JMM就是为了寻找一个平衡点,一方面为程序员提供足够强的可见性保证;另一方面,对编译器和处理器的限制尽可能的少。下面看看JSR-133如何实现这一目标。

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

在这个代码证中,A happens-before B; B happens-before C; A happens-before C。
在这3个happens-before关系中,第二个和第三个是必须的,第一个不是必须的。因此,JMM把happens-before要求禁止的重排序分为以下两类:

  • 会改变程序结果的重排序
  • 不会改变程序结果的重排序
    在这里插入图片描述从上面可以看出:
  • JMM向程序员提供的happens-before规则能满足程序员的需求。而且也能保证内存的可见性。
  • JMM对于处理器和编译器的约束尽可能的少。JMM在遵循一个原则:只要不改变程序的执行结果(指的是单线程或正确同步的多线程),编译器和处理器怎么优化都行,可以提高程序的执行效率。
happens-before的定义

JSR-133使用happens-before的概念来指定两个操作之间的执行顺序。 由于这两个操作可以是在一个线程内,也可以在不同的线程之间。因此JMM通过happens-before关系向程序员提供跨线程的内存可见性(如果A线程写操作a与B线程读操作b之间存在happens-before关系,尽管a操作和b操作在不同的线程中执行,但JMM向程序员保证操作将对b可见)
《JSR-133 Java Memory Model and Thread Specification》对happens-before关系的定义如下:

  • 如果一个操作happens-before另一个操作,那么第一个执行的结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。
  • **两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须按照happens-before关系指定的顺序来执行。如果重排序执行之后的结果,与按照happens-before关系来执行的结果一致,那么这个重排序是合法的。**这是JMM对编译器和处理器重排序的约束原则,只要不改变程序的语义,无论怎么重排序都行。因此,happens-before关系本质上和as-if-serial语义一回事。

其中:1) as-if-serial语义保证单线程内程序的执行结果不被改变,happens-before关系保证正确同步的多线程程序的执行结果不被改变; 2)as-if-serial语义让编写单线程程序的人认为:程序是按照顺序执行的。happens-before给编写正确同步的多线程的人认为:正确同步的多线程程序是按照happens-before指定的顺序执行的。其实两者这么做的目的都是为了在不改变程序正确运行的前提下,提升程序执行的并行度。

happens-before规则

《JSR-133 Java Memory Model and Thread Specification》对happens-before定下了如下规则:

  • 程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。
  • 监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。
  • volatile变量规则:对于一个volatile域的写,happens-before于任意后续对这个volatile域的读。
  • 传递性:A happens-before B,且B happens-before C,则B happens-before C。
  • start()规则:如果线程A执行操作Thread.start(),那么A线程的Thread.start()操作happens-before于线程B中的操作。
  • join()规则:如果线程A执行操作Thread.join()并成功返回,那么线程B中的任意操作happens-before于线程A从Thread.join()操作成功返回。

start()规则。假设线程A在执行的过程中,通过执行ThreadB.start()来启动线程B;同时,假设线程A在执行ThreadB.start()之前修改了一些共享变量,线程B在开始执行后会读取这些共享变量。对应的happens-before关系图为:

Alt
join()规则。假设线程A在执行过程中,通过执行ThreadB.join()来等待线程B终止;同时,假设线程B在终止之前修改了一些共享变量,线程A从ThreadB.join()返回后会读取这些共享变量。对应的happens-before关系图为:
在这里插入图片描述在图中,2 happens-before 4由join()规则产生;4 happens-before 5由程序顺序规则产生。根据传递性规则,2 happens-before 5。这意味着,线程A执行操作ThreadB.join()并成功返回后,线程B中的任意操作将对线程A可见。

JMM 的内存可见性保证

Java 程序的内存可见性保证按程序类型可以分为下列三类:

  • 单线程程序。单线程程序不会出现内存可见性问题。编译器,runtime 和处理器会共同确保单线程程序的执行结果与该程序在顺序一致性模型中的执行结果相同。
  • 正确同步的多线程程序。正确同步的多线程程序的执行将具有顺序一致性(程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同)。这是 JMM 关注的重点,JMM 通过限制编译器和处理器的重排序来为程序员提供内存可见性保证。
  • 未同步 / 未正确同步的多线程程序。JMM 为它们提供了最小安全性保障:线程执行时读取到的值,要么是之前某个线程写入的值,要么是默认值(0,null,false)。

下图展示了这三类程序在 JMM 中与在顺序一致性内存模型中的执行结果的异同:
在这里插入图片描述只要多线程程序是正确同步的,JMM 保证该程序在任意的处理器平台上的执行结果,与该程序在顺序一致性内存模型中的执行结果一致。

【参考文献】( https://www.infoq.cn/profile/1278512)

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值