Java内存模型

6 篇文章 0 订阅

文章摘自:Java并发编程的艺术 方腾飞 魏鹏 程小明 著

  • Java内存模型基础

线程通信与同步:

在命令式编程中,线程之间的通信机制有两种:共享内存和消息传递。

在共享内存的并发模型中,线程之间共享程序的公共状态,通过写-读内存中的公共状态进行隐式通信。而同步是显示进行的。即程序猿必须显示指定某个方法或者某段代码需要在线程之间互斥执行。

在消息传递的并发模型中,线程直接没有公共状态,线程之间必须通过发送消息来显示进行通信。而同步是隐式进行的。因为消息的发送必须在消息的接收之前。

Java的并发采用的是共享内存模型,Java线程之间的通信总是隐式进行,整个通信过程对程序猿透明。我们需要了解隐式进行的线程之间通信的工作机制才能从容的面对多线程编程时的各种内存可见性问题。Java线程之间的通信由Java内存模型(简称JMM)控制。

可见性:一个线程对共享变量值的修改,能够及时地被其他线程看到。

  • Java内存模型的抽象结构

JMM决定一个线程对共享变量(代指实例域,静态域和数组元素)的写入何时对另外一个线程可见。

从抽象角度来看,JMM定义了线程和主内存之间的抽象关系:线程之间共享变量存储在主内存中,每个线程都有一个私有的本地内存,本地内存中存储了该线程以读/写共享变量的副本。本地内存是JMM的一个抽象概念,并不真实存在。它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。如下图1为Java内存模型的抽象示意图。

抽象结构

                                                                              图1JMM抽象结构

由图可以看出,若线程A和B之间要通信,则需要:1.A把本地内存A中更新过的共享变量刷新到主内存。2.B到主内存中去读线程A之前已经更新过的共享变量。

  • Java内存模型内存可见性保证

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

  • 重排序

在执行程序时候,为了提高性能,编译器和处理器常常会对指令做重排序。

重排序分3种:

a编译器优化的重排序b指令级并行的重排序c内存系统的重排序

这些重排序可能会导致多线程程序出现内存可见性问题。对于编译器,JMM的编译器重排序规则会禁止他认定类型的编译器重排序(不是所有)。对于处理器重排序(b、c),JMM的处理器重排序规则会要求Java编译器在生成指令序列时,插入特定的类型的内存屏障指令,通过内存屏障指令来禁止特定类型的处理器重排序。

JMM确保在不同的编译器和不同的处理器平台之上,通过禁止特定类型的编译器重排序和处理器重排序,为程序猿提供一致的内存可见性保证。

数据依赖性:如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。

as-if-serial语义:不管怎么重排序,(单线程)程序的执行结果不能被改变。

为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序(这里所说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作),因为这种重排序会改变执行结果。

eg:int a = 1;//A  int b = 2;//B  int c = a*b;//C  可以看出A和C之间、B和C之间都存在数据依赖关系。因此在最终执行的指令序列中,C不能被重排序到A和B前面,但是A和B之间没有数据依赖关系,编译器和处理器可以重排序A和B之间的执行顺序。  

as-if-serial语义把单线程程序保护了起来,遵守as-if-serial语义的编译器、runtime和处理器共同为编写单线程程序的程序猿创建了一个幻觉:单线程程序是按程序的顺序执行的。as-if-serial语义使得单线程程序猿不需担心重排序会干扰他们,也不用担心内存可见性问题。

在单线程程序中,对存在控制依赖的操作重排序,不会改变执行结果(也是as-if-serial语义允许对存在控制依赖的操作做重排序的原因);但是在多线程程序中,对存在控制依赖的操作重排序可能会改变程序的执行结果。

Java内存模型内存一致性保证

  • 顺序一致性

数据竞争:当程序未正确同步时候,则会存在数据竞争(JMM规范对其的定义是:在一个线程中写一个变量,在另外一个线程中读同一个变量,而且写和读没有通过同步来排序)。

JMM对正确同步的多线程程序的内存一致性做了入如下保证:如果程序是正确同步的,程序的执行将具有顺序一致性(Sequentially Consistent)。即程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同。这里的同步为广义上的同步,包括去常用同步原语(synchronized volatile final)的正常使用。

顺序一致性内存模型是一个理想化了的理论的参考模型,在设计的时候,处理器的内存模型和编程语言的内存模型都会以顺序一致性内存模型作为参照。它为程序猿提供了极强的内存可见性的保证。

顺序一致性内存模型特性:1.一个线程中的所有操作必须按照程序的顺序来执行。2.不管程序是否同步,所有线程都只能看得到一个单一的操作执行顺序。在顺序一致性的内存模型中,每个操作都必须原子执行且立刻对所有线程可见。

顺序一致性内存模型概念:顺序一致性模型有一个的单一的全局内存,这个内存通过一个左右摆动的开关可以连接任意一个线程,同时每一个线程必须按照程序的顺序来执行内存读/写操作。如图2所示为顺序一致性内存模型视图。从图中可以看出,在任意的时间点最多只能有一个线程可以连接到内存。当多个线程并发执行时,图纸的开关装置能把所有线程的所有内存读/写操作串行化。

                                                         图2顺序一致性内存模型视图 

举例:两个线程A和B,各有3个操作,A1->A2->A3,B1->B2->B3,若两个线程使用监视器锁来正确同步,那么执行效果为:A1->A2->A3->B1->B2->B3,操作的执行整体上有序,且两个线程都只能看到这个执行顺序。若两个线程没有同步,那么执行效果是:B1>A1>A2>B2>A3>B3,操作的整体无序,但两个线程都只能看到这个整体执行顺序B1>A1>A2>B2>A3>B3,因为顺序一致内存模型中的特性2中的:每个操作必须立即对任意线程可见。

在JMM中就没有这个保证。未同步程序在JMM中不但整体的执行顺序是无序的,而且所有线程看到的操作执行顺序也可能不一致。如当前线程把写过的数据缓存在本地内存中,在没有刷新到主内存之前,这个写操作仅仅对当前线程可见;从其他线程的角度来看,会认为这个写操作根本没有被当前线程执行。只有当前线程把本地内存中写过的数据刷新到主内存之后,这个写操作才能对其他线程可见。

在JMM中,在不改变(正确同步的)程序执行结果的前提下,尽可能为编译器和处理器的优化打开方便之门。而对于未正确同步的多线程程序,JMM只提供最小安全性:线程执行时读到的值要么是某个线程写入的值要么是默认值(0,Null,False),JMM保证线程读操作读取到的值不会无中生有某出来。JMM不保证未同步程序的执行结果和该程序在顺序一致性模型中的执行结果一致。因为如果要保持一致,JMM需要禁止大量的处理器和编译器优化,这对于程序的执行性能会产生很大的影响。而且保证未同步程序在这两个模型中的执行结果一直没有什么意义。

未同步程序在JMM中的执行,整体上无序,其执行结果无法预知,未同步程序在两个模型中的执行特性有如下几个差异:1.顺序一致性模型保证单线程内的操作会按程序的顺序执行,而JMM不保证单线程的操作会按程序的顺序执行(比如重排序)。2.顺序一致模型保证所有线程只能看到一致的操作执行顺序,而JMM不保证所有线程能看到一致的操作执行顺序。3.JMM不保证对64位的long型和double型变量的写操作具有原子性,而顺序一致性保证对所有的内存读/写操作都具有原子性

第三点解释:第三点的差异与处理器总线的工作机制密切相关。总线的工作机制可以把处理器对内存的访问以串行化的方式来执行。在任意时间点,最多只能有一个处理器可以访问内存。而在一些32位的处理器上,如果要求对64位数据的写操作具有原子性,会有比较大的开销,为了照顾这种处理器,Java语言规范鼓励但是不强求JVM对64位的long和double变量的写操作具有原子性。当JVM在这种处理器上运行时,可能会把一个64位long/double变量的写操作拆分为两个32位的写操作来执行。而这两个32写可能会分配到不同的总线事务中执行,所有此时对64位变量的写不具有原子性了。这样会产生意想不到的后果。假设处理器A写一个long,同时处理器B读long,A写时候分为两个32位写,且两个32写操作被分到不同的写事务中执行,而B64位读被分配到单个读事务,这样的话B将看到仅仅被A写了一半的无效值。注:JSR-133之前的旧内存模型,一个64位long/double型变量的读/写操作可以被拆两个32位读/写来执行,而从JSR-133内存模型开始(JDK5开始),仅仅运行把一个64位long/double型变量写操作拆分两个写来执行,任意读操作都必须具有原子性(单个读事务中执行)。

  • volatile的内存语义

volatile变量自身的特性:1.可见性:对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入。2.原子性:对任意单个volatile变量的读/写具有原子性,但是类似与volatile++这种符合操作不具有原子性。(在Java多线程编程核心技术 高红岩著 一书中写到因为volatile++不具有原子性,所以将volatile特性一点写为非原子的特性,但其他两书表达的意思是一致的)。

    private volatile long a = 0l;   //volatile声明64位的long型变量
    private long b = 0;;    //64位的long型变量

    public long getA() {
        return a;   //单个volatile变量的读
    }

    public void setA(long a) {
        this.a = a; //单个volatile变量的写
    }

    public void getAndIncrementA() {
        a ++;   //复合volatile变量的读/写
    }

    public synchronized long getB() {
        return b;   //对单个普通变量的读用同一个锁同步
    }

    public synchronized void setB(long b) {
        this.b = b; //对单个普通变量的写用同一个锁同步
    }

    public void getAndIncrementB() {    //普通方法
        long temp = getB(); //调用已同步的读方法
        temp += 1l; //普通写
        setB(temp); //调用已同步的写方法
    }

如上代码所示,一个volatile变量的单个读/写操作,与一个普通变量的读/写操作都是使用同一锁来同步,他们之间的执行效果是相同的。从内存语义的角度来说,volatile的写读和锁的释放获取有相同的内存效果:volatile的写和锁的释放有相同内存语义,读与锁的获取有相同的内存语义。  

volatile写-读的内存语义:线程A写flag变量后,本地内存A中的共享变量被刷新到主内存。当读一个volatile变量时,JMM会把该线程对应的本地内存置无效,将从主内存中读取共享变量。

volatile写-读的内存语义总结:1.线程A写一个volatile变量,实质是线程A向接下来将要读这个volatile变量的某个线程发出来(对共享变量已修改)消息。2.线程B读一个volatile变量,实质上是线程B接收了之前某个线程发出的(在写volatile变量之前对共享变量所做修改)消息。3.线程A写一个volatile,随后线程B读这个volatile变量,这个过程实质上是线程A通过主内存向线程B发消息。如下图3所示。

                                      图3共享变量状态示意图

  • 锁的内存语义

锁的释放和获取的内存语义:

当线程释放锁时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存中。(与volatile写相同内存语义)

当线程获取锁时,JMM会把该线程对应的本地内存置为无效。从而使得必须从主内存读取共享变量。(与volatile读相同内存语义)

总结:线程A释放一个锁,实质上是线程A向接下来将要获取这个锁的某个线程发出了(共享变量被修改的)消息。线程B获取一个锁,实质上是线程B接受了之前某个线程发出的(在释放这个锁之前对共享变量所做的修改的)消息。线程A释放锁,随后线程B获取这个锁,这个过程实质是线程A通过主内存向线程B发消息

  • final的内存语义

写final域的重排序规则:1.JMM禁止编译器把final域的写重排序到构造函数之外。2.编译器会在final域的写之后,构造函数return之前,插入一个storestore屏障,这个屏障禁止处理器把final域的写重排序到构造函数之外。

写final域的重排序规则可以确保:在对象引用为任意线程可见之前,对象的final域已经正确初始化了,而普通域不具有这个保障。

读final域的重排序规则:在一个线程中,初次读对象引用与初次读对象包含的final域,JMM禁止处理器重排序这两个操作。编译器会在读final域的前面加一个loadload屏障。

final域若是一个引用类型,那么:在构造函数内对一个final引用的对象成员域的写入,与随后在构造函数外把这个被构造对象的引用赋值给一个引用变量,这两个操作不能重排序。

  • happens-before

JMM的设计:JMM把happens-before要求禁止的重排序分为了下面两类:1.会改变程序执行结果的重排序(JMM要求编译器和处理器必须禁止这种重排序)。2.不会改变程序执行结果的重排序(JMM对编译器和处理器不做要求,编译器和处理器想怎么优化都行,比如,如果编译器经过细致的分析后认定一个锁只会被单个线程访问,那么这个锁可以被消除。再如,如果编译器经过细致分析,认定一个volatile变量只会被单个线程访问,那么编译器可以把这个volatile变量当作一个普通 变量对待,这样的优化不仅不会改变程序执行结果,而且提高了程序的执行效率)。

 

                                                         图4 JMM的设计示意图 

JSR-133 对happens-before关系的定义如下:

1.如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。

2.两个操作直接存在heppens-before关系,并不意味着Java平台的具体实现必须要按照happens-before卦象你指定的顺序来执行。如果重排序之后的执行结果,与按happens-before关系来执行的结果一致,那么这种重排序并不非法(JMM允许这种重排序)。

上面的1是JMM对程序猿的承诺。从程序猿角度来说,可以这样理解happens-before,Ahapens-beforeB,那么JMM将向程序猿保证-A操作的结果将对B可见,且A的执行顺序排在B之前。

上面2是JMM对编译器和处理器重排序的约束原则。JMM其实是在遵循一个基本原则:只要不改变程序的执行结果(指的是单线程程序和正确同步的多线程程序),编译器和处理器怎么优化都行。JMM这样做原因:程序猿对于这两个操作是否真的重排序并不关系,程序猿关心的是程序执行时候的语义不能被改变(即执行结果不能被改变)。因此happens-before关系本质上和as-ifserial语义一回事。

as-if-serial语义保证单线程内程序的执行结果不被改变,happens-before关系保证正确同步的多线程程序的执行结果不被改变。

as-if-serial语义给编写单线程程序的程序猿创建一个幻境:单线程程序是按程序的顺序来执行的。happens-before关系给编写正确同步的多线程程序的程序猿创建一个幻境:正确同步的多线程程序是按happens-before指定的顺序来执行的。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值