Shawn's Blog

蒸汽兔

Java内存模型

字数:573 字 阅读时长:约 3 分钟 阅读

硬件效率与统一性

处理器、高速缓存、主内存间的交互关系

除了新增高速缓存(例如某些cpu中寄存器下的三级缓存L1、L2、L3), 为了使处理器内部的运算单元能尽量被充分利用,处理器可能会对输入代码进行乱序执行(Out-of-Order Execution)优化,处理器会在计算之后将乱序执行的结果重组,保证该结果与顺序执行结果一致,但并不保证程序中各个语句计算的先后顺序与输入代码中的顺序一致。

因此,如果存在一个计算任务依赖另外一个计算任务的中间结果,那么其顺序性并不能靠代码的先后顺序来保证。

与处理器的乱序执行优化类似,Java虚拟机的即时编译器也有指令重排序优化(Instruction Reorder)

Java内存模型 (JMM)

2.1 主内存&工作内存

IMG_3814

Java内存模型的主要目的是定义程序中各种变量的访问规则,即关注在虚拟机中把变量值存储到内存(store write)和从内存中取出变量值(load read)这样的底层细节。

2.2 内存间交互操作

关于主内存与工作内存之间的具体的操作协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存这一类的实现细节,Java内存模型中定义了以下8种操作来完成

2.2.1 8种基本操作:

  1. lock,锁定,所用于主内存变量,它把一个变量标识为一条线程独占的状态。
  2. unlock,解锁,解锁后的变量才能被其他线程锁定。
  3. read,读取,所用于主内存变量,它把一个主内存变量的值,读取到工作内存中。
  4. load,载入,所用于工作内存变量,它把read读取的值,放到工作内存的变量副本中。
  5. use,使用,作用于工作内存变量,它把工作内存变量的值传递给执行引擎,当JVM遇到一个变量读取指令就会执行这个操作。
  6. assign,赋值,作用于工作内存变量,它把一个从执行引擎接收到的值赋值给工作内存变量。
  7. store,存储,作用域工作内存变量,它把工作内存变量值传送到主内存中。
  8. write,写入,作用于主内存变量,它把store从工作内存中得到的变量值写入到主内存变量中

java内存模型只要书上述两个操作必须按顺序执行,但不要求是连续执行,也就是read与load、store与write之间是可以插入其他指令的,如对主内存变量a、b进行访问是,一种可能出现的顺序是 read a、read b、load b、 load a.

2.2.2 8种操作的规则:

  java内存模型还规定了在执行上述8种基本操作时必须满足如下规则:

  1. 不允许read和load、store和write操作之一单独出现,即不允许加载或同步工作到一半。
  2. 不允许一个线程丢弃它最近的assign操作,即变量在工作内存中改变了之后,必须吧改变化同步回主内存。
  3. 不允许一个线程无原因地(无assign操作)把数据从工作内存同步到主内存中。
  4. 一个新的变量只能在主内存中诞生。
  5. 一个变量在同一时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,,多次lock之后必须要执行相同次数的unlock操作,变量才会解锁。
  6. 如果对一个对象进行lock操作,那会清空工作内存变量中的值,在执行引擎使用这个变量前,需要重新执行load或assign操作初始化变量的值。
  7. 如果一个变量事先没有被lock,就不允许对它进行unlock操作,也不允许去unlock一个被其他线程锁住的变量。
  8. 对一个变量执行unlock操作之前,必须将此变量同步回主内存中(执行store、write)。

2.3 volatile型变量特殊规则

2.3.1 可见性

变量变更时会立刻将变更值从工作内存写入主内存。通过将执行处理器缓存写入内存时将别的处理器或者内核无效化其缓存(这样其他处理器或内核会重新从主内存中读取该缓存),保证变量在各线程的可见性。

由于volatile变量只能保证可见性,在不符合以下两条规则的运算场景中,我们仍要通过加锁(使用 synchronized、java.util.concurrent 中的锁或者原子类)来保证原子性:

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

2.3.2 禁止指令冲排序优化

有volatile修饰的变量,赋值后多执行了一个 “lock addl $0x0,(%esp)” 操作,这个操作的作用就相当于一个内存屏障(指重排序时不能将后面的指令重排序到内存屏障之前的位置)。

0x01a3de0fmov		$0x3375cdb0%esi		;……beb0cd75 33
											;{oop('Singleton')}
0x01a3de14mov		%eax0x150%esi		;……89865001 0000
0x01a3de1ashr		$0x9%esi				;……c1ee09
0x01a3de1dmovb	$0x00x1104800%esi	;……c6860048 100100
0x01a3de24lock	addl $0x0,(%esp			;……f0830424 00
											;*put static instance
											;-
SingletongetInstance@24

Q:为什么”lock addl $0x0,(%esp)” 能实现内存屏障?

A: lock锁住了总线和对应的地址,这样其他的CPU写和读都要等待锁的释放。当写完成后,释放锁,把缓存刷新到主内存。 使用 volatile之后,volatile在最后加了lock前缀,把前面的步骤锁住了,这样如果前面的步骤没做完是无法执行最后一步刷新到内存的,换句话说只要执行到最后一步lock,必定前面的操作都完成了。那么即使我们完成前面两步或者三步了,还没执行最后一步lock,或者前面一步执行了就切换线程2了,线程B在判断的时候也会判断实例为空,进而继续进来由线程B完成后面的所有操作。当写完成后,释放锁,把缓存刷新到主内存。

这里就可以看到此内存屏障只保证lock前后的顺序不颠倒,但是并没有保证前面的所有顺序都是要顺序执行的,比如我有1 2 3 4 5 6 7步,而lock在4步,那么前面123是可以乱序的,只要123乱序执行的结果和顺序执行是一样的,后面的567也是一样可以乱序的,但是整体上我们是顺序的,把123看成一个整体,4是一个整体 567又是一个整体,所以整体上我们的顺序的执行的,也达到了看起来禁止重排的效果

2.3.3 Java内存模型堆volatile变量定义的特殊规则

  1. 要求在工作内存中,每次使用变量前都必须先从主内存刷新最新的值,用于保证能看见其他线程对变量所做的更改
  2. 要求在工作内存中,每次修改变量后都必须立刻同步回主内存中,用于保证其他线程可以看到自己对变量所做的修改
  3. 被volatile所修饰的变量不会被指令重排序优化,从而保证代码的执行顺序与程序的顺序一致

2.4 long 和double 型变量的特殊规则

Java内存模型要求lock、unlock、read、load、assign、use、store、write 这八种操作都具有原子性,但是对于64位的数据类型double、long.在内存模型中特别定义了一条宽松的规定:

允许虚拟机将没有被volatile修饰的64位数据的读写操作划分为2次32位的操作类型来进行。

这就是所谓的 long和double的非原子性协定(non-atomic Treatment of double and long Variables)

2.5 原子性、可见性与有序性

2.5.1 原子性(atomicity)

由java内存模型来直接保证的原子性变量操作包括 read、load、assign、use、store、write 这6个, 我们大致可以认为,基本数据类型的访问、读写都是具备原子性的(例外就是long和double的非原子性协定)

java内存模型还提供了lock和unlock来满足这种需求,尽管虚拟机未把lock和unlock操作直接开放出来,但是却提供了更高层次的字节码指令monitorenter 和 monitorexit 来隐式使用这两个操作。这两个指令反映在java代码层面就是关键字synchronized , 因此synchronized块之间的操作也具备原子性。

2.5.2 可见性(visibility)

可见性就是指当一个线程修改了共享变量的值,其他线程可以立刻得知这个修改。

实现可见性可通过3个关键字volatilesynchronizedfinal

volatile

volatile变量和普通变量区别就是volatile的特殊规则保证了新值能立刻同步到内存,以及每次使用前立刻从主内存刷新。

synchronized

同步块的可见性是由

对一个变量执行unlock操作前,必须先把此变量同步回主内存中(执行store、write操作)

这条规则获得的

final

final关键字的可见性是指:被final修饰的字段在构造器一旦初始化完成,并且构造器没有把this的引用传递出去,那么在其他线程中就能看见final字段的值。

2.5.3 有序性(ordering)

java提供了volatile和synchronized两个关键字来保证线程之间操作的有序性。

  • volatile本身就包含禁止指令重排序的语义。

  • synchronized则是由一个变量在同一时刻只允许一条线程对其lock操作这条规则获得的,这个规则决定了持有同一个锁的两个同步块只能串行的进入。

2.6 先行发生原则

这个原则非常重要,它是判断数据是否存在竞争、 线程是否安全的主要依据,依靠这个原则,我们可以通过几条规则一揽子地解决并发环境下两个操作之间是否可能存在冲突的所有问题。

2.6.1 Java内存模型中的先行发生关系

下面是Java内存模型下一些“天然的”先行发生关系,这些先行发生关系无须任何同步器协助就已经存在,可以在编码中直接使用。 如果两个操作之间的关系不在此列,并且无法从下列规则推导出来的话,它们就没有顺序性保障,虚拟机可以对它们随意地进行重排序:

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

2.6.2 如何应用先行发生规则

Java语言无须任何同步手段保障就能成立的先行发生规则就只有上面这些了,笔者演示一下如何使用这些规则去判定操作间是否具备顺序性,对于读写共享变量的操作来说,就是线程是否安全,读者还可以从下面这个例子中感受一下“时间上的先后顺序”与“先行发生”之间有什么不同

案例
private int value=0
    pubilc void setValueint value{
    this.value=value
}

public int getValue(){
    return value
}

以上显示的是一组再普通不过的getter/setter方法,假设存在线程A和B,线程A先(时间上的先后)调用了“setValue(1)”,然后线程B调用了同一个对象的“getValue()”,那么线程B收到的返回值是什么? 我们依次分析一下先行发生原则中的各项规则,由于两个方法分别由线程A和线程B调用,不在一个线程中,所以程序次序规则在这里不适用;由于没有同步块,自然就不会发生lock和unlock操作,所以管程锁定规则不适用;由于value变量没有被volatile关键字修饰,所以volatile变量规则不适用;后面的线程启动、 终止、 中断规则和对象终结规则也和这里完全没有关系。 因为没有一个适用的先行发生规则,所以最后一条传递性也无从谈起,因此我们可以判定尽管线程A在操作时间上先于线程B,但是无法确定线程B中“getValue()”方法的返回结果,换句话说,这里面的操作不是线程安全的。 那怎么修复这个问题呢?我们至少有两种比较简单的方案可以选择:要么把getter/setter方法都定义为synchronized方法,这样就可以套用管程锁定规则;要么把value定义为volatile变量,由于setter方法对value的修改不依赖value的原值,满足volatile关键字使用场景,这样就可以套用volatile变量规则来实现先行发生关系。

通过上面的例子,我们可以得出结论:一个操作“时间上的先发生”不代表这个操作会是“先行发生”,那如果一个操作“先行发生”是否就能推导出这个操作必定是“时间上的先发生”呢?很遗憾,这个推论也是不成立的,一个典型的例子就是多次提到的“指令重排序”,演示例子如下代码所示:

//以下操作在同一个线程中执行
int i=1
int j=2

以上代码的两条赋值语句在同一个线程之中,根据程序次序规则,“int i=1”的操作先行发生于“int j=2”,但是“int j=2”的代码完全可能先被处理器执行,这并不影响先行发生原则的正确性,因为我们在这条线程之中没有办法感知到这点。

结论

时间先后顺序与先行发生原则之间基本没有太大的关系,所以我们衡量并发安全问题的时候不要受到时间顺序的干扰,一切必须以先行发生原则为准。

参考资料

  • 《深入理解Java虚拟机》12.1-12.3节,作者:周志明

© Shawn Jim. All rights reserved. 本站总访问量 次, 访客数 人次.