Java内存模型与线程(1)

本文深入解析Java内存模型(JMM),探讨主内存与工作内存、内存交互操作以及Volatile变量的特殊规则。Volatile提供轻量级同步,保证可见性和禁止指令重排序,但不保证原子性。此外,文章还介绍了先行发生原则,帮助理解并发执行的有序性。

写在最前,本篇文章大部分来源于《深入理解Java虚拟机》 并发部分 的提炼,并附带自己的理解,主要是为了帮助自己理解,和用于复习。如果同时还能对其他人有所裨益,那就更好不过了。如果有谬误的地方,还请不吝指出。

硬件的效率与一致性

由于计算机储存设备与处理器的运算速度差距太大,所以现代计算机加入了读写速度接近处理器运算速度的高速缓存作为缓冲:将数据复制到缓存中,使得运算能快速进行,运算结束再从缓存同步回内存。

但引入了一个问题:缓存一致性
在多路处理器系统中,每个处理器都有自己的高速缓存,共享同一主内存,称为共享内存多核系统。如果运算任务涉及到同一主内存区域,可能导致缓存数据不一致。同步回主内存时,就会发生矛盾。
通过遵守协议来解决问题。

内存模型:特定的操作协议下,对特定的内存或高速缓存进行读写访问的过程抽象。

除了高速缓存之外,为了使处理器内部的运算单元能够充分利用,处理器可能会对输入代码乱序执行(Out-Of-Order Execution)优化。
保证结果与顺序执行的结果是一致的。但语句执行的先后顺序不一定与输入代码的顺序一致。

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

类似地,Java虚拟机的即时编译器也有指令重排序优化

Java内存模型

JMM(Java Memory Model) 用以屏蔽硬件和操作系统的内存访问差异。以实现java程序在各种平台都能达到一致的内存访问效果。

主内存与工作内存

JMM的主要目的是定义程序中各种变量的访问规则。即从内存中存取变量的底层细节。
这里的变量不包括局部变量和方法参数(线程私有,不会被共享,也就不存在竞争问题)

JMM规定所有的变量都存在主内存中(指JVM的主内存)
每条线程还有自己的工作内存,保存了使用变量的主内存副本(与高速缓存类比)1
即对CPU的一种抽象

线程对变量的所有操作必须在工作内存中进行,而不能直接读写主内存的数据。不同线程之间无法直接访问对方工作内存的变量。线程间变量值得传递通过主内存进行。
即使是volatile也会有工作内存的拷贝,只是看上去像在主内存直接读写访问一样。(后文会解释原理)

注意:此处的主内存、工作内存与Java内存区域的堆、栈、方法区并不属于同一层次。

  • 主内存对应于物理硬件的内存
  • 基于对运行速度的要求,工作内存可能会存于寄存器和高速缓存中,因为程序运行主要访问工作内存。

内存间交互操作

八种操作,虚拟机必须保证原子性(对于double和long类型,有例外):

  1. lock: 作用于主内存的变量,它把一个变量标识为线程独占
  2. unlock: 作用于主内存的变量,将锁定的变量释放
  3. read: 作用于主内存的变量,把变量值从主内存传递到线程的工作内存中,以便load使用
  4. load: 作用于工作内存的变量,把read得到的变量存入工作内存的变量副本中
  5. use: 作用于工作内存的变量,把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量的字节码指令就会执行
  6. assign: 作用于工作内存的变量,把执行引擎收到的值
    赋给工作变量的内存,每当虚拟机遇到需要给变量赋值的字节码指令就会执行
  7. store: 作用于工作内存的变量,把工作内存一个变量的值,传送入主内存中
  8. write: 作用于主内存的变量,把store操作得到的变量值放入主内存的变量中

read+load: 变量从主内存拷贝到工作内存
store+write: 变量从工作内存同步回主内存
上述操作必须按顺序执行,但不一定是连续执行
read与load,store与write之间可以插入其它的指令

8种操作需满足规则:

  • 不允许read(load),store(write)指令单独出现
  • 不允许一个线程丢弃最近的assign操作,如果工作内存改变了变量,必须同步回主内存
  • 不允许线程无原因地把数据从工作内存同步回主内存
  • 新变量只能来源于主内存,不允许工作内存使用未被初始化地变量
  • 一个变量同一时刻只允许一条线程lock,但lock操作可以被同一线程重复执行多次,多次执行后,只有执行相同次数的unlock,才会被解锁。
  • 如果对变量执行lock,将会清空工作内存此变量的值,在执行引擎使用前,需要重新执行load或assign操作。
  • 如果变量事先没有被lock锁定,就不允许执行unlock,且unlock不能作用于其它线程锁定的变量
  • unlock之前,必须把变量同步回主内存(store+write)

JMM操作后来简化为read, write, lock, unlock四种,但只是语言描述上的等价简化,基础设计并未改变。

Volatile变量的特殊规则*

volatile是最轻量级的同步机制
volatile特性:

  1. 保证变量对所有线程的可见性:一旦一个线程修改,其它线程能立即得知改变。但并不意味着volatile是线程安全的。当且仅当符合下面两种情况才不需要加锁:
    • 运算结果不依赖于变量的当前值(即不产生get操作的修改,比如直接赋值), 或只有单一线程修改变量的值(即不会产生竞争条件)。
    • 不需要与其它状态变量参与不变约束(即两个或多个变量需要同时保持的约束,比如: 表示范围的两个变量,最小值必须保持小于最大值)。
  2. 禁止指令重排序优化,普通变量“线程内表现为串行的语义”,即结果一致,但执行顺序并也不一定与代码顺序一致。(重排序优化是机器级的优化操作,汇编代码被提前执行)
    在这里插入图片描述
    • 对于volatile变量,lock前缀相当于一个内存屏障2,重排序不能把后面的指令重排序到内存屏障之前的位置,只有一个处理器访问时,并不需要内存屏障。
    • lock指令将本处理器的缓存写入了内存,该动作也会引起其它处理器或内核的缓存无效化,相当于对缓存中的变量作了一次store+write操作。通过空操作,可以让修改对其他处理器立即可见。
    • 这里的lock指令,由于要把修改同步到内存,意味着所有之前的操作都已经执行完成,形成了重排序无法越过内存屏障的效果。

volatile效率如何?
volatile变量读操作性能消耗与普通变量无差别,写操作慢上一些(需要插入内存屏障以保证不发生乱序执行)
大部分情况下,开销比锁低。

  • 在JMM中,仅当前一个动作是load时,线程才能对volatile变量执行use操作,并且仅当对变量的后一个动作是use时,线程才能进行load。与load,read连续一起出现。也就是说,只有刚从主内存读取出来的数据,才能被使用。保证了每次都获取到最新的值,使得其它线程对变量的修改可见。
  • 仅当前一个动作是assign时,线程才能对volatile执行store操作(同上)。与store, write连续且一起出现。即修改会立即被同步回主内存中,保证其它线程可以看到自己对volatile变量的修改。
  • 假设在同一个线程中存在两个volatile变量x,y,那么如果x的use(assign)操作先于y的相同操作,那么对应的read,load(store,write)操作一定也先于y发生。保证了与程序顺序相同。
    比如:
int x1 = x; //其中有read,load,use操作
int y1 = y; //(同上)

如果是非volatile变量
可能发生 read y, read x, load y, load x, use x, use y的指令,与程序实际顺序不同
volatile保证了 如果use x 先于 use y 那么这个操作的所有x的指令都先于y的相应指令。

对于long,double变量的特殊规则

对于64位的数据类型,允许虚拟机将没有被volatile的读写操作划分为两次32位的操作进行。允许虚拟机自行选择是否保证64位的load, store, read, write操作的原子性。
即,所谓的“long和double的非原子性协定”

也就是说,如果不保护,所得到的值可能既非修改后的值,也非原值,甚至算不上过期数值,而是由两个32位数值(可能一个修改,一个未修改)拼凑而来的值。
实际情况下,很少出现这样的问题,仅在32位jvm可能出现(但也很少)

原子性,可见性与有序性

  1. 原子性(Atomicity)
    大致可以认为基本数据类型的访问、读写都是具备原子性的(除了long和double)
    如果还需要更大范围的原子性保证,可以通过lock,和unlock满足
  2. 可见性(Visbility)
    修改能够对其它线程可见。JMM通过在变量修改后,将新值同步回组内存,读取前从主内存刷新变量值来实现可见性。
    而普通变量与volatile变量区别是:其规则能保证新值可以立即同步到主内存,每次使用前立即从主内存刷新。

sychronized和final也能保证可见性。同步块的可见性是由:

对变量执行unlock操作前,必须将其同步回主内存中(执行store、write操作)

而final关键字的可见性是指,被final修饰的字段一旦初始化完成,并且构造器没有发生this引用逸出(详见 java并发编程实践第三章,发布与逸出),那么其它线程就能看见final字段的值。

  1. 有序性(Ordering)
    如果在本线程内观测,所有操作都是有序的;
    一个线程观察另一个线程,所有操作都是无序的;
    前半句指 线程内似表现位串行的语义,后半句指 指令重排序,和工作内存与主内存同步延迟的现象

sychronized关键字能保证以上所有三种特性, 但对性能影响也是最大的

先行发生原则(Happens-Before)

判断数据是否存在竞争,线程是否安全的有用手段。
先行发生是JMM中定义的两项操作之间的偏序关系,比如操作A先行发生于B,其实是说在B之前,A产生的影响能被操作B观察到。包括:修改,调用方法,发送消息等。
用代码作示例:

//线程A
 i=1;
//线程B
 j=i;
//线程C
 i=2;

假设线程A的操作先行发生于线程B的操作,那么B执行后,j一定等于1。依据:

  1. 先行发生保证i=1的结果可以被观察到
  2. 线程C还没开始,线程A操作结束后,没有其它线程修改i的值

现在假设C出现在A,B之间,且C与B没有先行发生关系,那么j的值就会不确定,可能为1或2。这时候B就存在读取过期数据的风险。

天然的先行发生关系:此处用>表示先行发生

程序次序规则:一个线程内,按照控制流顺序,书写于前面的操作先行发生于书写后面的操作。注:并非是代码顺序。考虑分支、循环等结构。

管程锁定规则:unlock>于后面(时间顺序)对同一个锁的lock操作。
volatile变量规则:写操作>后面(时间顺序)对变量的读操作
线程启动规则:start()方法>此线程的每一个动作
线程终止规则:线程所有行为>终止检测,可以通过Thread::join()方法是否结束,或Thread::isAlive()返回值来检验线程终止情况
线程中断规则:interrupt()方法调用>被中断线程代码检测到中断的发生

对象终结规则:对象的初始化完成(构造函数执行结束)>finalize()方法

传递性:若A>B,B>C,则A>C

以上所有规则无需任何同步手段。
运用代码来详细阐述先行发生的意义:

private int value=0;
public void setValue(int v){
	value=v;
}
public int getValue(){
	return value;
}

同一个对象,假设线程A先调用了setValue(1),然后线程B调用getValue(),那么返回值是什么?

可以仔细带入以上所有规则,可以发现均不适用于此处情况。
所以尽管A>B,我们无法确定B方法的返回结果。根本原因是无法确定B是否能观测到A操作所带来的影响。
两种简单方案:

  1. synchronized同步,管程锁定规则确保unlock之前发生能够被下一次lock的线程所知。
  2. volatile, 由于setter不依赖于value原值,满足使用场景,可以套用volatile变量规则

结论:时间上先发送不等于操作先行发生。
且,操作先行发生,也不代表一定时间上先发生
经典例子:指令重排序
我们现在都知道,根据程序次序规则,同一个线程,写于前面的操作总是先行发生于后面的操作。
但在处理器中,后面的代码完全可能在时间上先执行。

所以我们现在知道,指令重排序是基于时间顺序的,而先行发生原则是基于语义的。从程序员的角度,可以将先行发生等同于运行的顺序(即使重排序,也不影响语义的顺序),时间顺序的概念被弱化了。
比如:

int i=1;
int j=1;

即使j完全可能先于i的初始化,我们认为ij的初始化是顺序执行的,因为我们无法感知到这一点。
所以,如果某段代码具有先行发生的规则,则以其为主,而忽略实际时间上的发生顺序。


  1. 复制的是对象访问的某个字段,整个对象一般不会被复制 ↩︎

  2. 实际不是,但达到了类似的效果 ↩︎

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值