【Java】【并发编程】详解Java内存模型

举报
huahua.Dr 发表于 2021/09/24 23:26:29 2021/09/24
【摘要】 一、什么是JMM内存模型Java内存模型即 Java Menory Model,简称JMM。JMM定义了Java虚拟机(JVM)在计算机内存(RAM)中的工作方法。JVM是整个计算机虚拟模型,所以JMM隶属于JVM的。Java内存模型定义了多线程之间共享变量的可见性以及如何在需要的时候对共享变量进行同步。原始的Java内存模型效率并不是很理想,因此在Java1.5版本对其进行了重构,现在的J...

一、什么是JMM内存模型

Java内存模型即 Java Menory Model,简称JMMJMM定义了Java虚拟机(JVM)在计算机内存(RAM)中的工作方法。JVM是整个计算机虚拟模型,所以JMM隶属于JVM的。

Java内存模型定义了多线程之间共享变量的可见性以及如何在需要的时候对共享变量进行同步。原始的Java内存模型效率并不是很理想,因此在Java1.5版本对其进行了重构,现在的Java8仍沿用了1.5的版本。 模型如下:

计算机生成了可选文字:
作存〔共
JAVA*EA
怍内存〔只旱
JAVASE:B
作丙存〔共早
JAVA*tæc
芏内存(共壹量冫

二、Java内存模型与并发编程的关系

如果想要深入了解Java并发编程,就要先理解好Java内存模型。

并发编程的模型分类

总共分成两类:

  1. 共享内存并发模型
  2. 消息传递并发模型

在并发编程中的关键问题

    1. 线程之间如何通信
    2. 线程之间如何同步

通信是指线程之间以何种机制来交换信息,在命令式编程中(编程主要分类:允许有副作用的命令式编程,不允许有副作用的函数式编程和不描述操作执行顺序的声明式编程),线程之间的通信机制有两种:

  1. 共享内存:在共享内存的并发模型里,线程之间共享程序的公共状态(共享变量),线程之间通过写-读内存中的公共状态来隐式进行通信。
  2. 消息传递:在消息传递的并发模型里,线程之间没有公共状态,线程之间必须通过明确的发送消息来显式进行通信

同步是指程序用于控制不同线程之间操作发生相对顺序的机制,有两种方式:

  1. 共享内存:同步是显式进行的。程序员必须显式指定某个方法或某段代码需要在线程之间互斥执行。
  2. 消息传递:由于消息的发送必须在消息的接收之前,因此同步是隐式进行的。

Java里面的并发就是采用共享内存的并发模型,Java线程之间的通信总是隐式进行,整个通信过程对程序员是完全透明的(即你是看不见就发生了并发过程)。

Java的并发模型采用的是共享内存模型

Java线程之前的通信总是隐式进行的,整个通信过程对程序员完全透明。如果编写多线程程序不理解隐式进行的线程之间通信的工作机制,很可能会遇到各种奇怪的内存可见性问题。

三、Java内存模型的抽象

Java中的共享变量有:所有实例域和数组元素存储在堆内存中,堆内存在线程之间共享(方法区也是线程共享,方法区保存类信息【类名称,方法,字段属性】,常量和静态变量 。局部变量、方法定义参数和异常处理器参数不会再线程之间共享,他们不会有内存可见性的问题,也不受内存模型的影响。

Java线程之间的通信由Java内存模型(JMM)控制。JMM决定了一个线程对共享变量的写入时对另一个线程可见。从抽象的角度来看,JMM定义了线程与主内存之间的抽象关系,线程之间的共享变量存储在主内存中,每一个线程都有一个自己私有的本地内存,本地内存中存储了该变量以读/写共享变量的副本。本地内存是JMM的一个抽象概念,并不是真实存在。

JMM抽象模型图:

一 000000 过 0 思 0 
本 地 内 存 8 
主 内 存

从图上看,如果线程A要和线程B通信的话,所经历的步骤是:

  1. 线程A需要将本地内存A中的共享变量副本刷新到主内存中去
  2. 线程B去主内存中读取线程A之前已经更新过的共享内存

步骤图:

뵤C싀특%호흏「국E殭Ⅰ띨~ : 二들毛 
9彗테N睾 
: -들毛 
V豎罼M睾

整体看,这两个步骤实质上是线程A在向线程B发送消息,而这个通信过程必须经过主内存。JMM通过控制主内存与每个线程的本地内存之间的交互,来为我们提供内存的可见性保证。

重排序(带来的并发问题)

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

  1. 编译器优化的重排序(编译器重排序)。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序
  2. 指令级并行的重排序(处理器重排序)。现代处理器 采用了指令级并行技术将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应及其指令的执行顺序。
  3. 内存系统的重排序(处理器重排序)。由于处理器使用缓存和读写缓存,这使得加载和存储操作看上去可能是在乱序执行。

上面的重排序可能会导致多线程程序出现内存可见性问题,对于编译器,JMM的编译器重排序规则则会禁止特定类型的编译器重排序(并不是所有的编译器重排序都要禁止),对于处理器重排序,JMM的处理器重排序规则会要求Java编译器在生成指令序列时,插入特定类型的内存屏障指令,通过内存屏障来禁止特定类型的处理器重排序(不是所有的处理器重排序都要禁止)。

JMM属于语言级的内存模型,它确保在不同的编译器和不同处理器平台上,通过禁止特定类型的编译器重排序和处理器重排序,为我们提供一致的内存可见性保证。还有就是重排序(包括编译器和处理器重排序)必须遵守as-if-serial规则(解决编译器重排序问题),该语义也就是说不管怎么排序,单线程程序执行的结果都不能改变;让人感觉单线程程序是按程序的顺序执行的,如果多个线程操作之间没有数据的依赖性则是允许重排序的,但是如果存在数据的依赖,则不会重排序的,这一点的问题是有保证的,所以现在的主要问题是,处理器使用缓存来读写数据,会导致数据读取不一,带来一种代码指令被重排序的感觉,对于共享变量,很容易出现问题。

编译器重排序

编译器重排序的定义为:如果两个操作它们之间没有任何的依赖关系,也就是说A操作的结果和B操作的结果相互间没有任何的影响,此时编译器就可以对这两个操作进行重排序,如果两个操作共同操作一个共享变量,其中有一个操作为写,那么它们两是有数据依赖性的,从重排序会对最终执行结果产生影响,所以编译器重排序(也包括处理器重排序)都会遵循数据依赖性,编译器和处理器不会改变存在依赖关系的两个操作的执行顺序

处理器重排序

现在的处理器使用写缓冲区来临时保存向内存写入的数据。写缓存区可有保证指令流水想般持续运行,它可以避免由于处理器停顿下来等待向内存写入数据而产生的延迟,同时通过批处理方式刷新写缓存区,以及合并写缓存区中对同一内存地址的多次写,可以减少对内存总线的占用,。虽然写缓存区有这么多的好处,但是每个处理器上的写缓存区,仅仅对它所在的处理器可见,这个特性会对内存操作的执行顺序产生重要的影响:处理器对内存的读写操作的执行顺序,不一定与内存实际发生的读写操作顺序是一致的。

举个例子:ab变量为共享变量,两个处理器项目访问共享变量。

处理器A

处理器B

执行的代码:

a=1; //A1

x=b;//A2

执行的代码:

b=2;//B1

y=a;//B2

 初始状态:a=b=0;

处理器允许执行后可能得到的结果:x=y=0;

具体原因如下图:

41 
ЧЕХ 
4...42 
82

出现的一个执行顺序:

处理器A和处理器B同时把共享变量写入在写缓冲区中(A1B1步骤),然后再从主内存中读取另一个共享变量的值(A2,B2步骤),最后才把自己写缓冲区中保存的脏数据刷新到主内存中(A3,B3步骤)。但最后执行下来就会得到一个结果:x=y=0

从实际理想的发生的顺序来看,正常执行顺序是这样的:

直到处理器A执行了A3步骤之后已经刷新自己的写内存,写操作A1才算真正被执行,然后接着读A2。即发生的顺序:A1->A2.

但内存操作实际发生这种可能:A2-->A1。此时处理器A的内存操作顺序被重排序了。由于写缓存区仅对自己的处理器可见,它会导致处理器执行内存操作的顺序可能会与内存实际的操作执行顺序不一样。由于现在的处理器都会使用写缓存区,因此都会允许对读写操作指令进行重排序。

内存屏障指令(解决处理器重排序问题)

为了解决上面说的重排序问题需要保证内存的可见性,可以使用内存屏障来达到这个效果,通过在适当位置插入内存屏障指令来禁止特定类型的处理器重排序。JMM把内存屏障指令分为以下四类:

LoadLoad屏障

适用场景:Load1;LoadLoad;Load2

Load1Load2代表两条读指令。在Load2读取指令装载之前,确保Load1读指令已经装载完毕。

StoreStore屏障

适用场景:Store1;StoreStore;Store2

Store1Store2代表两条写指令。在Store2写指令的存储之前(刷新到内存中),确保Store1写指令的数据对其他处理器可见(刷新到内存中)

LoadStore屏障

适用场景:Load1LoadStoreStore2

Store2写指令的存储之前(刷新到内存中),确保Load1读指令已经装载读取完毕。

StoreLoad屏障

使用场景:Store1;StoreLoad;Load2

Load2读指令装载之前,确保Store1写指令的数据对其他处理器可见(刷新到内存中),开销最大;该屏障会使之前的所有内存访问指令(存储和状态指令)完成之后,才执行该屏障之后的内存访问指令。

happens-before规则(定义两个操作之间的执行顺序,利用内存屏障指令提供内存可见性的保障)

JSR-133内存模型使用happens-before的概念来阐述操作之间的内存可见性。在JMM中,如果一个操作执行的结果需要对另外一个操作可见,那么这两个操作之间必须要存在happens-before关系。这里提到的两个操作可以是一个线程内的,也可以是不同线程之内的。

与程序员密切相关的happens-before规则(共有八大规则)如下:

注意,两个操作之间具有happens-before关系,并不意味这一个操作必须在后一个操作之前执行,happens-before仅仅要求前一个操作(执行的结果)对后一个操作可见。且前一个操作(执行的结果)按顺序排在第二个操作之前。

  1. 程序顺序规则:两个操作之间存在happens-before关系,那么第一个操作的结果对第二个操作可见并且第一个操作的执行顺序在第二个操作之前。
  2. 监视器锁规则:对于一个监视器的解锁,happens-before于随后这个监视器的加锁。
  3. volatile变量规则:对于一个volatile域的写,happens-before于任意后续对这个volatile域的读。
  4.  传递性规则:如果 A  happens-before B,且B happens-before C,则A happens-before C.
  5. start()规则:如果线程A执行操作ThreadB.start()(启动线程B),那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作。
  6. join()规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回。
  7. 程序中断规则:对线程interrupted()方法的调用先行于被中断线程的代码检测到中断时间的发生。
  8. 对象finalize规则:一个对象的初始化完成(构造函数执行结束)先行于发生它的finalize()方法的开始。

总结一下

解决重排序问题主要两种规则:as-if-serial解决编译器重排序)和happens-before解决处理器重排序)

  1. As-if-serial规则保证单线程程序的执行结果不被改变,happens-before规则保证正确同步的多线程程序的执行结果不会被改变
  2. as-if-serial规则给我们一种感觉:单线程程序是按照程序顺序来执行的,而happens-before规则是正确同步的多线程程序是按照happens-before指定的顺序来执行的,
  3. as-if-serial和happens-before规则都是为了在不改变程序执行结果的前提下,尽可能的提高程序的执行并行度。

 

四、JMM在实际开发中遇到的问题及解决方法

当对象和变量存储到计算机的各个内存区域时,必然会面临一些问题,其中最主要有两个问题:

  1. 共享对象对各个线程的可见性(使用volatile关键字解决
  2. 共享对象的竞争现象(使用同步锁解决

我们在实际的多线程开发中需要从原子性、可见性、有序性这三方面进行考虑,有序性的话JMM已经帮我们基本优化了,重点看一下原子性和可见性

共享对象的可见性(锁以synchronized为例)

当多个线程同时操作一个共享对象时,如果没有合理的使用volatile和synchronized关键字,一个线程对共享对象的更新有可能导致其他线程不可见。 我们的共享对象存储在主内存中,一个CPU的线程去读取主内存的数据到CPU缓存中,然后对共享内存做了更改,但CPU缓存中的更改后的对象还没有刷新到主内存中,此时其他线程对共享对象的更改是不可见的,最终每个线程都会拷贝共享变量位于不同的CPU缓存中。要解决这个可见性问题,我们可以使用Java volatile关键字,volatile关键字可以保证共享变量会直接从主内存中读取,而对共享变量也会直接写到主内存中去。volatile原理是基于CPU内存屏障实现的。

竞争现象(锁以sybchronized为例)

如果多个线程共享一个对象,它们同时修改这个共享对象,这就产生了竞争关系。例如线程A和线程B共享一个对象,线程A从主内存中读取共享对象到CPU缓存中,同时,线程B也同时读取共享对象到它的CPU缓存中,线程AB同时对该共享变量做相同的操作(如同时进行+1操作,对象初始值为1),不管线程AB有没有刷新到主内存中,并行执行,结果都会出错(相加了两次,结果却为2)。要解决这种竞争关系问题,我们可以使用java中的synchronized代码块,synchronized代码块可以保证同一时刻只有一个线程进入到代码竞争区,synchronized代码块也能保证代码块中所有变量都是从主内存中读,当线程退出的时候,对所有变量的更新都将会flush到之内存中,不管这些变量是不是volatile类型的。

volatile和锁(synchronizedLock

对于一个volatile变量的单个读/写操作,与对一个普通变量的读写操作使用同一个锁来同步,它们之间的执行效果时相同的(因为它们都是从主内存中读写变量的)。

相同点:

可见性锁的happens-before规则是保证释放锁和获取锁的两个线程之间的内存可见性,这跟volatile的可见性是一样的:对于一个volatile变量的读,总能看到(任意线程)对这个volatile变量最后的写入。

原子性。锁的语义(同步)就决定了临界区代码的执行具有原子性,跟volatile一样,对于volatile变量的读取也具有原子性,但是对于多个volatile操作(类似于volatile++这种复合操作)就不具备原子性。

(原子性就是在执行的过程中不会被中断,一次执行的,不会被其他线程干扰)

不同点:

volatile:读写内存定义

当读一个volatile变量的时候,JMM会把线程对应的本地内存置为无效,线程将从主内存中读取共享变量。

当写一个volatile变量的时候,JMM会把该线程对应的本地内存中的共享变量刷新到主内存中。

volatile语义的实现:

为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。

基于保守策略的JMM内存屏障插入策略:

  1. 在每个volatile写操作的前面插入一个StoreStore屏障
  2. 在每个volatile写操作的后面插入一个StoreLoad屏障
  3. 在每个volatile读操作的后面插入一个LoadLoad屏障
  4. 在每个volatile读操作的后面插入一个LoadStore屏障

因为确保写操作内存可见,所以前面的写和后面的读写都不能重排序(按照原来的顺序来执行)

因为确保别的线程读正确,所以后面的读写指令都不能重排序(必须确保volatile读完后才操作,为了可见性)

具体插入内存屏障后生成的指令示意图如下:

Volati 
LoadLoad* 
Sto reStore%cE 
VOI atil 
StoreLoa d 
V o I atile

这种volatile读写操作的内存屏障是非常保守的,在实际执行过程中,只要不改变volatile读写的定义,编译器可以根据具体情况省略不必要的屏障。

锁:锁释放和锁获取的内存定义

当线程释放锁时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存中。

当线程获取锁时,JMM会把该线程对应的本地内存置为无效,从而使得被监视器保护的临界区代码必须从主内存中读取共享变量。

锁内存定义的实现:

锁有很多,包括ReentrantLock,Synchronized,公平锁,非公平锁,AQS等等,现在借助ReentrantLock来说明一下锁内存定义的实现。

首先是concurrent包的实现:

如果我们仔细分析concurrent包的源代码实现,会发现一个通用化的实现模式:

  1. 首先声明共享变量为volatile
  1. 然后使用CAS的原子条件更新来实现线程之同步
  2. 同时配合以volatile的读写和CAS所具有的volatile读和写的内存语义来实现线程之间的通信
  3. AQS
  4. 非阻塞数据结构和原子变量类(java.util.concurrent.atomic包中的类)

这些concurrent包中的基础类都是使用这种模式来实现的,而concurrent包中的高层类又是依赖这些基础类来实现的。从整体来看,concurrent包的实现示意图如下:

井 发 容 
Lock 
VolatileZN

五、总结

JMM的设计示意图

J M M 的 设 计 示 意 圜 
程 序 员 基 于 
happens-before 规 
则 提 供 的 内 存 可 见 
JMM 
happens- 
before 
求 
止 
会 改 变 程 序 执 行 
结 果 的 重 排 序 
要 
要 
求 
求 
不 会 改 变 程 序 执 
行 结 果 的 重 排 序 
程 序 员 以 为 JMM 禁 
止 了 这 种 重 排 序 , 但 
实 际 上 」 MM 并 没 有

  1. JMM向程序员提供的happens-before规则能满足程序员的需求。JMM的happens-before规则不但简单易懂,而且也向程序员提供了足够强的内存可见性保证(有些内存可见性保证其实并不一定真实存在,比如上面的A happens-before B)。
  2. JMM对编译器和处理器的束缚已经尽可能少。从上面的分析可以看出,JMM其实是在遵循一个基本原则:只要不改变程序的执行结果(指的是单线程程序和正确同步的多线程程序),编译器和处理器怎么优化都行。例如,如果编译器经过细致的分析后,认定一个锁只会被单个线程访问,那么这个锁可以被消除。再如,如果编译器经过细致的分析后,认定一个volatile变量只会被单个线程访问,那么编译器可以把这个volatile变量当作一个普通变量来对待。这些优化既不会改变程序的执行结果,又能提高程序的执行效率。

 

Java程序的内存可见性保证

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

最后

从上面内存抽象结构来说,可能出在数据“脏读”的现象,这就是数据可见性的问题,另外,重排序在多线程中不注意的话也容易存在一些问题,比如一个很经典的问题就是DCL(双重检验锁),这就是需要禁止重排序,另外,在多线程下原子操作例如i++不加以注意的也容易出现线程安全的问题。但总的来说,在多线程开发时需要从原子性,有序性,可见性三个方面进行考虑。J.U.C包下的并发工具类和并发容器也是需要花时间去掌握的。

【版权声明】本文为华为云社区用户原创内容,转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息, 否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。