Java并发编程 -- Volatile解析


前言

Volatile是轻量级的Synchronized。那么Volatile轻在哪,它的实现原理是什么样的?要想了解这些就要搞清楚java内存模型(JMM)等内容


一、Java内存结构和Java内存模型

1.1 Java内存结构

Java的内存结构既是Java的运行时数据区, 此部分参考<深入理解Java虚拟机>书的第2章,里面讲的很详细,很好

1.2 Java内存模型

Java内存模型也叫JMM, <深入理解Java虚拟机>书中说到, 内存模型可以理解为在特定的操作协议下,对特定的内存或高速缓存进行读写访问的过程抽象. 计算机有自己的内存模型,而Java虚拟机也有自己的内存模型, 两者可以类比,下面先介绍计算机的内存模型

1.2.1 硬件内存架构

  • 处理器与内存交互是不可避免的,如读取运算数据、 存储运算结果等,这个I/O操作就是很难消除的(无法仅靠寄存器来完成所有运算任务)。由于计算机 的存储设备与处理器的运算速度有着几个数量级的差距,所以现代计算机系统都不得不加入一层或多 层读写速度尽可能接近处理器运算速度的高速缓存(Cache)来作为内存与处理器之间的缓冲:将运算 需要使用的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存之中,这样处 理器就无须等待缓慢的内存读写了。
  • 在多路处理器系统中,每 个处理器都有自己的高速缓存,而它们又共享同一主内存(Main Memory),这种系统称为共享内存 多核系统(Shared Memory Multiprocessors System),如下图所示。
    处理器、高速缓存、主内存间的交互关系
  • 详细的来说,在CPU内部有一组CPU寄存器,寄存器是cpu直接访问和处理的数据,是一个临时放数据的空间, 由于从内存中取数据的速度远远小于CPU的计算速度,于是就加了多级缓存,图示如下:
    在这里插入图片描述
  • MESI协议: 基于高速缓存的存储交互很好地解决了处理器与内存速度之间的矛盾,但是也为计算机系统带来 更高的复杂度,它引入了一个新的问题:缓存一致性(Cache Coherence)。当多个处理器的运算任务都涉及 同一块主内存区域时,将可能导致各自的缓存数据不一致。如果真的发生这种情况,那同步回到主内 存时该以谁的缓存数据为准呢?为了解决一致性的问题,需要各个处理器访问缓存时都遵循一些协 议,在读写时要根据协议来进行操作,这类协议有MSI、MESI(Illinois Protocol)、MOSI、 Synapse、Firefly及Dragon Protocol等。

1.2.2 Java内存模型

  • Java内存模型(即Java Memory Model,简称JMM)本身是一种抽象的概念,并不真实存在,它描述的是一组规则或规范,通过这组规范定义了程序中各个变量(包括实例字段,静态字段和构成数组对象的元素)的访问方式.
  • JMM规定了共享变量都存在主内存中,主内存是共享的内存区域,所有线程都可访问.每个线程创建时JVM都会为其创建一个工作空间,线程的工作内存中保 存了被该线程使用的变量的主内存副本,线程对变量的所有操作(读取、赋值等)都必须在工作内 存中进行,而不能直接读写主内存中的数据。不同的线程之间也无法直接访问对方工作内存中的变 量,线程间变量值的传递均需要通过主内存来完成,线程、主内存、工作内存三者的交互关系如图所示:
    在这里插入图片描述

1.2.3 JMM和Java运行时数据区的关系

  • JMM和Java运行时数据区是不同概念,.JMM描述的是通过这组规则控制程序中各个变量在共享数据区域和私有数据区域的访问方式. Java运行时数据区是值JVM规范中把其管理的内存分为了几个区域,每个区域存放不同的数据,与其关系比较大的是GC.
  • 但是JMM和Java运行时数据区的相似点在于: 都有共享数据区域和私有的数据区域, 可以理解为, 主内存对应于堆和方法区, 工作内存对应于栈,程序计数器,本地方法栈

1.2.4 JMM和硬件内存结构的关系

  • 问题一: JMM中的主内存对应着物理硬件的内存, 工作内存对应着CPU缓存,寄存器???这样好像是错的,怎么理解呢???

二、原子性、可见性与有序性

介绍完Java内存模型的相关操作和规则后,我们再整体回顾一下这个模型的特征。Java内存模型是 围绕着在并发过程中如何处理原子性、可见性和有序性这三个特征来建立的,我们逐个来看一下哪些 操作实现了这三个特性。

1.原子性

由Java内存模型来直接保证的原子性变量操作包括read、load、assign、use、store和write这六个, 我们大致可以认为,基本数据类型的访问、读写都是具备原子性的(例外就是long和double的非原子性 协定,读者只要知道这件事情就可以了,无须太过在意这些几乎不会发生的例外情况)。 如果应用场景需要一个更大范围的原子性保证(经常会遇到),Java内存模型还提供了lock和 unlock操作来满足这种需求,尽管虚拟机未把lock和unlock操作直接开放给用户使用,但是却提供了更 高层次的字节码指令monitorenter和monitorexit来隐式地使用这两个操作。这两个字节码指令反映到Java 代码中就是同步块——synchronized关键字,因此在synchronized块之间的操作也具备原子性。

2.可见性

可见性就是指当一个线程修改了共享变量的值时,其他线程能够立即得知这个修改。上文在讲解 volatile变量的时候我们已详细讨论过这一点。Java内存模型是通过在变量修改后将新值同步回主内 存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性的,无论是 普通变量还是volatile变量都是如此。普通变量与volatile变量的区别是,volatile的特殊规则保证了新值 能立即同步到主内存,以及每次使用前立即从主内存刷新。因此我们可以说volatile保证了多线程操作 时变量的可见性,而普通变量则不能保证这一点。 除了volatile之外,Java还有两个关键字能实现可见性,它们是synchronized和final。同步块的可见 性是由“对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行store、write操 作)”这条规则获得的。而final关键字的可见性是指:被final修饰的字段在构造器中一旦被初始化完 成,并且构造器没有把“this”的引用传递出去(this引用逃逸是一件很危险的事情,其他线程有可能通 过这个引用访问到“初始化了一半”的对象),那么在其他线程中就能看见final字段的值. 如下面代码所示,变量i与j都具备可见性,它们无须同步就能被其他线程正确访问。
代码如下(示例):

public static final int i; 
public final int j; 
static { i = 0; 
// 省略后续动作 
}
{ 
// 也可以选择在构造函数中初始化 
j = 0; 
// 省略后续动作
}

3. 有序性

Java内存模型的有序性在前面讲解volatile时也比较详细地讨论过了,Java程序中天然的有序性可以 总结为一句话:如果在本线程内观察,所有的操作都是有序的;如果在一个线程中观察另一个线程, 所有的操作都是无序的。前半句是指“线程内似表现为串行的语义”(Within-Thread As-If-Serial Semantics),后半句是指“指令重排序”现象和“工作内存与主内存同步延迟”现象。 Java语言提供了volatile和synchronized两个关键字来保证线程之间操作的有序性,volatile关键字本 身就包含了禁止指令重排序的语义,而synchronized则是由“一个变量在同一个时刻只允许一条线程对 其进行lock操作”这条规则获得的,这个规则决定了持有同一个锁的两个同步块只能串行地进入。

三、happens-before 原则

如果Java内存模型中所有的有序性都仅靠volatile和synchronized来完成,那么有很多操作都将会变 得非常啰嗦,但是我们在编写Java并发代码的时候并没有察觉到这一点,这是因为Java语言中有一 个“先行发生”(Happens-Before)的原则。这个原则非常重要,它是判断数据是否存在竞争,线程是 否安全的非常有用的手段。依赖这个原则,我们可以通过几条简单规则一揽子解决并发环境下两个操 作之间是否可能存在冲突的所有问题,而不需要陷入Java内存模型苦涩难懂的定义之中。 现在就来看看“先行发生”原则指的是什么。先行发生是Java内存模型中定义的两项操作之间的偏 序关系,比如说操作A先行发生于操作B,其实就是说在发生操作B之前,操作A产生的影响能被操作B 观察到,“影响”包括修改了内存中共享变量的值、发送了消息、调用了方法等。这句话不难理解,但 它意味着什么呢?我们可以举个例子来说明一下。

// 以下操作在线程A中执行 
i = 1; 
// 以下操作在线程B中执行 
j = i; 
// 以下操作在线程C中执行 
i = 2;

假设线程A中的操作“i=1”先行发生于线程B的操作“j=i”,那我们就可以确定在线程B的操作执行 后,变量j的值一定是等于1,得出这个结论的依据有两个:一是根据先行发生原则,“i=1”的结果可以 被观察到;二是线程C还没登场,线程A操作结束之后没有其他线程会修改变量i的值。现在再来考虑 线程C,我们依然保持线程A和B之间的先行发生关系,而C出现在线程A和B的操作之间,但是C与B没 有先行发生关系,那j的值会是多少呢?答案是不确定!1和2都有可能,因为线程C对变量i的影响可能 会被线程B观察到,也可能不会,这时候线程B就存在读取到过期数据的风险,不具备多线程安全性。 下面是Java内存模型下一些“天然的”先行发生关系,这些先行发生关系无须任何同步器协助就已 经存在,可以在编码中直接使用。如果两个操作之间的关系不在此列,并且无法从下列规则推导出 来,则它们就没有顺序性保障,虚拟机可以对它们随意地进行重排序。

  • 程序次序规则(Program Order Rule):在一个线程内,按照控制流顺序,书写在前面的操作先行 发生于书写在后面的操作。注意,这里说的是控制流顺序而不是程序代码顺序,因为要考虑分支、循 环等结构。
  • 管程锁定规则(Monitor Lock Rule):一个unlock操作先行发生于后面对同一个锁的lock操作。这 里必须强调的是“同一个锁”,而“后面”是指时间上的先后。
  • volatile变量规则(Volatile Variable Rule):对一个volatile变量的写操作先行发生于后面对这个变量 的读操作,这里的“后面”同样是指时间上的先后。
  • 线程启动规则(Thread Start Rule):Thread对象的start()方法先行发生于此线程的每一个动作。
  • 线程终止规则(Thread Termination Rule):线程中的所有操作都先行发生于对此线程的终止检 测,我们可以通过Thread::join()方法是否结束、Thread::isAlive()的返回值等手段检测线程是否已经终止 执行。
  • 线程中断规则(Thread Interruption Rule):对线程interrupt()方法的调用先行发生于被中断线程 的代码检测到中断事件的发生,可以通过Thread::interrupted()方法检测到是否有中断发生。
  • 对象终结规则(Finalizer Rule):一个对象的初始化完成(构造函数执行结束)先行发生于它的 finalize()方法的开始。
  • 传递性(Transitivity):如果操作A先行发生于操作B,操作B先行发生于操作C,那就可以得出 操作A先行发生于操作C的结论。

四、volatile内存语义

volatile在并发编程中很常见,但也容易被滥用,现在我们就进一步分析volatile关键字的语义。volatile是Java虚拟机提供的轻量级的同步机制。volatile关键字有如下两个作用

  • 保证被volatile修饰的共享gong’x变量对所有线程总数可见的,也就是当一个线程修改了一个被volatile修饰共享变量的值,新值总数可以被其他线程立即得知。

  • 禁止指令重排序优化。

4.1 volatile的可见性

关于volatile的可见性作用,我们必须意识到被volatile修饰的变量对所有线程总数立即可见的,对volatile变量的所有写操作总是能立刻反应到其他线程中,但是对于volatile变量运算操作在多线程环境并不保证安全性,如下

public class VolatileVisibility {
    public static volatile int i =0;

    public static void increase(){
        i++;
    }
}

正如上述代码所示,i变量的任何改变都会立马反应到其他线程中,但是如此存在多条线程同时调用increase()方法的话,就会出现线程安全问题,毕竟i++;操作并不具备原子性,该操作是先读取值,然后写回一个新值,相当于原来的值加上1,分两步完成,如果第二个线程在第一个线程读取旧值和写回新值期间读取i的域值,那么第二个线程就会与第一个线程一起看到同一个值,并执行相同值的加1操作,这也就造成了线程安全失败,因此对于increase方法必须使用synchronized修饰,以便保证线程安全,需要注意的是一旦使用synchronized修饰方法后,由于synchronized本身也具备与volatile相同的特性,即可见性,因此在这样种情况下就完全可以省去volatile修饰变量。

public class VolatileVisibility {
    public static int i =0;

    public synchronized static void increase(){
        i++;
    }
}

现在来看另外一种场景,可以使用volatile修饰变量达到线程安全的目的,如下


public class VolatileSafe {

    volatile boolean close;

    public void close(){
        close=true;
    }

    public void doWork(){
        while (!close){
            System.out.println("safe....");
        }
    }
}

由于对于boolean变量close值的修改属于原子性操作,因此可以通过使用volatile修饰变量close,使用该变量对其他线程立即可见,从而达到线程安全的目的。那么JMM是如何实现让volatile变量对其他线程立即可见的呢?实际上,当写一个volatile变量时,JMM会把该线程对应的工作内存中的共享变量值刷新到主内存中,当读取一个volatile变量时,JMM会把该线程对应的工作内存置为无效,那么该线程将只能从主内存中重新读取共享变量。volatile变量正是通过这种写-读方式实现对其他线程可见(但其内存语义实现则是通过内存屏障,稍后会说明)。

4.2 volatile禁止重排优化

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应该都有了比较全面的认识,总而言之,我们应该清楚知道,JMM就是一组规则,这组规则意在解决在并发编程可能出现的线程安全问题,并提供了内置解决方案(happen-before原则)及其外部可使用的同步手段(synchronized/volatile等),确保了程序执行在多线程环境中的应有的原子性,可视性及其有序性。

问题

问题一

  • 单核CPU来说, 高速缓存是共享的还是每个线程私有的
    问题二:
  • jmm和硬件的对应关系
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值