JAVA内存模型与线程(一)

前言:

《深入理解jvm》差不多看完了重点的部分,揭秘下下一本书是《java
并发艺术》,继续冲冲冲。

JAVA内存模型(JMM)

img

  • Java虚拟机规范中试图定义一种Java内存模型(Java Memory Model,JMM)来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果。 C/C++等则直接使用物理硬件和操作系统的内存模型,因此,会由于不同平台上内存模型的差异而导致程序的移植性比较差。

  • Java内存模型必须定义得足够严谨,才能让Java的并发内存访问操作不会产生歧义;但是,也必须定义得足够宽松,使得虚拟机的实现有足够的自由空间去利用硬件的各种特性(寄存器、高速缓存和指令集中某些特有的指令)来获取更好的执行速度。

  • java内存模型规定了所有的变量都存储在主内存(Main Memory,类比物理内存)。每条线程还有自己的工作内存(Working Memory,类比处理器高速缓存),线程的工作内存中保存了被该线程使用到的变量的主内存副本拷贝。线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成,线程、主内存、工作内存三者的交互关系如图12-2所示。

  • 主内存主要对应于Java堆中的对象实例数据部分,而工作内存则对应于虚拟机栈中的部分区域。 从更低层次上说,主内存就直接对应于物理硬件的内存,而为了获取更好的运行速度,虚拟机(甚至是硬件系统本身的优化措施)可能会让工作内存优先存储于寄存器和高速缓存中,因为程序运行时主要访问读写的是工作内存。

需要注意的是:

  • 如果局部变量是一个reference类型,它引用的对象在Java堆中可被各个线程共享,但是reference本身在Java栈的局部变量表中,它是线程私有的
  • 如“假设线程中访问一个10MB的对象,也会把这10MB的内存复制一份拷贝出来吗?”,事实上并不会如此,这个对象的引用、对象中某个在线程访问到的字段是有可能存在拷贝的,但不会有虚拟机实现成把整个对象拷贝一次。
  • Java虚拟机规范的规定,volatile变量依然有工作内存的拷贝,但是由于它特殊的操作顺序性规定(后文会讲到),所以看起来如同直接在主内存中读写访问一般。
  • 除了实例数据,Java堆还保存了对象的其他信息,对于HotSpot虚拟机来讲,有Mark Word(存储对象哈希码、 GC标志、 GC年龄、 同步锁等信息)、class Point(指向存储类型元数据的指针)及一些用于字节对齐补白的填充数据(如果实例数据刚好满足8字节对齐的话,则可以不存在补白)。

内存间的交互作用

一个变量

1、如何从主内存拷贝到工作内存?

2、如何从工作内存同步回主内存之类的实现细节?

Java内存模型中定义了以下8种操作来完成,虚拟机实现时必须保证下面提及的每一种操作都是原子的、 不可再分的。

8种操作

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

如果要把一个变量从主内存复制到工作内存,那就要顺序地执行read和load操作,如果要把变量从工作内存同步回主内存,就要顺序地执行store和write操作。注意,Java内存模型只要求上述两个操作必须按顺序执行,而没有保证是连续执行。也就是说,read与load之间、store与write之间是可插入其他指令的,如对主内存中的变量a、 b进行访问时,一种可能出现顺序是read a、 read b、 load b、 load a。

操作的规定

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

volatile

作用:

  • 保证此变量对于所有线程的可见性
  • 禁止指令冲排序优化
  • 保证了可见性,不保证原子性

实现原理:

  • 保证此变量对于所有线程的可见性

    • 每次用于变量的use动作 都必须和read、load关联,就是每次将工作内存中的变量复制给执行引擎时候都从主存中获取最新的值
    • 每次用于变量assign动作时候,都必须与store、write动作相关联,就是每次将执行引擎中的变量复制到工作内存中是都把其刷新到主内存中
    • 上述亮点保证了每次使用工作内存中变量都是最新的值、变量赋值到工作内存的时候都刷新到主存中。
  • 禁止指令重排序优化

    • 何为指令重排序?

      1.Java语言规范JVM线程内部维持顺序花语义,即只要程序的最终结果与它顺序化情况的结果相等,那么指令的执行顺序可以与代码逻辑顺序不一致,这个过程就叫做指令的重排序。

      2.指令重排序的意义:使指令更加符合CPU的执行特性,最大限度的发挥机器的性能,提高程序的执行效率。

      3.不管怎么进行指令重排序,单线程内程序的执行结果不能被改变(但多线程中就不是这样子了噢)

      int i = 0;               
      boolean flag = false;
      i = 1;                //语句1   
      flag = true;          //语句2
      

      上述代码中 语句1 语句2谁先执行,在单线程中都没有影响。那么就有可能在执行过程中指令重排序,语句2先执行而语句1后执行。

      但是要注意,虽然处理器会对指令进行重排序,但是它会保证程序最终结果会和代码顺序执行结果相同,那么它靠什么保证的呢?再看下面一个例子:

      int a = 10;    //语句1
      int r = 2;    //语句2
      a = a + 3;    //语句3
      r = a*a;	 //语句4
      

      执行的顺序是 1234或者2134

      那么可不可能是这个执行顺序呢: 43

      那么可不可能是这个执行顺序呢: 语句2 语句1 语句4 语句3

      不可能,因为处理器在进行重排序时是会考虑指令之间的数据依赖性,如果一个指令Instruction 2必须用到Instruction 1的结果,那么处理器会保证Instruction 1会在Instruction 2之前执行。

      虽然重排序不会影响单个线程内程序执行的结果,但是多线程呢?下面看一个例子:

      //线程1:
      context = loadContext();   //语句1
      inited = true;             //语句2
       
      //线程2:
      while(!inited ){
        sleep() 
      }
      doSomethingwithconfig(context);
      

      上面代码中,由于语句1和语句2没有数据依赖性,因此可能会被重排序。假如发生了重排序,在线程1执行过程中先执行语句2,而此是线程2会以为初始化工作已经完成,那么就会跳出while循环,去执行doSomethingwithconfig(context)方法,而此时context并没有被初始化,就会导致程序出错。

      从上面可以看出,指令重排序不会影响单个线程的执行,但是会影响到线程并发执行的正确性.

      当然上面是用大的语句来分析,其实指令重排序针对的并不仅仅语句,而是语句分解后的指令

      例如int a=300;

      这里面就包含了 分配对象空间指令-1、对象初始化指令-2、赋值给引用变量指令-3

      正常来说是1 2 3 执行,但是1 3 2也是可以的,不影响最后的工作结果,在单线程这貌似没什么问题,但是在多线程就有大问题了,这在于单例模式中的双重检测里面有重要体现,容我下篇博客再单独分析。

    • 如何禁止?

      通过内存屏障,即在汇编代码上加入lock前缀指令。

      ​ 何为内存屏障

      ​ 观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令,此lock非jmm交互操作的lock

      ​ lock指令的作用是使本cpu的cache写入内存,该写入动作会引起其他cpu的cache无效化(缓存一致性)。通过这样一个操作让对于volatile变量的修改对于其他cpu可变。

      ​ lock指令把之前的cache都同步到内存中,等同于让lock指令后面的指令依赖于lock指令前面的指令,根据处理器在进行重排序时是会考虑指令之间的数据依赖性,所以lock指令之前的指令不会跑到lock指令之后,之后的也不会跑到之前。

      这样子lock前缀指令实际上相当于一个内存屏障(也成内存栅栏),内存屏障会提供3个功能:

      ​ 1)它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;相当于分割线

      2)它会强制将对缓存的修改操作立即写入主存;

      3)如果是写操作,它会导致其他CPU中对应的缓存行无效。

对于long和double型变量的特殊规则

​ 允许虚拟机将没有被volatile修饰的64位数据类型(long和double)的读取操作划分为两次32位的操作来进行,即允许虚拟机实现选择可以不保证64位数据类型的load、store、read和write这4个操作的原子性,就点就是long和double的非原子协定(Nonatomic Treatment of double and long Variables)。

​ 不过这种读取带“半个变量”的情况非常罕见(在目前商用虚拟机中不会出现),因为Java内存模型虽然允许虚拟机不把long和double变量的读写实现成原子操作,但允许虚拟机选择把这些操作实现为具有原子性的操作,而且还“强烈建议”虚拟机这样实现。默认都实现。

原子性、可见性、有序性

原子性:

原子性(Atomicity):由Java内存模型来直接保证的原子性变量操作包括read、load、assign、use、store和write,我们大致可以认为基本数据类型的访问具备原子性(long和double例外)。总结起来就是要么不执行,要么就全部执行完。

​ 如果应用场景需要一个更大范围的原子性保证,Java内存模型还提供了lock和unlock操作来满足需求,尽管虚拟机未把lock和unlock操作直接开放给用户,但是却提供了更高层次的字节码指令monitorenter和monitorexit来隐式地使用这两个操作,这两个字节码指令反应到Java代码中就是同步块——synchronized关键字,因此在synchronized块之间的操作也具备原子性

可见性:

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

除了volatile,Java还有两个关键字能实现可见性,synchronized和final同步块的可见性是由“对一个变量执行unlock操作之前,必须把此变量同步回主内存中(执行store和write操作)”这条规则获得的,而final关键字的可见性是指:变成了常量了,每个线程中的都是一样的,且不可修改

有序性:

**有序性(Ordering):**Java程序中天然的有序性可以总结为一句话:如果在本线程内观察,所有的操作都是有序的;如果在一个线程中观察另外一个线程,所有的操作都是无序的。前半句是指“线程内表现为串行的语义”(Within-Thread As-if-Serial Semantics),后半句是指“指令重排序”现象和“工作内存与主内存同步延迟”现象。

Java语言提供了volatile和synchronized两个关键字来保证线程之间操作的有序性,volatile关键字本身就包含了禁止指令重排序的语义,而synchronized则是由“一个变量在同一时刻只允许一条线程对其进行lock操作”这条规则获得的,这个规则决定了持有同一个锁的两个同步块只能串行地进入。

先行发生原则

先行发生是Java内存模型中定义的两项操作之间的偏序关系,如果说操作A先行发生于操作B,就是说A产生的影响能被B观察到,”影响“包括修改了内存中的共享变量值、发送了消息、调用了方法等。

// 线程A中执行
i = 1;

// 线程B中执行
j = i;

// 线程C中执行
i = 2;

​ 如果说线程A是先行发生于线程B的,那么可以确定在线程B执行之后 j=1,因为根据先行发生原则,A操作 i = 1 的结果可以被B观察到,并且线程C还没有执行。

那么如果线程C是在A与B之间,j 的值是多少呢?答案是不确定。此时就不满足先行发生原则了

2. 自动实现先行发生的规则

以下是Java内存模型中天然的先行发生规则,对于不在此列的关系,就没有顺序性保障,虚拟机可以随意的进行重排:

  • 程序次序规则:代码执行顺序符合流程控制顺序。
  • 管程锁定规则:unlock 操作先行发生于后面对同一个锁的 lock 操作。
  • volatile 变量规则:对一个 volatile 变量的写操作先行发生于后面对这个变量的读操作。
  • 线程启动规则:线程对象 start() 方法先行发生于此线程的每一个动作。
  • 线程终止规则:线程中所有操作先行发生于对此线程的终止检测。
  • 线程中断规则:对线程 interrupt() 方法的调用先行发生于被中断线程的代码检测到中断事件的发送:可以通过 Thread.interrupted() 方法检测到是否有中断发生。
  • 对象终结规则:一个对象的初始化完成先行发生于它的 finalize() 方法的开始。
  • 传递性:如果操作A先行发生于操作B,B先行发生于C,那么A先行发生于C。

3. 示例

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

假设有2个线程 A 和 B,A 先调用了 setValue(1),然后 B 调用 get 方法,那么 B 的返回值是什么?

我们对照一下上面的那些原则:

  • 2个方法分别在2个线程中调用,不在一个线程中,”程序次序规则“不适用;
  • 没有同步块,不会发生 lock 和 unlock 操作,”管程锁定规则“不适用;
  • value 没有使用 volidate 关键字,”volatile 变量规则“不适用;
  • 其他的线程、对象的启动终结之类的规则和此代码没有关系,都不适用;

所以,B 的返回值无法确定,就是说线程不安全。

先行发生原则是我们判断并发问题的准则

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值