Java虚拟机札记-Java内存模型

什么是Java内存模型

Java内存模型(Java Memory Model,JMM),是Java虚拟机规范中的定义,用来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各个平台下都能达到一致的并发效果。

Java内存模型目标

定义程序中各个变量的访问规则,比如在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节。此处的变量包括实例字段、静态字段和构成数组对象的元素,但是不包括局部变量和方法参数,因为这些是线程私有的,不会被共享,所以不存在竞争问题。

主内存和工作内存
  • 所有的变量都存储在主内存。
  • 每个线程都有自己的工作内存,保存了被该线程使用到的变量的主内存副本拷贝。线程对变量的所有操作(读取、赋值)都必须在工作内存中进行,不能直接读写主内存的变量。
  • 不同的线程之间也无法直接访问对方工作内存的变量,线程间变量值的传递需要通过主内存。
    MarkdownPhotos/master/CSDNBlogs/JVM/12-2.png
    注意,这里讲的主内存、工作内存与Java内存区域中的Java堆、栈、方法区并不是同一层次的内存划分, 基本上没有关系。如果非要勉强对应起来,主内存对应Java堆中的对象实例数据,工作内存对应于虚拟机栈的部分区域。
内存之间的交互操作

一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存?Java内存模型定义了8种操作来完成。虚拟机实现时必须保证操作都是原子性的。

  • lock(锁定):作用于主内存的变量,把一个变量标识为一条线程独占的状态。
  • unclock(解锁):作用于主内存的变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
  • read(读取):作用于主内存的变量,把一个变量的值从主内存传输到线程的工作内存,以便随后的load动作使用。
  • load(载入):作用于工作内存的变量,把read操作从主内存中得到的变量值放入工作内存的变量副本中。
  • use(使用):作用于工作内存的变量,把工作内存中一个变量的值传递给执行引擎。
  • assign(赋值):作用于工作内存的变量,把执行引擎接收到的值赋给工作内存的变量。
  • store(存储):作用于工作内存的变量,把工作内存中一个变量的值传送给主内存中,以便随后的write操作使用。
  • write(写入):作用于主内存的变量,把store操作从工作内存中得到的变量的值放入主内存的变量中。

Java内存模型规定了执行上述八种操作时必须满足以下规则:

  • 不允许read和load、store和write操作之一单独出现,即不允许一个变量从主内存中读取了但工作内存不接受,或者工作内存发起了回写但主内存不接受的情况出现。
  • 不允许一个线程丢弃它的最近的assign操作,即变量在工作内存中改变了之后必须把该变化同步到主内存。
  • 不允许一个线程无原因地(没有发生任何assign操作)把数据从线程的工作内存同步到主内存。
  • 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量,换句话说,就是对一个变量use、store操作之前,必须先执行过了assign和load操作。
  • 一个变量在同一时刻只允许一个线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,多次执行lock后,只有执行相同次数的unclock操作,变量才会被解锁。
  • 如果对一个变量执行lock操作,那么会清空工作内存次变量的值,在执行引擎使用这个变量前,需要重新执行load或assign操作初始化变量的值。
  • 如果对一个变量执行lock操作,那么会清空工作内存次变量的值,在执行引擎使用这个变量前,需要重新执行load或assign操作初始化变量的值。
  • 对一个变量执行unclock之前,必须先把此变量同步回主内存中(执行store、write操作)。

8种内存访问操作和上述规则限定,再加上下面即将讲到的对volatile的特殊规定,就已经确定了Java程序中哪些内存访问操作在并发环境下是安全的。

对volatile变量的特殊规则

关键字volatile是Java虚拟机提供的最轻量级的同步机制。当一个变量被定义成volatile之后,具备两种特性:

  • 保证此变量对所有线程的可见性。可见性是指当一条线程修改了这个变量的值,新值对于其他线程是可以立即得知的。而普通变量做不到这一点。
  • 禁止指令重排序优化,保证有序性普通变量仅仅能保证在该方法执行过程中,得到正确结果,但是不保证程序代码的执行顺序。

为什么基于volatile变量的运算在并发下不一定是安全的?
volatile变量在各个线程的工作内存中不存在一致性问题(各个线程的工作内存中volatile变量,每次使用前都要刷新到主内存),但Java里面的运算并非原子操作,导致volatile变量的运算在并发下一样是不安全的。

使用volatile变量而不需要加锁的场景
由于volatile变量只能保证可见性,在不符合以下两条规则的运算中,我们依然要通过加锁来保证原子性。

  • 运算结果并不依赖于变量的当前值,或者只有单一的线程能修改变量的值;
  • 变量不需要与其他的状态变量一起参与状态约束;

禁止指令重排序优化
什么是指令重排序?
从硬件架构上讲,指令重排序是CPU采用了允许将多条指令不按程序规定的顺序分开发送给各相应电路单元处理。
为什么要指令重排序?
为了充分利用CPU。为了尽可能减少内存操作速度远慢于CPU运行速度所带来的CPU空置的影响,虚拟机可能会对指令的执行顺序做重排序,以充分利用CPU。
指令重排序的影响
在单线程环境下,指令重排序后程序的执行结果应当与其在顺序执行下的效果一致。在多线程环境下,可能会改变程序的执行结果。

为什么使用volatile?
在某些情况下,volatile同步机制的性能要优于锁(synchronized关键字),但是由于虚拟机对锁实行的许多消除和优化,所以很难量化性能比锁高了多少。
volatile变量读操作的性能消耗与普通变量几乎没有差别,但是写操作则可能慢一些,因为它需要在本地代码中插入许多内存屏障指令来保证处理器不发生乱序执行。

对volatile变量的特殊规则
假设T表示一个线程、V1和V2分别表示两个volatile类型变量,那么在进行read、load、use、assign、store和write操作时需要满足以下规则:

  • 只有当线程T对变量V1执行的前一个动作是load的时候,线程T才能对变量V1执行use动作;并且,只有当线程T对变量V1执行的后一个动作是use的时候,线程T才能对变量V1执行load动作。线程T对变量V1的use动作可以认为是和线程T对变量V1的load、read动作相关联,必须连续一起出现。这条规则要求在工作内存中,每次使用V1前都必须先从主内存刷新最新的值,用于保证能看见其他线程对变量V1所做的修改后的值。即使用变量:read->load->use
  • 只有当线程T对变量V1执行的前一个动作是assign的时候,线程T才能对变量V1执行store动作;并且,只有当线程T对变量V1执行的后一个动作是store的时候,线程T才能对变量V1执行assign动作。线程T对变量V1的assign动作可以认为是和线程T对变量V1的store、write动作相关联,必须连续一起出现。这条规则要求在工作内存中,每次修改V1后都必须立刻同步回主内存中,用于保证其他线程可以看到自己对变量V1所做的修改。即修改变量:assign->store->write
  • 假定动作A是线程T对变量V1实施的use或assign动作,假定动作F是和动作A相关联的load或store动作,假定动作P是和动作F相应的对变量V1的read或write动作;类似的,假定动作B是线程T对变量V2实施的use或assign动作,假定动作G是和动作B相关联的load或store动作,假定动作Q是和动作G相应的对变量V2的read或write动作。如果A先于B,那么P先于Q。这条规则要求volatile修饰的变量不会被指令重排序优化,保证代码的执行顺序与程序的顺序相同。
对long和double变量的特殊规则

Java内存模型要求lock、unlock、read、load、use、assign、store、write这八种操作都具有原子性。但对于64位的非volatile修饰的long和double型数据,Java内存模型允许虚拟机对其操作分为2次32位的操作,允许Java虚拟机不保证read、load、store和write是非原子的。这就是long和double的非原子性协议

由于非原子性协议,如果多个线程同时对未声明为volatile的变量进行读取和修改操作,可能某些线程读取到一个既不是原值,也不是被某个线程修改之后的值的代表了“半个变量”的数值。这种情况非常罕见,Java内存模型只是规定虚拟机不需要把这4种操作实现为原子性的,但允许虚拟机把这些操作实现为原子性的,而且还“强烈建议”虚拟机这样实现。所以现代商用的大多数虚拟机都会选择将其实现为原子操作,一般情况下不需要将将long和double变量声明成volatile的。

原子性、可见性和有序性

Java内存模型实际上是围绕着如何在并发操作中如何处理原子性、可见性、有序性这三个特征建立的。

原子性
对基本数据类型的访问和读写是具备原子性的。对于更大范围的原子性保证,Java内存模型提供了lock和unlock操作来满足需求。尽管Java虚拟机没有将lock和unlock直接给用户使用,但却提供了字节码指令monitorenter和monitorexit来隐式使用lock和unlock操作。这两个字节码指令反映到Java代码中就是同步块——synchronized关键字。因此synchronized块之间的操作也具有原子性。

可见性
当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。Java内存模型是通过在变量修改后将新值同步回主内存,在变量读取之前从主内存刷新变量值来实现可见性的。在Java中有三个关键字能保证可见性

  • volatile。与普通变量不同,volatile变量保证了新值能够立即同步到主内存,以及每次使用前立即从主内存刷新。
  • synchronized。由“对一个变量执行unclock之前,必须先把此变量同步回主内存中(执行store、write操作)”这条规则实现。
  • final。final修饰的字段在构造器中一旦被初始化完成,并且构造器没有把this的引用传递出去,那么其他线程中就能看见final字段的值。

有序性
Java程序的有序性可以总结为一句话:如果在本线程内观察,所有的操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的。前半句指线程内表现为串行的语义,后半句指指令重排序现象和工作内存与主内存同步延迟线性现象。Java中有两个关键字能保证有序性

  • volatile。volatile本身就包含了禁止指令重排序的语义。
  • synchronize。有“一个变量在同一时刻只允许一个线程对其进行lock操作”这条规则实现。
先行发生原则(Happens-before)

什么是先行发生原则
先行发生是Java内存模型中定义的两项操作之间的偏序关系。如果说操作A先行发生于操作B,其实就是说在发生操作B之前,操作A产生的影响被操作B察觉。

重要性
是判断是否存在数据竞争、线程是否安全的主要依据。

Java内存模型中的先行发生关系
下面是Java内存模型中天然的先行发生关系

  • 程序次序规则:同一个线程内,按照代码出现的顺序,前面的代码先行于后面的代码,准确的说是控制流顺序,因为要考虑到分支和循环结构。
  • 管程锁定规则:一个unlock操作先行发生于后面(时间上)对同一个锁的lock操作。
  • volatile变量规则:对一个volatile变量的写操作先行发生于后面(时间上)对这个变量的读操作。
  • 线程启动规则:Thread的start( )方法先行发生于这个线程的每一个操作。
  • 线程终止规则:线程的所有操作都先行于此线程的终止检测。可以通过Thread.join( )方法结束、Thread.isAlive( )的返回值等手段检测线程的终止。
  • 线程中断规则:对线程interrupt( )方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupt( )方法检测线程是否中断
  • 对象终结规则:一个对象的初始化完成先行于发生它的finalize()方法的开始。
  • 传递性:如果操作A先行于操作B,操作B先行于操作C,那么操作A先行于操作C。

Java无需任何的同步手段就能成立的先行发生关系。如果两个操作之间的关系不在此列,且无法通过上述关系推导出来,它们就没有顺序保障,虚拟机可以对它们随意地进行重排序。

本文就讲到这里。本文已收录于Java并发编程札记专栏

本文内容摘录或总结自《深入理解Java虚拟机 JVM高级特性与最佳实践》。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值