VB.net 2010 视频教程 VB.net 2010 视频教程 python基础视频教程
SQL Server 2008 视频教程 c#入门经典教程 Visual Basic从门到精通视频教程
当前位置:
首页 > 编程开发 > Java教程 >
  • Java内存模型(JMM)&volatile

计算机多路并发处理

计算机硬件系统包括运算器、存储器、控制器、输入设备、输出设备。在此处详细说明一下运算器、存储器与控制器。

控制器

控制器是对输入的指令进行分析,并统一控制计算机的各个部件完成一定任务的部件。它一般由指令寄存器、状态寄存器、指令译码器、时序电路和控制电路组成。
是协调指挥计算机各部件工作的元件,其功能是从内存中依次取出命令,产生控制信号,向其他部件发出指令,指挥整个运算过程。

运算器

运算器又称算术逻辑单元(ArithmeticLogicUnit简称ALU),是进行算术、逻辑运算的部件。运算器的主要作用是执行各种算术运算和逻辑运算,对数据进行加工处理。控制器、运算器和寄存器等组成硬件系统的核心----中央处理器(CentralProcessingUnit,简称CPU)。

存储器

存储器是计算机记忆或暂存数据的部件。计算机中的全部信息,包括原始的输入数据。经过初步加工的中间数据以及最后处理完成的有用信息都存放在存储器中。而且,指挥计算机运行的各种程序,即规定对输入数据如何进行加工处理的一系列指令也都存放在存储器中。
存储器按在计算机中的作用分类可分为主存、Flash Memory、高速缓冲存储器(cache)、辅助存储器

CPU、内存、I/0设备速度平衡优化而导致的并发编程问题

绝大多数的运算任务都不可能只靠处理器"计算"就能完成。处理器至少要与内存交互,如读取运算数据、存储运算结果等,这个I/O操作是很难消除的(无法仅依靠寄存器来完成所有运算任务)。

由于计算机的存储设备与处理器的运算速度有着几个数量级的差距,所以现代计算机系统都不得不加入一层或多层读写速度尽可能接近处理器运算速度的高速缓存(cache)来作为内存与处理器之间的缓冲:将运算需要使用的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存之中,这样处理器就无须等待缓慢的内存读写了。

基于高速缓存的存储交互很好解决了处理器与内存速度之间的矛盾,但是也为计算机系统带来更高的复杂度,它引入了一个新的问题:缓存一致性。(导致了可见性问题的产生)
在多路处理器系统中,每个处理器都有自己的高速缓存,而它们又共享同一主内存,这种系统称为共享多核系统,当多个处理器的运算任务都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致
故为了解决一致性问题,需要各个处理器访问缓存时都遵循一些协议。

除了增加高速缓存之外,为了使处理器内部的运算单元能尽量被充分利用,处理器可能会对输入代码进行乱序执行优化,处理器会在计算之后将乱序执行的结果重组,保证该结果与顺序执行的结果是一致的,但并不保证程序中各个语句计算的先后顺序与输入代码中的顺序一致
因此如果存在一个计算任务依赖另外一个计算任务的中间结果,那么其顺序性并不能靠代码的先后顺序来保证。(导致了有序性问题的产生)

操作系统增加了线程、进程,以分时复用CPU,进而均衡CPU与I/O设备的速度差异(导致了原子性问题的产生)

Java内存模型(Java Memory Model)

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

Java内存模型的主要目的是定义程序中各种变量的访问规则,即关注在虚拟机中把变量值存储到内存和从内存中取出变量值这样的底层细节。

Java内存模型规定了所有的变量都存储在主内存中。每条线程还有自己的工作内存,线程的工作内存中保存了被该线程使用的变量的主内存副本,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的数据。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。

Java内存模型中定义了以下八种操作来完成一个变量从主内存拷贝到工作内存、如何从工作内存同步回主内存这一类的实现细节。
Java虚拟机实现时必须保证下面提及的每一种操作都是原子的、不可再分的。

  • lock(锁定) - 作用于主内存的变量,它把一个变量标识为一条线程独占的状态。

  • unlock(解锁) - 作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。

  • read(读取) - 作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用。

  • load(载入) - 作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。

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

  • assign(赋值) - 作用于工作内存的变量,它把一个从执行引擎接收的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。

  • store(存储) - 作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的write操作使用。

  • write(写入) - 作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中。

Java内存模型还规定了执行上述八种基本操作时必须满足如下规则

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

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

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

  • 一个新的变量只能在主内存中"诞生",不允许在工作内存中直接使用一个未被初始化(load或assign)的变量,换句话说就是对一个变量实施use,store操作之前,必须先执行assign和load操作。

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

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

  • 如果一个变量事先没有被lock操作锁定,那就不允许对它执行unlock操作,也不允许去unlock一个被其他线程锁定的变量。

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

原子性、可见性、有序性

Java内存模型是围绕着在并发过程中如何处理原子性,可见性和有序性这三个特征来建立的。

原子性

原子性是指在一个操作中就是cpu不可以在中途暂停然后再调度,即不被中断操作,要不执行完成,要不就不执行。
由Java内存模型来直接保证的原子性变量操作包括read、load、assign、use、store和write这六个。基本数据类型的访问、读写都具备原子性的。
如果应用场景需要一个更大范围的原子性保证,Java内存模型还提供了lock和unlock操作来满足这种需求,尽管虚拟机未把lock和unlock操作直接开放给用户使用,但是却提供了更高层次的字节码指令monitorenter和monitorexit来隐式地使用这两个操作。
这两个字节码指令反映到Java代码中就是同步块---synchronized关键字,因此在synchronized块之间的操作也具备原子性。

可见性

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

除了volatile之外,Java还有两个关键字能实现可见性,它们是synchronized和final。
同步块的可见性是由"对一个变量执行unlock之前,必须先把此变量同步回主内存中(执行store、write操作)"这条规则获得的。
final关键字的可见性是指:被final修饰的字段在构造器中一旦被初始化完成,并且构造器没有把"this"的引用传递出去,那么在其他线程中就能看见final字段的值。

有序性

Java程序中天然的有序性可以总结为一句话:如果在本线程内观察,所有的操作都是有序的;如果在一个线程中观察另外一个线程,所有的操作都是无序的。

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

volatile

当一个变量被定义为volatile之后,它将具备两项特性:

  1. 保证此变量对所有线程的可见性,这里的"可见性"是指当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。而普通变量并不能做到这一点,普通变量的值在线程间传递时需要通过主内存来完成的。

  2. 禁止重排序优化;

线程内表现为串行的语义

普通的变量仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值操作的顺序与程序代码中的执行顺序一致。因为在同一个线程的方法执行过程中无法感知到这点。
指令重排序
处理器采用了允许将多条指令不按程序规定的顺序分开发送给各个相应的电路单元进行处理。

volatile采用"内存屏障"实现,加入volatile关键字时,会多出一个lock前缀指令
lock前缀指令实际上相当于一个内存屏障(也称内存栅栏),内存屏障会提供3个功能:

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

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

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

由于volatile变量只能保证可见性,在不符合以下两条规则的运算场景中,我们仍要通过加锁来保证原子性:
运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值
变量不需要与其他的状态变量共同参与不变约束

final在多线程环境下的重排序

参考链接:@pdai-关键字: final详解

final域为基本类型

示例代码:

著作权归https://pdai.tech所有。
链接:https://pdai.tech/md/java/thread/java-thread-x-key-final.html

public class FinalDemo {
    private int a;  //普通域
    private final int b; //final域
    private static FinalDemo finalDemo;

    public FinalDemo() {
        a = 1; // 1. 写普通域
        b = 2; // 2. 写final域
    }

    public static void writer() {
        finalDemo = new FinalDemo();
    }

    public static void reader() {
        FinalDemo demo = finalDemo; // 3.读对象引用
        int a = demo.a;    //4.读普通域
        int b = demo.b;    //5.读final域
    }
}
假设线程A在执行writer()方法,线程B执行reader()方法。

写final域重排序规则
写final域的重排序规则:禁止对final域的写重排序到构造函数之外,这个规则的实现主要包含了两个方面

  • JMM禁止编译器把final域的写重排序到构造函数之外
  • 编译器会在final域写之后,构造函数return之前,插入一个storestore屏障。
    通过这个屏障,实现禁止编译器把final域的写重排序到构造函数之外。

对照示例代码:
由于a,b之间没有数据依赖性,普通域(普通变量)a可能会被重排序到构造函数之外,线程B就有可能读到的是普通变量a初始化之前的值(零值),这样就可能出现错误。而final域变量b,根据重排序规则,会禁止final修饰的变量b重排序到构造函数之外,从而b能够正确赋值,线程B就能够读到final变量初始化后的值。
因此,写final域的重排序规则可以确保:在对象引用为任意线程可见之前,对象的final域已经被正确初始化过了,而普通域就不具有这个保障。

读final域重排序规则
读final域重排序规则:在一个线程中,初次读对象引用和初次读该对象包含的final域,JMM会禁止这两个操作的重排序。处理器会在读final域操作的前面插入一个LoadLoad屏障。
实际上,读对象的引用和读该对象的final域存在间接依赖性,一般处理器不会重排序这两个操作。但是有一些处理器会重排序,因此,这条禁止重排序规则就是针对这些处理器而设定的

读对象的普通域被重排序到了读对象引用的前面就会出现线程B还未读到对象引用就在读取该对象的普通域变量,这显然是错误的操作。而final域的读操作就“限定”了在读final域变量前已经读到了该对象的引用,从而就可以避免这种情况。
读final域的重排序规则可以确保:在读一个对象的final域之前,一定会先读这个包含这个final域的对象的引用。

final域为引用类型

对final修饰的对象的成员域写操作
针对引用数据类型,final域写针对编译器和处理器重排序增加了这样的约束:在构造函数内对一个final修饰的对象的成员域的写入,与随后在构造函数之外把这个被构造的对象的引用赋给一个引用变量,这两个操作是不能被重排序的。

关于final重排序的总结

按照final修饰的数据类型分类:

  • 基本数据类型:
    • final域写:禁止final域写与构造方法重排序,即禁止final域写重排序到构造方法之外,从而保证该对象对所有线程可见时,该对象的final域全部已经初始化过。
    • final域读:禁止初次读对象的引用与读该对象包含的final域的重排序。
  • 引用数据类型:
    • 额外增加约束:禁止在构造函数对一个final修饰的对象的成员域的写入与随后将这个被构造的对象的引用赋值给引用变量的重排序

先行发生原则

如果Java内存模型中的有序性都依靠volatile和synchronized来完成,那么有很多操作都会变得非常啰嗦,但是我们在编写Java并发代码的时候并没有察觉到这一点,这是因为Java语言中有一个"先行发生原则"

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

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

JMM包含以下"天然"先行发生关系,这些先行发生关系无须任何同步器协助就已经存在,可以在编码中直接使用。

  • 程序次序规则
    在一个线程内,依照控制流顺序,书写在前面的操作先行发生于书写在后面的操作。(注意,这里说的是控制流顺序而不是程序代码顺序,因为要考虑分支、循环等结构)

  • 管程锁定规则
    一个unlock操作先行发生于后面对同一个锁的lock操作

  • volatile变量规则
    对一个volatile变量的写操作先行发生于后面对这个变量的读操作

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

  • 线程终止规则
    线程中的所有操作都先行发生于此线程的终止检测,我们可以通过Thread::join()方法是否结束、Thread::isAlive()的返回值等手段检测线程是否已经终止运行

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

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

  • 传递性
    如果操作A先行发生于操作B,操作B先行发生于操作C,那就可以得出操作A先行发生于操作C的结论。

     

    来源:https://www.cnblogs.com/winter0730/p/14867343.html

相关教程