Java并发编程的艺术-学习3

第三章 Java内存模型

3.1 Java内存模型的基础

3.1.1 并发编程模型的两个关键问题

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

在共享内存的并发模型里,线程之间共享程序的公共状态,通过读-写内存中的公共状态进行隐士通信。在消息传递的并发模型里,线程之间没有公共状态,线程之间必须通过发送消息来显示通信。
同步是指程序中用于控制不同线程间操作发生相对顺序的机制。在共享内存并发模型里,同步是显示进行的。程序员必须显示指定某个方法或某段代码需要在线程之间互斥执行。在消息传递的并发模型里,由于消息的发送必须在消息的接收之前,因此同步是隐式进行的。
Java的并发采用的是共享内存模型,Java线程之间的通信总是隐式进行,整个通信过程对程序员完全透明。

3.1.2 Java内存模型的抽象结构

在Java中,所有实例域、静态域和数组元素都存储在堆内存中,堆内存在线程之间共享(本章用“共享变量”这个术语指代实例域、静态域和数组元素)。局部变量、方法定义参数和异常处理器参数不会在线程之间共享,它们不会有内存可见性问题,也不受内存模型的影响。(不懂)
Java线程之间的通信有Java内存模型(简称JMM)控制,JMM决定一个线程对共享变量的写入何时对另一个线程可见。从抽象角度来看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存中,每个线程都有一个私有的本地内存,本地内存中存储了该线程以读/写共享变量的副本。本地内存是JMM的一个抽象概念,并不真实存在。它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。
在这里插入图片描述
JMM通过控制主内存与每个线程的本地内存之间的交互,来为java程序员提供内存可见性保证。

3.1.3 从源代码到指令序列的重排序

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

(1)编译器优化的重排序。

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

(2)指令级并行的重排序。

现代处理器采用了指令级并行技术来讲多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。

(3)内存系统的重排序。

由于处理器使用缓存和读/写缓冲区,使得加载和存储操作看上去可能是在乱序执行。
在这里插入图片描述
1属于编译器重排序,2和3属于处理器重排序。
JMM的编译器重排序规则会禁止特定类型的编译器重排序。
JMM的处理器重排序规则会要求Java编译器在生成指令序列时,插入特定类型的内存屏障指令,通过内存屏障指令来禁止特定类型的处理器重排序。
JMM属于语言级的内存模型,它确保在不同的编译器和不同的处理器平台之上,通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。

3.1.4 并发编程模型的分类

处理器使用写缓冲区临时保存向内存写入的数据。写缓冲区可以保证指令流水线持续运行,它可以避免由于处理器停顿下来等待向内存写入数据而产生的延迟。同时,通过以批处理的方式刷新写缓冲区,以及合并写缓冲区中对同一内存地址的多次写,减少对内存总线的占用。虽然写缓冲区有这么多好处,但每个处理器上的写缓冲区,仅仅对它所在的处理器可见。这个特性会对内存操作的执行顺序产生重要影响:处理器对内存的读/写操作的执行顺序,不一定与内存实际发生的读/写操作顺序一致!

处理器Processor AProcessor B
代码a=1; //A1 x=b; //A2b=2; //B1 y=a; //B2
运行结果初始状态:a = b = 0 处理器运行执行后得到结果:x=y=0

为保证内存可见性,Java编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序。JMM把内存屏障指令分为4种:

屏障类型指令示例说明
LoadLoad BarriersLoad1;LoadLoad;Load2确保Load1数据的装载先于Load2及所有后续装载指令的装载
StoreStore BarriersStore1;StoreStore;Store2确保store1数据对其他处理器可见(刷新到内存)先于Store2以及所有后续存储指令的存储
LoadStore BarriersLoad1;LoadStore;Store2确保Load1数据装载先于Store2及所有后续的存储指令刷新到内存
StoreLoad BarriersStore1;StoreLoad;Load2确保Store1数据对其他处理器变得可见(指刷新到内存) 先于Load2及所有后续装载指令的装载。StoreLoad Barriers会使该屏障之前的所有内存访问指令完成之后,才执行该屏障之后的内存访问指令

StoreLoad Barriers是一个“全能型”的屏障,它同时具有其他3个屏障的效果。执行该屏障开销会很昂贵,因为当前处理器通常要吧写缓冲区中的数据全部刷新到内存中。

3.1.5 happens-before简介

在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在happens-before关系。这两个操作既可以是在一个线程之内,也可以是在不同线程之间。
相关的happens-before规则如下:
程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。
监视器锁规则:对于一个锁的解锁,happens-before于随后对这个锁的加锁。
volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。
传递性:A happens-before B,且B happens-before A,则A happens-before C
两个操作之间具有happens-before关系,并不意味着前一个操作必须要在后一个操作之前执行!仅仅要求前一个操作(执行的结果)对后一个操作可见。

3.2 重排序

重排序是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段。

3.2.1 数据依赖性

编译器和处理器重排序时,会遵守数据依赖性。数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑。

3.2.2 as-if-serial语义

as-if-serial语义:不管怎么重排序,(单线程)程序的执行结果不变。
编译器、处理器不会对存在数据依赖关系的操作重排序。

3.2.3 程序顺序规则

Happens-before

3.2.4 重排序对多线程的影响

可能影响结果

3.3 顺序一致性

3.3.1 数据竞争与顺序一致性

如果程序是正确同步的,程序的执行将具有顺序一致性(Sequentially Consistent),执行结果不变。同步包括对常用原语(Synchronized、volatile、final)的正确使用。
3.3.2 顺序一致性内存模型
两大特性:
(1) 一个线程中的所有操作必须按照程序的顺序来执行
(2) (不管程序是否同步)所有线程都只能看到一个单一的操作执行顺序。在顺序一致性内存模型中,每个操作都必须原子执行且立刻对所有线程可见。

3.3.3 同步程序的顺序一致性效果

public class SynchronizedExample {
    int a = 0;
    boolean flag = false;
    public synchronized void writer() {
        a = 1;
        flag = true;
    }
    public synchronized void reader() {
        if(flag) {
            int i = a;
            ······
        }
    }
}

顺序一致性模型中,所有操作完全按照程序的顺序串行执行。而在JMM中,临界区内的代码可以重排序(但JMM不允许临界区内的代码“逸出”到临界区之外,那样会破坏监视器的语义)。JMM会在退出临界区和进入临界区这两个关键时间点做一些特别处理,使得线程在这两个时间点具有与顺序一致性模型相同的内存视图。虽然线程A在临界区内做了重排序,但由于监视器互斥执行的特性,这里线程B根本无法“观察”到线程A在临界区内的重排序。
在这里插入图片描述

3.3.4 未同步程序的执行特性

对于未同步或未正确同步的多线程程序,JMM只提供最小安全性:线程执行时读取到的值,要么是之前某个线程写入的值,要么是默认值(0,Null,False),JMM保证线程读操作读取到的值不会无中生有。
为了实现最小安全性,JVM在堆上分配对象时,首先会对内存空间进行清零,然后才会在上面分配对象(JVM内部会同步这两个操作)。因此,在已清零的内存空间(Pre-zeroed Memory)分配对象时,域的默认初始化已经完成。
JMM不保证未同步程序的执行结果与该程序在顺序一致性模型中的执行结果一致。因为如果想保证执行结果一致,JMM需要禁止大量的处理器和编译器的优化,这对程序的执行性能会产生很大影响。而且未同步程序在顺序一致性模型中执行时,整体是无序的,其执行结果往往无法预知。而且,保证未同步程序在这两个模型中的执行结果一致没有意义。
未同步程序在JMM中的执行时,整体上是无序的,其执行结果无法预知。未同步程序在两个模型中的执行特性有如下差异:
(1)顺序一致性模型保证单线程内的操作会按程序的顺序执行,而JMM不保证单线程内的操作会按程序的顺序执行(比如临界区重排序)
(2)顺序一致性模型保证所有线程只能看到一致的操作执行顺序,而JMM不保证所有线程能看到一致的操作执行顺序。
(3)JMM不保证对64位的long型和double型变量的写操作具有原子性,而顺序一致性模型保证对所有的内存读/写操作都具有原子性。
第3个差异与处理器总线的工作机制密切相关。
数据通过总线在处理器和内存之间传递。每次处理器和内存之间的数据传递都是通过一系列步骤来完成的,这一系列步骤称之为总线事务。总线事务包括读事务和写事务。读事务从内存传送数据到处理器,写事务从处理器传送数据到内存,每个事务会读/写内存中一个或多个物理上连续的字。这里的关键是,总线会同步试图并发使用总线的事务。在一个处理器执行总线事务期间,总线会禁止其他的处理器和I/O设备执行内存的读/写。
在这里插入图片描述
总线的这些工作机制可以把所有处理器对内存的访问以串行化的方式来执行。在任意时间点,最多只能有一个处理器可以访问内存。这个特性确保了单个总线事务之中的内存读/写操作具有原子性。
64位的long/double型变量的写操作拆分为两个32位的写操作来执行,可能会分配到不同的总线事务中,因此不具有原子性。

3.4 volatile的内存语义

一个volatile变量的单个读/写操作,与一个普通变量的读/写操作都使用同一个锁来同步,执行效果相同。
锁的happens-before规则保证释放锁和获取锁的两个线程之间的内存可见性。即一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入。
锁的语义决定了临界区代码的执行具有原子性。
volatile的特性:
可见性。对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入。
原子性。对任意单个volatile变量的读/写具有原子性,但类似volatile++复合操作不具有原子性。

3.4.2 volatile写-读建立的happens-before关系

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

3.6.3 读final域的重排序规则

读final域的重排序规则是:在一个线程中,初次读对象引用与初次读该对象包含的final域,JMM禁止处理器重排序这两个操作(注意,仅仅针对处理器)。编译器会在读final域操作的前面插入一个LoadLoad屏障。
读final域的重排序规则可以确保:在读一个对象的final域之前,一定会先读包含这个final域的对象的引用。如果该引用不为Null,那么引用对象的final域一定已经被线程初始化过了。

3.6.4 final域为引用类型

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

public class FinalReferenceExample {

    final int[] intArray;   //final是引用类型
    static FinalReferenceExample obj;

    public FinalReferenceExample() {
        intArray = new int[1];
        intArray[0] = 1;
    }

    public static void writerOne() {	//写线程A执行
        obj = new FinalReferenceExample();
    }

    public static void writerTwo() {	//写线程B执行
        obj.intArray[0] = 2;
    }

    public static void reader() {	//读线程C执行
        if (obj != null) {
            int temp1 = obj.intArray[0];
        }
    }
}

如果想要确保读线程C看到写线程B对数组元素的写入,写线程B和读线程C之间需要使用同步原语(lock或volatile)来确保内存可见性。

3.6.5 为什么final引用不能从构造函数内“逸出”

写final域的重排序规则可以确保:在引用变量为任意线程可见之前,该引用变量指向的对象的final域已经在构造函数中被正确初始化过了。其实,要得到这个效果,还需要一个保证:在构造函数内部,不能让这个被构造对象的引用为其他线程所见,也就是对象引用不能在构造函数中“逸出”。(不懂)

public class FinalReferenceEscapeExample {
    final int i;
    static FinalReferenceEscapeExample obj;

    public FinalReferenceEscapeExample() {
        i = 1;  //操作1:写final域
        obj = this; //操作2:this引用在此"逸出"
    }

    public static void writer() {
        new FinalReferenceEscapeExample();
    }

    public static void reader() {
        if (obj != null) {
            int temp = obj.i;
        }
    }
}

操作1与操作2可能被重排序,使得对象还未完成构造就为其他线程可见(不懂)

3.6.6 final语义在处理器中的实现

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值