Java 内存模型

并发编程模型的分类

       在并发编程中, 我们需要处理两个关键问题: 线程之间如何通信及线程之间如何同步(线程指并发执行的活动实体). 通信是指线程之间以何种机制来交换信息. 在命令式编程中, 线程之间的通信机制有两种: 共享内存和消息机制.

       在共享内存的并发模型里, 线程之间共享程序的公共状态, 线程之间通过写-读内存中的公共状态来隐式通信. 在消息传递的并发模型里, 线程之间没有公共状态, 线程之间必须通过明确的发送消息来显示进行通信.

       同步是指程序用于控制不同线程之间操作发生相对顺序的机制. 在共享内存并发模型里, 同步是显示进行的. 程序员必须显示指定某个方法或某段代码需要在线程之间互斥执行. 在消息传递的并发模型里, 由于消息的发送必须在消息的接收之前, 因此同步是隐式进行的.

        Java的并发采用的是共享内存模型, Java线程之间的通信总是隐式进行, 整个通信过程对程序员完全透明. 如果编写多线程程序的Java程序员不理解隐式进行的线程之间通信的工作机制, 很可能会遇到各种奇怪的内存可见性问题.

Java内存模型的抽象

        在java中, 所有实例域、静态域和数组元素存储在对内存中, 堆内存在线程之间共享("共享变量"在本文指代实例域、静态域和数组元素). 局部变量, 方法定义参数和异常处理器参数不会在线程之间共享, 它们不会存在可见性问题, 也不受内存模型的影响.

        Java线程之间的通信由Java内存模型(简称JMM)控制, JMM决定一个线程对共享变量的写入何时对另一个线程可见. 从抽象的角度来看, JMM定义了线程和主内存之间的抽象关系: 线程之间的共享变量存储在主内存中, 每个线程都有一个私有的本地内存, 本地内存中存储了该线程以读/写共享变量的副本. 本地内存是JMM的一个抽象概念, 并不真实存在. 它涵盖了缓存, 写缓冲区, 寄存器以及其他的硬件和编译器优化. Java内存模型的抽象示意图如下:

从上图来看, 线程A与线程B之间如要通信的话, 必须要经历下面2个步骤:

1. 首先, 线程A把本地内存A中更新过的共享变量刷新到主内存中去;

2, 然后, 线程B到主内存中去读取线程A之前已更新过的共享变量.

下面通过示意图来说明这两个步骤:

       如上图所示, 本地内存A和B有主内存中共享变量x的副本/ 假设初始时, 这三个内存中的x值都为0. 线程A在执行时, 把更新后的x值(假设值为1)临时存放在自己的本地内存A中. 当线程A和线程B需要通信时, 线程A首先会把自己本地内存中修改后的x值刷新到主内存中, 此时主内存中x值变为1. 随后, 线程B到主内存中去读取线程A更新后的x值, 此时线程B的本地内存的x值也变为了1.

      从整体来看, 这两个步骤实质上是线程A在向线程B发送信息, 而且这个通信过程必须要经过主内存. JMM通过控制主内存与每个线程的本地内存之间的交互, 来为java程序员提供内存可见性保证. 

内存间交互操作

    JMM定义了8种操作(原子操作),虚拟实现时保证这8种操作均为原子操作,以下为8种操作的介绍以及执行顺序:

    (1)lock(锁定):作用于主内存的变量,把一个变量标志为一个线程占有状态(锁定);

    (2)unlock(解锁):作用于主内存的变量,把一个变量从一个线程的锁定状态解除,以便其他线程锁定;

    (3)read(读取):作用于主内存的变量,将变量从主内存读取到线程的本地内存,以便后续load操作使用;

    (4)load(载入):作用于工作内存的变量,将load操作从主内存得到的变量放入工作内存变量副本中;

    (5)use(使用):作用于工作内存的变量,将工作内存中的变量值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的字节码指令时将执行这个操作;

    (6)assign(赋值):作用于工作内存的变量,把一个从执行引擎接收的值赋给工作内存的变量;

    (7)store(存储):作用于工作内存的变量,把工作内存的一个变量传到主内存,以便后续write操作使用;

    (8)write(写入):作用于主内存的变量,把store操作从工作内存中得到的变量值放入主内存的变量中. 

 

工作示意图:

Note:这些操作之间是存在一些冲突的,需保持顺序执行

有关操作的一些规定:

(1)不允许read和load、store和write操作之一单独出现,即不允许一个变量从主内存读取了但工作内存不接受,或者从工作内存发起回写了但主内存不接受的情况;

(2)不允许一个线程丢弃它的最近assign操作,即变量在工作内存中改变了之后必须把该变化同步回主内存;

(3)不允许一个线程无原因的(没有发生过任何assign操作)把数据从线程的工作内存同步回主内存中;

(4)一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量,就是对一个变量执行use和store之前必须先执行过了load和assign操作;

(5)一个变量在同一个时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁;

(6)如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load或assign操作初始化变量的值;

(7)如果一个变量事先没有被lock操作锁定,则不允许对它执行unlock操作,也不允许去unlock一个被其他线程锁定住的变量;

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

Note:以上可以完全确定Java程序中哪些内存访问操作在并发下是安全的。

java内存模型对并发提供的保障:原子性、可见性、有序性

(1)原子性:

    Java内存模型直接保证得原子性操作包括read、load、use、assign、store和write这六个。可以认为基本数据类型(long和double除外,64字节在32位上需要两部操作)的变量操作是具有原子性的,而lock和unlock对应在高层次的字节码指令monitorenter和monitorexit来隐式的使用底层的这两个操作,高层次的这两个字节码指令在Java中就是同步块,Synchronized关键字,因此在synchronized块之间的操作也具备原子性。

(2)可见性

    可见性是指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。Java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性的,无论是普通变量还是volatile变量都是如此,普通变量与volatile变量的区别是,volatile的特殊规则保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新。因此,可以说volatile保证了多线程操作时变量的可见性,而普通变量则不能保证这一点。

    除了volatile外,Synchronized和final也提供了可见性,

Synchronized的可见性是由“对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行store、write操作)”这条规则获得Final的可见性是指被final修饰的字段在构造器中一旦初始化完成,并且构造器没有把this的引用传递出去(this引用逃逸是一件很危险的事情,其他线程有可能通过这个引用访问到“初始化了半”的对象),那在其他线程就能看见final字段的值

******这里的this逃逸问题******

(3)有序性

理解指令重排
计算机在执行程序时,为了提高性能,编译器和处理器的常常会对指令做重排,一般分以下3种

编译器优化的重排

编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。

指令并行的重排

现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性(即后一个执行的语句无需依赖前面执行的语句的结果),处理器可以改变语句对应的机器指令的执行顺序

内存系统的重排

由于处理器使用缓存和读写缓存冲区,这使得加载(load)和存储(store)操作看上去可能是在乱序执行,因为三级缓存的存在,导致内存与缓存的数据同步存在时间差。

其中编译器优化的重排属于编译期重排,指令并行的重排和内存系统的重排属于处理器重排,在多线程环境中,这些重排优化可能会导致程序出现内存可见性问题,下面分别阐明这两种重排优化可能带来的问题

       Java程序在本线程所有操作是有序的(线程表现为串行的),在一个线程中看另一个线程是无序的(指令重排和工作内存与主内存同步延迟现象),可以用Synchronized和volatile来保证线程操作的有序性.

Volatile本身就包含禁止指令重排序的语义;

Synchronized是因为:一个变量在同一时刻只能被一个线程lock,即串行化操作保证有序.

先行发生原则

    先行发生原则是java内存模型中定义的两项操作之间的偏序关系,这个原则作为依据来判断是否存在线程安全和竞争问题,如果两个操作之间的关系不在此列,并且无法从下列规则推导出来的话,它们就没有顺序性保障,虚拟机可以对它们随意地进行重排序。以下为8个具体原则:

    (1)程序次序规则(Program Order Rule):在一个线程内,按照程序代码顺序,书写在前面的操作先行发生于书写在后面的操作。准确地说,应该是控制流顺序而不是程序代码顺序,因为要考虑分支、循环等结构;

    (2)传递性(Transitivity):如果操作A先行发生于操作B,操作B先行发生于操作C,那就可以得出操作A先行发生于操作C的结论;

    (3)对象终结规则(Finalizer Rule):一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始;

    (4)管程锁定规则(Monitor Lock Rule):一个unlock操作先行发生于后面对同一个锁的lock操作。这里必须强调的是同一个锁,而“后面”是指时间上的先后顺序;

    (5)volatile变量规则(Volatile Variable Rule):对一个volatile变量的写操作先行发生于后面对这个变量的读操作,这里的“后面”同样是指时间上的先后顺序;

    (6)线程启动规则(Thread Start Rule):Thread对象的start()方法先行发生于此线程的每一个动作;

    (7)线程终止规则(Thread Termination Rule):线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值等手段检测到线程已经终止执行;

    (8)线程中断规则(Thread Interruption Rule):对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测到是否有中断发生。

volatile型变量

    Volatile是java虚拟机提供的最轻量级的同步机制,它具有可见性和有序性,但不保证原子性,在大多数场景下,volatile总开销仍然比锁要低

volatile是强制从主内存(公共堆)中取得变量的值,而不是从线程的私有堆栈中取得变量的值。如下图所示

volatile保证了变量的新值能立即同步到主内存,以及每次使用之前立即从主内存刷新。因此可以说volatile保证了多线程操作时变量的可见性,而普通变量不能保证这一点.

(1)volatile可以保证变量对所有线程的可见性,即一条线程修改了变量的值,新值对于其他线程来说是可以立即得知的。volatile变量在各个线程的工作内存中不存在一致性问题(即时存在,由于每次使用之前都得刷新,执行引擎看不到不一致的情况,所以认为是 不存在一致性问题)volatile实际上就使用到了内存屏障技术来保证其变量的修改对其他CPU立即可见;

(2)volatile禁止指令重排,保证有序性;

*Volatile不能保证的原子性操作有以下两条:

(1)对变量的写入操作不依赖于该变量的当前值(比如a=0;a=a+1的操作,整个流程为a初始化为0,将a的值在0的基础之上加1,然后赋值给a本身,很明显依赖了当前值),或者确保只有单一线程修改变量。

(2)该变量不会与其他状态变量纳入不变性条件中,(当变量本身是不可变时,volatile能保证安全访问,比如双重判断的单例模式。但一旦其他状态变量参杂进来的时候,并发情况就无法预知,正确性也无法保障)

下面简单说明一下volatile是如何实现禁止指令重排优化的。先了解一个概念,内存屏障(Memory Barrier)。 
内存屏障,又称内存栅栏,是一个CPU指令,它的作用有两个,一是保证特定操作的执行顺序,二是保证某些变量的内存可见性(利用该特性实现volatile的内存可见性)。由于编译器和处理器都能执行指令重排优化。如果在指令间插入一条Memory Barrier则会告诉编译器和CPU,不管什么指令都不能和这条Memory Barrier指令重排序,也就是说通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化。Memory Barrier的另外一个作用是强制刷出各种CPU的缓存数据,因此任何CPU上的线程都能读取到这些数据的最新版本。总之,volatile变量正是通过内存屏障实现其在内存中的语义,即可见性和禁止重排优化。下面看一个非常典型的禁止重排优化的例子DCL,如下:
public class DoubleCheckLock {

    private static DoubleCheckLock instance;

    private DoubleCheckLock(){}

    public static DoubleCheckLock getInstance(){

        //第一次检测
        if (instance==null){
            //同步
            synchronized (DoubleCheckLock.class){
                if (instance == null){
                    //多线程环境下可能会出现问题的地方
                    instance = new DoubleCheckLock();
                }
            }
        }
        return instance;
    }
}

上述代码一个经典的单例的双重检测的代码,这段代码在单线程环境下并没有什么问题,但如果在多线程环境下就可以出现线程安全问题。原因在于某一个线程执行到第一次检测,读取到的instance不为null时,instance的引用对象可能没有完成初始化。因为instance = new DoubleCheckLock();可以分为以下3步完成(伪代码)

memory = allocate(); //1.分配对象内存空间
instance(memory);    //2.初始化对象
instance = memory;   //3.设置instance指向刚分配的内存地址,此时instance!=null

由于步骤1和步骤2间可能会重排序,如下:

memory = allocate(); //1.分配对象内存空间
instance = memory;   //3.设置instance指向刚分配的内存地址,此时instance!=null,但是对象还没有初始化完成!
instance(memory);    //2.初始化对象

由于步骤2和步骤3不存在数据依赖关系,而且无论重排前还是重排后程序的执行结果在单线程中并没有改变,因此这种重排优化是允许的。但是指令重排只会保证串行语义的执行的一致性(单线程),但并不会关心多线程间的语义一致性。所以当一条线程访问instance不为null时,由于instance实例未必已初始化完成,也就造成了线程安全问题。那么该如何解决呢,很简单,我们使用volatile禁止instance变量被执行指令重排优化即可。

  //禁止指令重排优化
  private volatile static DoubleCheckLock instance;


ok~,到此相信我们对Java内存模型和volatile应该都有了比较全面的认识。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值