吃透Java内存模型

首先强调,Java内存模型(JMM)和Java运行时数据区的别搞混了。Java内存模型不是介绍内存是怎么分配的,Java的多线程并发依赖Java内存模型。Java运行时数据区才是将内存分成了哪些部分,各部分分别放的是社么。JMM大概点可以分为基本的模型、3个同步原语的内存语句、Java内存模型中的重排序和顺序一致性内存模型等几块,理解了他们也就理解就JMM。

内存模型:为在特定的操作协议下,对特定的内存或高速缓存进行读写访问的过程抽象。不同的硬件之间的内存模型有些许差异,Java内存模型屏蔽各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果。

通信和同步:在并发编程中,线程间的通信有两种模型:共享内存和消息传递。共享内存是指线程间共享,通过读写内存中的共功状态来实现隐式通信,消息传递线程间没有共享状态,线程间必须通过发送接受消息来进行显示通信。共享内存的同步是显示进行的,必须显示的指定某个方法或者某段代码在程序之间互斥执行,而消息传递的同步是隐式执行的,消息的发送一定在消息的接受之前嘛。Java采用的是共享内存模型,隐式通信,显示同步。

共享模型:简单理解就是有一个主内存,每个线程有自己的工作内存。线程工作时先从主内存中把需要的数据拷贝到工作内存中,然后线程从工作内存中读取相关数据进行处理。随后将处理完成的数据从工作内存写回到主内存中。这样当另一个线程从内存中读取数据时,得到的就是之前线程处理过的,两个线程之间完成了通信(隐式通信)。但如何确保线程从主内存中读取的先后顺序,比如后面的线程一定是等之前的线程将数据处理完之后再从主内存读取,这需要在程序中显示的指定互斥执行(显示同步)。

在这里插入图片描述
其实工作内存并不是一块真正的内存,它是缓存、写缓冲区、寄存器以及其他的硬件和编译器优化等等一系列的抽象。在硬件层面,真正流程是下图:
在这里插入图片描述
主内存中存放的数据应该是线程间共享的。这里的主内存对应于Java堆中的对象实例数据部分,在Java中,所有实例域、静态域和数组元素都存放堆内存中,堆内存在线程间共享。而局部变量表和方法参数等是线程私有的,并不会被共享,局部变量表和方法参数是分配在虚拟机栈上的,并不是堆。

高速缓存:处理器首先需要先读取数据,然后才能进行各种运算。但是处理器的速度要比内存的速度高出几个数量级,要是从内存读一个数然后处理器运算一个指令,处理器的时间都被浪费在等待内存上面了。所以现代计算机系统都不得不加入一层或多层读写速度尽可能接近处理器运算速度的高速缓存(Cache)来作为内存与处理器之间的缓冲:将运算需要使用的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存之中,这样处理器就无须等待缓慢的内存读写了。

工作内存概念不仅有高速缓存,还包括寄存器、硬件和编译器优化等待。

可见性在并发编程中,会出现各种各样的可见性问题。简单的说就是程序员认为的逻辑,和代码真正执行的逻辑并不一致,就会造成可见性问题。程序员不断的检查自己程序逻辑,程序逻辑正确但是运行结果却错误,造成各种认为的“玄学”问题。而只有真正了解计算机是如何处理代码的,才能破解“玄学”。

可见性其实就是一个线程执行的结果,其他的线程是否可以正确的访问到。假设线程A处理完后,将结果放入自己的工作内存,但并没有从工作内存刷回到主内存,其他线程是看不到这个结果的。或者在线程A执行之前,其他线程就去主内存中查看,这种肯定也看不到。JMM 通过控制主内存与每个线程的本地内存之间的交互,来为 Java
程序员提供内存可见性保证。

重排序

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

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

从源代码到最后执行的指令需要分别经历三次排序
在这里插入图片描述
1 属于编译器重排序,2 和 3 属于处理器重排序。对于编译器,JMM 的编译器重排序规则会禁止特定类型的编译器重排序(不是所有的编译器重排序都要禁止)。对于处理器重排序,JMM 的处理器重排序规则会要求 Java 编译器在生成指令序列时,插入特定类型的内存屏障,通过内存屏障指令来禁止特定类型的处理器重排序。

不同的编译器和处理器的重排选择是不同的,但JMM 属于语言级的内存模型,它确保在不同的编译器和不同的处理器平台之上,通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。有了JMM,程序员只要思考对JMM是否能正常工作即可。JMM也是Java语言跨平台的重要支撑。

数据依赖性:如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。数据依赖分为 写后读、写后写、读后写三种情况(只要前后任意操作涉及到写,就存在数据依赖性),只要重排序两个操作的执行顺序,程序的执行结果就会被改变。编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。但此处的数据依赖性只是单个处理器和单个线程中的操作,不同处理器和不同线程之间的数据依赖不被编译器和处理器考虑。

as-if-serial 语义:不管怎么重排序,单线程程序的执行结果不能被改变。各种重排序必须遵守as-if-serial 语义。比如上面的数据依赖性,并不会重排存在数据依赖性的操作,因为这样会改变结果。单线程程序无论怎样重拍,最后的结果一定是正确的。as-if-serial很好理解,单线程如果都不能保证,那整个程序就乱套啦。它使单线程程序员无需担心重排序会干扰程序,也无需担心内存可见性问题。

但as-if-serial 隐含的意思时,只要重排序不影响最后的结果,那么你怎样重排都是可以的。单线程不会有影响,但多线程就会出现可见性问题。此时就需要正确的同步。

程序顺序规则
A先于B,B先与C,则A先与C。

顺序一致性模型

顺序一致性模型是一种理想化理论参考模型:

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

JMM并没有实现顺序一致模型,因为那样的成本太大了,各种优化措施都不能用。JMM只保证:如果程序是正确同步的,程序的执行将具有顺序一致性-----即程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同。
注意是执行结果相同,并不是按照一致性执行,JMM允许重排序且只有在正确同步下才能保证可见性。对于未同步或未正确同步的多线程程序,JMM 只提供最小安全性。

顺序一致性模型中,所有操作完全按程序的顺序串行执行。 JMM中,临界区内的代码可以重排序(但是不允许临界区内的代码“逃逸”,那样会破坏互斥性,并不是正确的同步)。JMM 会在退出临界区和进入临界区这两个关键时间点做一些特别处理,使得线程在这两个时间点具有与顺序一致性模型相同的内存视图。因为同步的互斥性,使得其他线程根本看不都临界区内重排序,线程只能在公共节点出看到相同的内存视图。
在这里插入图片描述
JMM的基本方针:在不改变正确同步的程序执行结果前提下,尽可能地为编译器和处理器的优化打开方便之门。

未正确同步的程序,JMM提供最小安全性:线程读取到的值,要么是默认值(0,null等),要么是其他线程写入的值。注意,保证其他是其他线程写入的值并不是说这个值就是正确的,最小安全性只是确保读到的值不是胡乱产生的而已。假设64位数据,先写入低32位后,此时其他线程来读取此值,此种情况虽然读取到的64位数不正确(一半写入一半没写),但是依然符合最小安全性。因为即使这个64位数是拼凑起来的,但它也是之前的值(以前线程写入)和后32位(现在线程写入),都是由线程写入的,并不是胡乱产生的。

JMM 不保证未同步程序的执行结果与该程序在顺序一致性模型中的执行结果一致。不同步JMM认为这两个线程间没有关系,各自执行优化。

volatile 的内存语义

volatile 变量自身具有下列特性:
可见性。对一个 volatile 变量的读,总是能看到(任意线程)对这个 volatile 变量最后的写入。
原子性:对任意单个 volatile 变量的读/写具有原子性,但类似于 volatile++这种复合操作不具有原子性。

volatile 写的内存语义:当写一个 volatile 变量时,JMM 会把该线程对应的本地内存中的共享变量值刷新到主内存。
volatile 读的内存语义:当读一个 volatile 变量时,JMM 会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量。

从内存语义的角度讲,volatile写和释放锁具有相同的内存效果,把本线程处理的共享变量的刷回到主内存,其他线程便可看见。volatile读和获取锁具有相同的内存效果,从主内存中读取最新的共享变量。

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

volatile 内存语义的实现

对于主内存和工作内存之间的交互,JMM规定了8种操作:

lock(锁定):作用于主内存的变量,它把一个变量标识为一条线程独占的状态。
·unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量 才可以被其他线程锁定。
·read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以 便随后的load动作使用。
load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的 变量副本中。
·use(使用):作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚 拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收的值赋给工作内存的变量, 每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
·store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随 后的write操作使用。
·write(写入):作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的 变量中。

同时还有许多规则,比如unlock必须在lock之后,read和load、store和write两两成对,不允许单独出现等等。和volatile相关的只需要记住两个,load和store。标注volatile变量会限制一部分重排序:
在这里插入图片描述
1.第二个操作是volatile写,不管第一个操作是啥都不允许重排序。确保 volatile 写之前的操作不会被编译器重排序到 volatile 写之后
2.第一个操作时volatile读,不管第二个操作是啥都不允许重排序。确保 volatile 读之后的操作不会被编译器重排序到 volatile 读之前。
3.第一个是volatile写第二个是volatile读不允许重排序。

为了实现volatile限制重排序的功能,编译器在生成字节码时会插入内存屏障来静止特定类型的处理器重排序。而因为每个处理器重排序规则都不一样,JMM采取了保守策略插入内存屏障,保证JMM在不同处理器上最后重拍限制都相同。

在每个 volatile 写操作的前面插入一个 StoreStore 屏障
在每个 volatile 写操作的后面插入一个 StoreLoad 屏障
在这里插入图片描述

在每个 volatile 读操作的后面插入一个 LoadLoad 屏障
在每个 volatile 读操作的后面插入一个 LoadStore 屏障
在这里插入图片描述
并不是严格的要求必须插入这些内存屏障,上述只是最保守的策略。在实际执行时,只要不该改变volatile写-读的内存语义,编译器可以优化掉不必要的内存屏障。

上面提到,store和load分别是存储到主内存和从主内存读取。在内存屏障的两侧,各有一些数据,内存屏障会保证在执行后面的执行之前,之前的相关指令已经全部完成。比如storestore屏障,在后面数据刷回到主内存之前,前面的数据已经全部刷回主内存。LodaStore屏障保证在后面数据刷回到主内存之前,已经从主内存中载入数据。
在这里插入图片描述
锁的内存语义
当线程释放锁时,JMM 会把该线程对应的本地内存中的共享变量刷新到主内存中。当线程获取锁时,JMM 会把该线程对应的本地内存置为无效。从而使得被监视器保护的临界区代码必须从主内存中读取共享变量。锁的实现也采用了 volatile内存语义。

在ReentrantLock 中,依赖于AQS框架实现锁,分为公平锁和非公平锁。
公平锁语义的实现:加锁方法首先读 volatile 变量 state(AQS的state),在释放锁的最后写 volatile 变量 state.
非公平锁的实现:在非公平锁时,会采用CAS设置state.CAS具有 volatile 读和写的内存语义.

final内存语义

对于 final 域,编译器和处理器要遵守两个重排序规则。

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

final内存语义实现

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

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

final为引用类型(前提为final引用不能从构造函数溢出):
在构造函数内对一个 final 引用的对象的成员域的写入,与随后在构造函数外把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。

happens-before

happens-before 是 JMM 最核心的概念
对于程序员:JMM 向程序员提供的 happens-before 规则能满足程序员的需求,也向程序员提供了足够强的内存可见性 保证。
对于处理器和编译器:只要不改变程序的执行结果(指的是单线程程序和正确同步的多线程程序),编译器和处理器怎么优化都行。例如锁消除和volatile消除。

定义: 此处的定义分别呼应上面的两点
1.如果一个操作 happens-before 另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。
2.两个操作之间存在 happens-before 关系,并不意味着 Java 平台的具体实现必须要按照 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 中的任意操作。
    join()规则:如果线程 A 执行操作 ThreadB.join()并成功返回,那么线程 B 中的任意操作 happens-before 于线程 A 从 ThreadB.join()操作成功返回。
  • 4
    点赞
  • 24
    收藏
    觉得还不错? 一键收藏
  • 4
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值