Java内存模型<一> _ 基础

目录

一、并发编程的关键问题

1. 线程间通信

2. 线程间同步

3. 总结

二、Java内存模型

三、指令重排序

1. 重排序分类

2. 数据依赖性

3. as-if-serial语义

四、内存屏障

1. CPU的写缓冲区

2. 内存屏障

五、顺序一致性内存模型

1. 顺序一致性

2. 顺序一致性内存模型

六、JMM的happens-before关系

1. JMM的设计

2. happens-before定义

3. happens-before规则

七、参考资料


一、并发编程的关键问题

        并发编程中,需要处理两个关键问题:线程之间如何通信、如何同步?

1. 线程间通信

        通信是指线程之间以何种机制来交换信息。分为两种通信机制:共享内存和消息传递。在共享内存并发模型里,线程之间有公共状态,通过写-读内存中的公共状态进行隐式通信;在消息传递的并发模型里,线程之间没有公共状态,线程之间必须通过发送消息来显式进行通信。

2. 线程间同步

        同步是指程序中用于控制不同线程间操作发生相对顺序的机制。在共享内存并发模型里,程序员必须显式指定某个方法或某段代码需要在线程之间互斥执行;在消息传递的并发模型里,由于消息的发送必须在消息接收之前,因此同步是隐式进行的。

3. 总结

关键问题共享内存并发模型消息传递并发模型
通信(交换信息机制)公共状态隐式通信显式通信
同步(数据同步机制)显式同步隐式同步
注意

1. Java并发采用共享内存并发模型,隐式通信

二、Java内存模型

        Java线程之间的通信由Java内存模型(JMM)控制,JMM决定一个线程对共享变量的写入何时对另一个线程可见。从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(Main Memory)中,每个线程都有一个私有的本地内存(Local Memory),本地内存中存储了该线程以读/写共享变量的副本。注意本地内存是JMM的一个抽象概念,并不真实存在(涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化)。JMM示意图如下所示。

        从上图看出,线程A和线程B通信的话,需要2步骤:

步骤1:线程A把本地内存A中更新过的共享变量刷新到主内存中去;

步骤2:线程B到主内存中去读取线程A之前已更新过的共享变量。

        上图所示,当线程A和线程B需要通信时,线程A首先会把自己本地内存中修改后的x值刷新到主内存中,然后发送消息给线程B,最后线程B接受消息后到主内存中去读取更新后的x值。整体上看,两线程通信必须经过主内存。所以,JMM通过控制主内存与每个线程的本地内存之间的交互,从而提供内存可见性保证。

        Java中,所有实例域、静态域、数组元素都存储在堆内存中,堆内存在线程之间共享,即:“共享变量”代指实例域,静态域和数组元素。局部变量(Local Variables)、方法定义参数(Formal Method Parameters)、异常处理器参数(Exception Handler Parameters)不会在线程之间共享,它们不会有内存可见性问题,也不受内存模型的影响。

三、指令重排序

1. 重排序分类

        指令重排序是指编译器和处理器为了优化程序性能而对指令序列进行重新排序。从Java源代码到最终实际执行的指令序列,会经历下面3种重排序,如下图所示。

总类型重排序分类说明
编译器重排序编译器优化的重排序1. 编译器不改变单线程程序语义的前提下排序
处理器重排序指令级并行的重排序

1. 采用指令级并行技术将多条指令并行执行

2. 不存在数据依赖性,则可以改变执行顺序

内存系统的重排序1. 由缓存和读/写缓冲区,导致加载和储存可能乱序执行

注意问题:

        1. 重排序可能导致多线程出现内存可见性问题;

        2. 编译器重排序规则会禁止特定类型的编译器重排序(如:volatile、synchronized);

        3. 处理器重排序会要求Java编译器在生成指令序列时,插入特定类型的内存屏障来禁止特定类型的处理器重排序;

        4. 经过编译器、处理器重排序后,可能与最终执行的指令顺序不一致(写缓冲区导致);

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

2. 数据依赖性

        两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。编译器和处理器重排序不会改变存在数据依赖关系的两个操作的执行顺序。下表所示是数据依赖性三种类型:

数据依赖类型

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

3. as-if-serial语义

        as-if-serial意思是:不管怎么重排序,单线程程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial。as-if-serial语义使单线程无需担心重排序会干扰他们,也无需担心内存可见性问题。

double r = 2.1;              //(1)

double pi = 3.14;          //(2)

double area = p * r * r; //(3)

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

四、内存屏障

1. CPU的写缓冲区

        CPU使用写缓冲区临时保存向内存写入的数据。其优点:1.保证指令流水线持续运行,避免由于处理器停顿下来等待向内存写入数据而产生的延迟;2. 批处理的方式刷新写缓冲区,以及合并写缓冲区中对同一内存地址的多次写,减少对内存总线的占用。

        每个处理器上的写缓冲区,仅仅对当前处理器可见。这个特性会对内存操作的执行顺序产生重要的影响:处理器对内存的读/写操作的执行顺序,不一定与内存实际发生的读/写操作顺序一致!为了保证内存可见性,Java编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序。

        如下表代码所示,运行结果可能是x=y=0。具体原因,如下图所示。

        这里处理器A和B可以同时把共享变量写入自己的写缓冲区(A1,B1),然后从内存中读取另一个共享变量(A2,B2),最后才把自己写缓存区中保存的脏数据刷新到内存中(A3,B3)。当以这种时序执行时,程序就可以得到x=y=0的结果。 

        从内存操作实际发生的顺序来看,直到处理器A执行A3来刷新自己的写缓存区,写操作A1才算真正执行了。虽然处理器A执行内存操作的顺序为:A1→A2,但内存操作实际发生的顺序却是A2→A1。此时,处理器A的内存操作顺序被重排序了。

2. 内存屏障

        由于CPU的写缓冲区的存在,导致了处理器对内存的读/写操作的执行顺序,不一定与内存实际发生的读/写操作顺序一致。在Java中为了保证内存可见性,Java编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序。

        内存屏障(Memory Barriers,Intel称之为Memory Fence),它是个抽象概念,目的是禁止处理器重排序。如下表所示,是内存屏障指令的分类。

内存屏障类型表

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

五、顺序一致性内存模型

1. 顺序一致性

        数据竞争指在一个线程中写一个变量,而另一个线程读同一个变量,而且写和读没有通过同步来排序。当程序未正确同步时,就可能会存在数据竞争,导致结果不同。

        执行正确同步的程序将具有顺序一致性(Sequentially Consistent),即:程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同。JMM对正确同步的多线程程序的内存一致性做了如上保证。同步关键字synchronized、volatile和final的正确使用。

2. 顺序一致性内存模型

        顺序一致性内存模型是理想化的理论参考模型,提供极强的内存可见性保证。所有线程的所有内存读/写操作串行化,即:所有操作之间具有全序关系。顺序一致性内存模型的特性:

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

        以下图所示,线程A和B看到的执行顺序都是:B1→A1→A2→B2→A3→B3,所有线程都只能看到一个一致的整体执行顺序。

         注意,JMM中不但整体的执行顺序是无序的,而且所有线程看到的操作执行顺序也可能不一致。比如,在当前线程把写过的数据缓存在本地内存中,在没有刷新到主内存之前,这个写操作仅对当前线程可见,只有当前线程把本地内存中写过的数据刷新到主内存之后,这个写操作才能对其他线程可见。

六、JMM的happens-before关系

1. JMM的设计

        在设计JMM时,需要考虑的因素:

  • 程序员对内存模型的使用:易于理解、易于编程;
  • 内存模型对编译器和处理器的束缚越少越好(弱内存模型)。

        如下图所示,JMM要遵循一个基本原则:只要不改变程序的执行结果(指的是单线程程序和正确同步的多线程程序),编译器和处理器怎么优化都行。 

2. happens-before定义

        happens-before是JMM最核心的概念,用来阐述两操作之间的内存可见性。其定义如下:

  • 两操作之间存在happens-before关系,前操作的执行结果将对后操作可见;
  • 两操作之间存在happens-before关系,并不意味着具体实现必须按照happens-before关系指定的顺序来执行。如果重排序之后的执行结果,与按happens-before关系来执行的结果一致,那么这种重排序JMM允许。

        as-if-serial语义保证单线程程序的执行结果不被改变,happens-before关系保证正确同步的多线程程序的执行结果不被改变。两者的目的,都是为了在不改变程序执行结果的前提下,尽可能地提高程序执行的并行度。

3. happens-before规则

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

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

七、参考资料

深入理解Java内存模型(一)——基础-InfoQ

Java内存模型(JMM)总结 - 知乎

全面理解Java内存模型_Heaven Wang 的专栏-CSDN博客_java内存模型

java 8大happen-before原则超全面详解 - 简书

聊聊内存屏障 - 知乎

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值