任脉修炼——JMM

什么是JMM?

​ JMM是一种定义jvm与各种硬件,操作系统与之间的内存访问规则的模型,即程序中各种变量的访问规则。关注虚拟机把变量值存储到内存和从内存中取出变量值这种底层的细节。这里的变量值,指的是共享变量,不是线程私有的变量。

为什么需要JMM?

  • 跨平台:跨平台是通过虚拟机进行的,不过不同平台上jvm和操作系统内存之间访问规则的兼容由JMM定义。
  • 并发:共享内存中数据的访问,换句话就是,虚拟机如何实现并发,需要JMM支撑。

JMM如何规定的内存间访问规则?

主内存和工作内存之间的访问规则

  • 所有的变量都存储在主内存(这个主内存是虚拟机内存的一部分,虚拟机所拥有的内存资源是操作系统分配给虚拟机进程的)。

  • 每一条线程又有自己的工作内存,线程的工作内存中保存了被该线程使用的变量的主内存副本(有点绕,看图)。

  • 线程对变量的所有操作,都必须在工作内存上进行,不能直接操作主内存中的数据。

  • 不同线程之间,工作内存私有,不可互相访问。

  • 线程之间的变量值传递,均需要通过主内存来完成。

JMM内存访问规则

例如:一个简单的读写流程(按照图上①②③④的标号依次进行,另外read,load等操作会在后文详细描述)

① 通过read,load操作,线程A将主内存的变量1值读取到,并且将值载入到工作内存A的变量1副本中。

② 通过use,线程A将工作内存A中的变量副本值传递给执行引擎。

③ 通过assign,store,线程A将变量1副本的值重新赋值并且将新值传送到主内存中。

④ 通过write操作,线程A对变量1副本操作后的新值会被主内存写入到变量1的值。

内存之间的交互操作规则

内存间交互操作规则定义了一个变量如何从主内存拷贝到工作内存,如何从工作内存再同步回主内存。

8种交互操作

操作描述作用范围含义
lock(锁定)主内存变量将一个变量标识为一条线程独占的状态
unlock(解锁)主内存变量将一个处于线程独占的变量释放出来。释放的变量才可以被其他线程锁定
read(读取)主内存变量将主内存变量的***值*** 从主内存传输到线程的工作内存中,以便load
load(载入)工作内存变量将read操作读取的 放入工作内存变量副本中
use(使用)工作内存变量将工作内存中的变量副本值传递给执行引擎
assign(赋值)工作内存变量将从执行引擎接受的值赋值给工作内存中变量副本
store(存储)工作内存变量将工作内存中变量副本的 传送到主内存,以便后续的write
write(写入)主内存变量将主内存的变量值写入从store操作中获取到的新值

示例:此示例是单个线程的读取变量到赋值变量的过程,多线程的话,如果锁定了,其他线程也是竞争不到这个锁的。
8种内存之间的交互操作

  • ① 线程A锁定主内存中的变量1,此时其他线程不能再访问变量1,且线程A的工作内存中的变量1副本值清空。

  • ② 通过read,线程A读取到变量1中的值,假设是“one”。读取的是值,不是整个变量。

  • ③ 通过load,线程A将读取到的变量1的值“one”放入线程A工作内存的变量1副本中,此时变量1副本的值是“one”。

  • ④ 当虚拟机遇到一个需要使用变量1的值的指令时,通过use,将变量1副本中的“one”值传递给执行引擎进行计算。

  • ⑤ 当虚拟机遇到一个给变量赋值的指令时,通过assign,将值赋值给变量1副本,比如是“two”,此时变量1副本中的值为“two”。

  • ⑥ 通过store,线程A将变量1副本中的新值“two”传送到主内存中。

  • ⑦ 通过write,将新值“two”写入变量1,此时变量1的值为“two”。

  • ⑧ 线程A释放对变量1的锁。其他线程可以访问变量1。

对8种交互操作的限制

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

对volatile变量的特殊规则

说起volatile,几乎可以脱口而出:它保证并发操作过程中线程的可见性和有序性。不保证原子性。可以说volatile是jvm提供的最轻量级的同步机制,但是不能说他是jvm保证线程安全的最轻的锁。

volatile的特性
  • 保证此变量对所有线程的可见性。

当一个线程修改了这个变量的值,其他线程能够立即得知(因为volatile要求线程修改了这个值之后立即写回主内存中,assign,store,write必须连续执行)。而普通变量不行,普通变量只能等那个线程把新值写回主内存之后,别的线程读取主内存才能得知,而这个等的过程中,assign和store之间并不是连续的。但是即使这样,也有可能是多个线程同时读到这个值,导致后面的写回的结果不正确。这也是在单例模式的一种写法中,即使写了volatile,写还是要上synchronized关键字的原因。

  • 禁止指令重排序。

普通的变量仅会保证赋值的正确结果,而不能保证变量赋值操作的顺序与程序代码中的执行顺序一致。而volatile修饰的变量,能够保证在方法执行的时候,对此变量的操作顺序与代码中的执行顺序一致。原因就是在对这个变量操作的时候,加了个内存屏障。而重排序的时候,不会把内存屏障之后的指令排到内存屏障之前。这也是在单例模式中的双重检查写法里面也要对单例的变量进行volatile修饰的原因(对象实例化的时候有3步操作)。

所以当一个变量被volatile修饰之后,上面所讲8种操作就又需要满足一些规则:

  • 对主内存变量的read,load,use操作必须是连续一起的。(不连续,那就不有序了)。也就是在use的时候,必须从主内存read和load进来新的值。
  • 对工作内存变量副本的assign,store,write必须连续且一起出现。也就是要求了,每次修改了一个变量之后,比如立刻同步回主内存。保证其他县城可以看到自己对变量所做的修改。
  • 禁止指令重排序,保证指令和代码的执行顺序一致。
volatile关键字的适用场景
  • 运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值。
  • 变量不需要与其他的状态变量共同参与不变约束。

例如:下面代码段

volatile boolean shutdownRequested;

public void shutdown(){
    shutdownRequested = true;
}

public void doWork(){
    while(!shutdownRequested){
        // do something ...
    }
}

shutdownRequested 变量只参与标记,且运算结果也并不依赖变量当前值,只是进行一个赋值。其他动作只是根据shutdownRequested这个变量来进行调整。

对long和dubbo类型变量的特殊规则——简单了解

JMM要求上述的8中操作:lock,unlock,read,load,use,assign,store,write都具有原子性(这个操作不能被拆分,要么成功,要么失败)。

但是!对于64位的数据类型,允许没有被volatile修饰的变量数据的读写分为两次32位的操作来进行。这就是“long和double的非原子性协定”。

这个特殊规则作为了解内容,一般情况下不会有读取“半个变量”的情况发生。摘一段《深入理解java虚拟机》的文字用于解释。

如果有多个线程共享一个并未声明为volatile的long或double类型的变量,并且同时对它们进行读取和修改操作,那么某些线程可能会读取到一个既不是原值,也不是其他线程修改值的代表了“半个变量”的数值。不过这种读取到“半个变量”的情况是非常罕见的,经过实际测试[插图],在目前主流平台下商用的64位Java虚拟机中并不会出现非原子性访问行为,但是对于32位的Java虚拟机,譬如比较常用的32位x86平台下的HotSpot虚拟机,对long类型的数据确实存在非原子性访问的风险。从JDK 9起,HotSpot增加了一个实验性的参数-XX:+AlwaysAtomicAccesses(这是JEP 188对Java内存模型更新的一部分内容)来约束虚拟机对所有数据类型进行原子性的访问。而针对double类型,由于现代中央处理器中一般都包含专门用于处理浮点数据的浮点运算器(Floating Point Unit,FPU),用来专门处理单、双精度的浮点数据,所以哪怕是32位虚拟机中通常也不会出现非原子性访问的问题,实际测试也证实了这一点。笔者的看法是,在实际开发中,除非该数据有明确可知的线程竞争,否则我们在编写代码时一般不需要因为这个原因刻意把用到的long和double变量专门声明为volatile。——《深入理解java虚拟机》

综述

JMM定义内存之间交互的相关操作和规则基本围绕并发中的 原子性,可见性,有序性三个特征建立。

原子性

原子性:一个或多个操作,要么全部执行且在执行过程中不被任何因素打断,要么全部不执行

synchronized实现原子性原理

回顾8个操作,实际对于读写只有6个操作(除去lock和unlock),他们都是原子性的。所以对于基本数据类型的读写,都是具备原子性的(忽略掉long和double的非原子性协定)。

如果有更大范围的原子性操作,JMM提供了lock和unlock,虽然jvm未直接开放这两个操作,但是提供两个字节码指令monitorenter和monitorexit。这个在实际编码中对应的就是synchronized关键字。

可见性

可见性:当一个线程修改了共享变量的值时,其他线程能够立即得知这个修改

volatile实现可见性原理

回顾volatile关键字,它保障可见性的原理是对于变量的读取和修改操作,必须是连续的。read,load,use 和 assign,store,write这两组操作必须连续,所以才会立即将新值写回主内存。

除了volatile能够保证可见性,java中还有两个也能时间可见性:synchronized和final。

synchronized实现可见性原理

原理是jmm定义的8种操作的规则之一:对一个变量执行unlock之前,必须先把变量同步到主内存中去。换句话说就是在unlock之前,必须先store,write一下。

final实现可见性原理

final:不可变。我们都知道final修饰类,类不可被继承;修饰方法,方法不可被重写;修饰变量,变量不可变。

而个final保证可见性,就是因为它不可变。

有序性

有序性:程序的执行顺序和代码书写的顺序一致

volatile实现有序性原理

通过上文,volatile实现有序性靠的是内存屏障,不允许将内存屏障之后的指令重排到内存屏障之前。

synchronized实现有序性原理

synchronized的有序性原理要重新追寻到JMM规定的8中操作的规范。其中一条是:一个变量在同一时刻只允许一条线程对其进行lock操作。也就是说,后面的线程要“排队”等待当前线程unlock后才可以进行后续处理,从这一点上synchronized保证了有序性。

JMM中的先行发生原则

先行发生原则也叫作 happens-before原则。先行发生原则是指如果A操作先于B操作,那么在B操作之前,对于A操作后的影响,B操作是可以观察到的。影响包括:共享变量的值,发送了消息,调用了方法,等等。

如果Java内存模型中所有的有序性都仅靠volatile和synchronized来完成,那么有很多操作都将会变得非常啰嗦,但是我们在编写Java并发代码的时候并没有察觉到这一点,这是因为Java语言中有一个“先行发生”(Happens-Before)的原则。—— 《深入理解java虚拟机》

先行发生原则也有8个,且这些原则在JMM中已经定义,可以在编码中直接使用。如果两个操作之间的关系不在这8个原则中,并且无法从这8个原则中推导出来,那么jvm就可以对这两个操作进行重排序。

  • 程序次序规则:在一个线程内,按照控制流顺序,书写在前面的操作先行发生于书写在后面的操作。注意,这里说的是控制流顺序而不是程序代码顺序,因为要考虑分支、循环等结构。
  • 管程锁定规则:一个unlock操作先行发生于后面对同一个锁的lock操作。这里必须强调的是“同一个锁”,而“后面”是指时间上的先后。
  • volatile变量规则:对一个volatile变量的写操作先行发生于后面对这个变量的读操作,这里的“后面”同样是指时间上的先后。
  • 线程启动规则:Thread对象的start()方法先行发生于此线程的每一个动作。
  • 线程终止规则:线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过Thread::join()方法是否结束、Thread::isAlive()的返回值等手段检测线程是否已经终止执行。
  • 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread::interrupted()方法检测到是否有中断发生。
  • 对象终结规则:一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始。
  • 传递性:如果操作A先行发生于操作B,操作B先行发生于操作C,那就可以得出操作A先行发生于操作C的结论。
先行发生原则的作用

它是判断数据是否存在竞争,线程是否安全的非常有用的手段。

依赖这个原则,我们就可以通过几条简单规则一揽子解决并发环境下两个操作之间是否可能存在冲突的所有问题,而不需要陷入Java内存模型苦涩难懂的定义之中。

比如:

以下代码,有这么一个问题:

Q:假设存在线程A和B,线程A先(时间上的先后)调用了setValue(1),然后线程B调用了同一个对象的getValue(),那么线程B收到的返回值是什么?

private int value = 0;

public void setValue(int value){
    this.value = value;
}

public int getValue(){
    return value;
}

A:依次分析一下先行发生原则中的各项规则。

由于两个方法分别由线程A和B调用,不在一个线程中,所以程序次序规则在这里不适用;

由于没有同步块,自然就不会发生lock和unlock操作,所以管程锁定规则不适用;

由于value变量没有被volatile关键字修饰,所以volatile变量规则不适用;

后面的线程启动、终止、中断规则和对象终结规则也和这里完全没有关系。

因为没有一个适用的先行发生规则,所以最后一条传递性也无从谈起。

因此判定,尽管线程A在操作时间上先于线程B,但是无法确定线程B中getValue()方法的返回结果,换句话说,这里面的操作不是线程安全的。

Q:如何修复此问题?

A:第一种把getter/setter方法都定义为synchronized方法,这样就可以套用管程锁定规则;

第二种把value定义为volatile变量,由于setter方法对value的修改不依赖value的原值,满足volatile关键字使用场景,这样就可以套用volatile变量规则来实现先行发生关系。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值