JVM之内存构成(二)--JAVA内存模型与并发

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/qq_33938256/article/details/52584863

这部分内容,跟并发有关

我们知道,多任务处理,在现代操作系统几乎是必备功能。让计算机同时去做几件事情,不仅因为CPU运算能力太强大了,还有一个重要原因,CPU的运算速度远远高于它的存储和通信子系统的速度,大量时间耗费在磁盘I/O,网络I/O,数据库访问

虚拟机层面,如何实现多线程,多线程之间因数据共享或竞争而引发的一系列问题及解决方案


物理机中的并发–硬件效率与一致性

物理机遇到的并发与虚拟机中的情况,有不少相似之处,再扩展到分布式系统,我发现,其实也有不少相似之处。这之间有许多值得玩味的地方。

让计算机并发执行多个运算任务

这里面,不可能仅仅靠CPU计算就搞定的。CPU至少要跟内存交互,读取运算数据,存储运算结果,这个IO很难消除。当然,也无法仅仅靠CPU内的寄存器完成所有运算任务

CPU与存储设备之间的运算速度有几个数量级的差距,所以现代计算机系统都不得不加入一层读写尽可能接近CPU速度的高速缓存(Cache),作为内存与CPU间的缓冲。将运算需要的数据复制到缓冲,让运算快速进行,完后将缓存同步到内存。如此,CPU就无需等待缓慢的内存读写

在速度差距很大时,利用缓存来缓冲,用空间换时间;但同时会带来数据同步问题

引入了缓存一致性(Cache Coherence)问题

多核处理器里,每个CPU都有自己的高速缓存(一级、二级、三级),而它们又共享同一主内存。

当多个CPU的运算任务都涉及同一块主内存区域,可能导致各自的缓存数据不一致;数据同步回主存时,以谁的缓存数据为准呢?

为解决一致性问题,需要CPU访问缓存时都遵循一些协议,读写时,根据操作协议来。如MSI、MESI、MOSI、Synapse、Firefly、Dragon、Protocol

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

同时为了使得处理器充分被利用,CPU可能会对输入代码进行乱序执行(Out-of-Order Execution)优化,CPU会在计算后将结果重组,保证结果与顺序执行一致。Java虚拟机的即时编译器也有类似的指令重排序(Instruction Reorder)优化

若一个计算任务依赖另一计算任务的中间结果,那其顺序性,不能靠代码的先后顺序来保证

CPU


Java线程执行的内存模型

image

Java虚拟机使用定义种Java内存模型,以屏蔽各种硬件和OS的内存访问差异,以实现让Java程序在各个平台下都能达到一致的并发效果。C/C++直接使用物理硬件和OS的内存模型。

目标

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

Java内存模型

工作内存

工作内存

  • 每条线程都有自己的工作内存(可与高速缓存类比)
  • 线程读写变量,必须在自己工作的工作内存中进行
  • 工作内存保存主内存变量的值的拷贝
  • 不能直接读写主内存的变量
  • 不同线程间,无法直接访问对方工作内存的变量
  • 线程间变量值的传递,需要通过主内存

主内存

主內存

  • 所有的变量存在主内存(虽然名字跟物理机的主内存一样,可类比,但此主内存只是虚拟机内存的一部分)

内存间交互

内存间交互

一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存,Java内存模型定义了8种操作。这些操作,都是原子操作

Operation Place Instruction
lock Main Memory 将变量标识为一条线程独占状态
unlock Main Memory 释放被锁定的变量,释放后的变量才能被其他线程锁定
read Main Memory 变量值从主内存读取到线程的工作内存,以便紧接着的load操作
load Working Memeory 将read操作得到变量值放入工作内存的变量副本中
use Working Memeory 将工作内存的变量值传递给线程执行引擎
assign Working Memory 將一个从执行引擎接收到的值,赋给工作内存的变量
store Working Memory 将工作内存中的一个变量的值,传送到主内存,以便紧接着的write操作
write Main Memory 将store操作的变量值,放入主内存的变量中

变量从主内存复制到工作内存,顺序执行read和load

变量从工作内存同步到主内存,顺序执行store和write


long和double的非原子性协定

Nonatomic Treatment of double and long Variables

Java内存模型要求8个操作都具有原子性,但对64位的数据类型,long和double,模型定义了相对宽松

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

允许,并强烈建议,虚拟机将这些操作实现为原子性操作。

目前商用Java虚拟机几乎都选择把64位数据的读写作为原子操作来对待

编写代码时,一般不需为long或double专门声明为volatile


Volatile类型变量的特殊规则和语义

前面说过,Java内存模型,其实是定义读写内存变量的规则。

有些类型的变量比较特殊,除了上面所述的8个基本操作原则外,有特殊的规则。

特殊规则

  • read、load、use操作,须连续一起出现,每次use时,都从主内存read,工作内存load主内存的值,相当于每次use都从主内存中获取变量的最新值。保证能看见其他线程对变量的修改
  • assign、store、write操作,须连续一起出现,工作内存中的每次修改,须立刻同步回主内存。保证其他线程可以看到自己对变量的修改
  • 两条线程,若A线程对变量a的use/assign操作,先于B线程对变量b的use/assign操作,那么A线程对a变量的read/write操作,先于B线程对变量b的read/write操作。该规则要求变量不被指令重排序优化,保证代码执行顺序与程序的顺序相同

特殊语义

  • 保证可见性
  • 禁止指令重排优化

保证可见性

volatile是轻量级的synchronized,在多CPU开发中,保证了共享变量的“可见性”。

指当一条线程修改了变量的值,新的值可以被其他线程立即知道

volatile只能保证可见性,但无法保证原子性,which is a necessity for synchronization.
因此,如果不符合下面两个规则的运算场景,我们需要通過加锁,如synchronized关键字和java.util.concurrent包下的原子类,来保证源自性。如果符合,volatile就能保证同步

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

如下面的代码,就非常适合用volatile变量来控制并发

volatile boolean shutdownRequested;

public void shutdown() {
    shutdownRequested = true;
}
//当shutdown()被调用时,能保证所有线程中执行的doWork()方法都停下来
public void doWork() {
    while(!shutdownRequest) {
        //do something
    }
}

禁止指令重排优化

被volatile修饰的变量,多执行了lock addl $0x0,(%esp)操作

这个操作,相当于一个内存屏障(Memory Barrier/Memory Fence),意思是,重排序时,不能把后面的指令重排序到內存屏障之前的位置

lock addl $0x0,(%esp)汇编指令,把ESP寄存器的值加0,这个是空操作。其作用,是使得本CPU的Cache写入内存,该写入动作,也会引起别的CPU或别的内核无效化(Invalidate)其Cache,相当于对Cache中的变量,做了一次如Java内存模型中的”Store且Write操作”。所以,通过这样一个空操作,可让volatile变量的修改,对其他CPU立即可见

硬件架构上讲,指令重排序,是指CPU采用了允许将多条指令不按程序规定的顺序,分开发送给各个相应电路单元处理,同时保证结果正确,与程序顺序执行的结果一致。


高效并发的原则

Java内存模型,围绕着并发过程中如何实现原子性、可见性和有序性,3个特征来建立。我们来看看哪些操作,实现了这些特征

可见性、有序性和原子性

原子性(Atomicity)

  • 对基本数据类型的访问和读写是具备原子性的。
  • 对于更大范围的原子性保证,Java内存提供lock,unlock操作,但未直接开发给用户使用
  • 更高层次,可以使用字节码指令monitorenter和monitorexit来**隐式使用**lock和unlock操作。这两个字节码指令反映到Java代码中,就是同步块——synchronized关键字。因此synchronized块之间的操作也具有原子性。

可见性(Visibility)

  • 当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。Java内存模型是通过在变量修改后将新值同步回主内存,在变量读取之前从主内存刷新变量值来实现
    可见性的。volatile的特殊规则保证了新值能够立即同步到主内存,每次使用前立即从主内存刷新。
  • synchronized和final也能实现可见性。unlock前,先同步数据到主存。final修饰的字段在构造器中一旦被初始化完成,并且构造器没有把this的引用传递出去,那么其他线程中就能看见final字段的值

有序性(Ordering)

  • Java程序的有序性可以总结为一句话,如果在本线程内观察,所有的操作都是有序的(线程内表现为串行的语义);如果在一个线程中观察另一个线程,所有的操作都是
    无序的(指令重排序和工作内存与主内存同步延迟线性

先行发生(Happens-Before)

如果Java内存模型中所有的有序性,仅仅靠volatile和synchronized来完成,那么一些操作会很繁琐,但我们没有感觉得到,因为有happens-before原則。

该原则是判断数据是否存在竞争、线程是否安全的主要依据

先行原则
Java内存模型中定义的两项操作之间的偏序关系。如果操作A Happens-Before 操作B,意思是,B发生时,A产生的影响能被B观察到
//线程A中执行
i = 1;
//线程B中执行
j = i;
//线程C中执行
i = 2;

如果操作A和操作C之间,不存在先行发生关系,C出现在A和B之间,那么,C线程对变量j的修改,B线程不一定观察得到,此时,B读取到的数据可能不是最新的,不是线程安全的

Java内存模型中的先行发生

8条规则

  • 程序次序规则(Program Order Rule)
    • 一个线程内, 按照控制流顺序,写在前面的操作先行发生与写在后面的操作
  • 管程锁定规则(Monitor Lock Rule)
    • 一个unlock操作先行发生于后面对同一个锁的lock操作(就是拿到一个同步监视器的锁后,其他线程在这个锁被释放前,必须等待)
  • Volatile变量规则(Volatile Variable Rule)
    • 对一个volatile变量的写操作先行发生于后面对这个变量读操作,“后面”指时间上的先后
  • 线程启动规则(Thread Start Rule)
    • Thread对象的start()方法先行发生于此线程每一个动作
  • 线程终止规则(Thread Termination Rule)
    • 线程中的所有操作都先行发生于对此线程的终止检测
  • 线程中断规则(Thread Interruption Rule)
    • 对线程的interrupt()方法的调用,先行发生于被中断线程的代码检测到中断事件的发生
  • 对象终结规则(Finalizer Rule)
    • 对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法
  • 传递性(Transitivity)

    • A先行发生于B,B先行发生于C,那么,A先行发生于C
  • 时间上的先后,不等于“先行发生”。

  • 一操作先行发生,推不出时间上先发生。有指令重排序存在。

时间先后顺序与happens-before基本没太大关系,衡量并发安全问题,一切以happens-before原则为准,不要受到时间顺序的干扰

推荐阅读
infoq 深入理解Java内存模型
infoq Java并发编程的艺术
并发编程网

阅读更多
想对作者说点什么?

博主推荐

换一批

没有更多推荐了,返回首页