java虚拟机JVM--由Volatile关键字引出的java内存模型

前言

本文是由volatile关键字引出的一场血案。。。

首先,我们要知道,volatile关键字的作用是什么:

  • 1.保持可见性:保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的;
  • 2.禁止进行指令的重排序:volatile关键字能确保变量在线程中的操作不会被重排序而是按照代码中规定的顺序进行访问;
  • 3.并不能保证原子性;

由此,引发了思考, volatile关键字为啥能达到这样的效果? 它背后的实现机制是什么,它是怎么实现这个效果的?

这就不得不提到java内存模型了(JMM)了,下面, 我们将虚拟机里探个究竟, 因为虚拟机才是处理这个关键字的地方~

简述JVM内存结构

关于JVM的内存架构, 已在 java虚拟机JVM–java虚拟机的结构 一文中有了详细的阐述, 这里再简单讲讲, 首先贴个JVM内存区域结构图:

在这里插入图片描述

JVM内存划分主要有五部分:堆区、java虚拟机栈、本地方法栈、PC寄存器、方法区。不过在我们使用的sun的JDK自带的Hotspot虚拟机中,java虚拟机栈和本地方法栈是一个区域, 所以我们有时简称栈区。其中左边黄色的部分是线程私有的内存空间, 右边蓝色的部分是进程共享的内存空间

Java的内存模型-JMM

强调一下, 千万不要和JVM的内存结构混淆了, java内存模型(Java Memory Model, 简称JMM)描述的是一组规则或规范,这是一个抽象概念, 并不是真实存在的。JVM依靠这一套规则,去具体的实现了我们的堆区、栈区、方法区的划分,定义了程序中各个变量(包括实例字段,静态字段和构成数组对象的元素)的访问方式。

主内存和工作内存

在JMM中, 有个主内存和工作内存的概念。JVM中运行的实体是线程, 每个线程创建时, JVM都会为其分配工作内存(或者称为栈空间),用于存储线程的私有数据, 而java内存模型规定所有变量都存储在主内存, 主内存是共享内存区域, 所有线程都可以访问。 但是线程对变量的操作(读、取、赋值等)必须在工作内存中进行,那么首先就要把变量从主内存中拷贝到线程自己的工作内存空间,然后才能对变量进行操作,操作完成后再将变量写回主内存。线程不能直接操作主内存中的变量,只能操作自己工作内存中存储的主内存变量的副本。

在这里插入图片描述

对比一下java虚拟机的内存结构(可参考文中第一张图), 是不是很像, 黄色区域都是私有的内存空间, 蓝色区域都是共享的内存空间。 其实java内存结构的实现,都是依靠java模型所描述的规则来完成的,线程的工作内存, 其实对应的就是线程的栈空间, JVM的主内存, 对应的就是 堆区以及方法区。

  • java虚拟机栈区 即对应 java内存模型的 工作内存变量, 都是线程私有的
  • java虚拟机堆区/方法区 即对应 java内存模型主内存, 都是进程共享的

再次强调: java内存模型是抽象的概念, 描述的是一系列规则, 这些规则去实现了 java内存结构的划分,把内存结构分成了 栈区、 堆区、方法区。

理解清楚了java内存结构和java内存模型的关系后, 我们再看看关于主内存和工作内存的具体定义, 就更清晰了:

  • 主内存: 主要存储的是Java实例对象,所有线程创建的实例对象都存放在主内存中,不管该实例对象是成员变量还是方法中的本地变量(也称局部变量),当然也包括了共享的类信息、常量、静态变量。由于是共享数据区域,多条线程对同一个变量进行访问可能会发现线程安全问题。

  • 工作内存:主要存储当前方法的所有本地变量信息(工作内存中存储着主内存中的变量副本拷贝),每个线程只能访问自己的工作内存,即线程中的本地变量对其它线程是不可见的,就算是两个线程执行的是同一段代码,它们也会各自在自己的工作内存中创建属于当前线程的本地变量,当然也包括了字节码行号指示器、相关Native方法的信息。注意由于工作内存是每个线程的私有数据,线程间无法相互访问工作内存,因此存储在工作内存的数据不存在线程安全问题。

清楚了主内存和工作内存后概念后,接着了解一下主内存与工作内存的数据存储类型以及操作方式,根据虚拟机规范,对于一个实例对象中的成员方法而言,如果方法中包含本地变量是基本数据类型(boolean,byte,short,char,int,long,float,double),将直接存储在工作内存的帧栈结构中,但倘若本地变量是引用类型,那么该变量的引用会存储在工作内存的帧栈中,而对象实例将存储在主内存(堆区)中。但对于实例对象的成员变量,不管它是基本数据类型或者包装类型(Integer、Double等)还是引用类型,都会被存储到堆区。至于static变量以及类本身相关信息将会存储在主内存中。需要注意的是,在主内存中的实例对象可以被多线程共享,倘若两个线程同时调用了同一个对象的同一个方法,那么两条线程会将要操作的数据拷贝一份到自己的工作内存中,执行完成操作后才刷新到主内存,这其中就要涉及到多线程加锁的问题了。

硬件内存结构与java内存模型的关系

下图是一个计算机硬件内存结构的简图,这里描述的是一个双核处理器,实际上比这要复杂一些, 可能是四核或八核等, 这里为了理解方便,我们省去了南北桥并将缓存统一为CPU缓存(有些CPU可能是二级缓存,现在主流CPU是三级缓存)。

在这里插入图片描述
一个线程的每次真正执行, 都会占用一个CPU。在CPU内部有一组CPU寄存器,寄存器是cpu直接访问和处理的数据,是一个临时放数据的空间。一般CPU都会从内存取数据到寄存器,然后进行处理,但由于内存的处理速度远远低于CPU,导致CPU在处理指令时往往花费很多时间在等待内存做准备工作,于是在寄存器和主内存间添加了CPU缓存,CPU缓存相对主内存小很多,但访问速度比主内存快得多,如果CPU总是操作主内存中的同一址地的数据,很容易影响CPU执行速度,此时CPU缓存就可以把从内存提取的数据暂时保存到缓存,如果寄存器要取内存中同一位置的数据,直接从缓存中提取,无需直接从主内存取。需要注意的是,寄存器并不每次数据都可以从缓存中取得数据,万一不是同一个内存地址中的数据,那寄存器还必须直接绕过缓存从内存中取数据。所以并不每次都得到缓存中取数据,这种现象有个专业的名称叫做缓存的命中率,从缓存中取就命中,不从缓存中取从内存中取,就没命中,可见缓存命中率的高低也会影响CPU执行性能,这就是CPU、缓存以及主内存间的简要交互过程,总而言之当一个CPU需要访问主存时,会先读取一部分主存数据到CPU缓存(当然如果CPU缓存中存在需要的数据就会直接从缓存获取),进而在读取CPU缓存到寄存器,当CPU需要写数据到主存时,同样会先刷新寄存器中的数据到CPU缓存,然后再把数据刷新到主内存中。

介绍完了硬件的内存结构, 下面我们去梳理下java内存模型和硬件内存结构的关系:

  • 线程的数据, 不管是工作内存, 还是主内存, 首先肯定都是存在于硬件的主内存上的(就是我们的内存条之类的硬件), 然后,当线程被执行时, 数据就可能被硬件的CPU缓存或者CPU寄存器读取, 因为具体的执行, 最后肯定都是要经过CPU处理的, 不管是从java内存模型中的主内存拷贝数据副本到工作内存, 还是线程的工作内存的执行, 最后都要靠硬件的CPU处理的, java内存模型中 工作内存和主内存 的数据,肯定存在于 硬件的内存条中,然后可能存在于CPU缓存和CPU寄存器中。
  • 一定要区分清楚, 硬件内存结构和 java内存模型的区别,硬件内存结构是真是存在的,看得见摸得着,java内存模型是一组规则规范, 是一个抽象概念,java内存模型就类似于我们的if-else语句, 满足某种条件就执行对应的分支。

注意:文中除了这一段, 其他地方的主内存 都是指 JMM概念中的主内存。

java内存模型的原子性、可见性、有序性

介绍完了java内存模型的基本概念后, 不禁思索一下,为什么需要JMM呢? 经过前面的介绍, 我们知道,java线程执行时, 会先到主内存中去拷贝一份数据到工作内存, 然后操作完以后,再把数据写回主内存。但是, 如果有多个线程同时取读写, 比如线程A、B、C同时执行,主内存有一个int x=0,线程A对x进行+1操作,线程B进行+2操作,线程C打印出X的值,那么,最终线程C打印出来的值是哪一个呢? 如果不做任何处理,打印出来的值,既可能是0,也可能是1,2或者3。因为线程的执行, 是去抢时间片, 谁先抢到,谁就执行。如果不做任何处理, 岂不是很混乱,结果不可控了,这就涉及到多线程的线程安全问题了。

为了解决这一类问题, java在JVM中定义了一组规则,这组规则就称为java内存模型(Java Memory Model)。Java内存模型用来屏蔽各个硬件平台和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果。那么Java内存模型规定了哪些东西呢,它定义了程序中变量的访问规则,往大一点说是定义了程序执行的次序。这些规则围绕程序执行的 原子性、可见性、有序性 展开。

1.原子性

在Java中,对基本数据类型的变量的读取和赋值操作是原子性操作,即这些操作是不可被中断的,要么执行,要么不执行,这就是原子性。

上面一句话虽然看起来简单,但是理解起来并不是那么容易。看下面一个例子i:

请分析以下哪些操作是原子性操作:

x = 1;         //语句1
y = x;         //语句2
x++;           //语句3
x = x + 1;     //语句4

咋一看,有些朋友可能会说上面的4个语句中的操作都是原子性操作。其实只有语句1是原子性操作,其他三个语句都不是原子性操作。

语句1是直接将数值1赋值给x,也就是说线程执行这个语句的会直接将数值1写入到工作内存中。

语句2实际上包含2个操作,它先要去读取x的值,再将x的值写入工作内存,虽然读取x的值以及 将x的值写入工作内存 这2个操作都是原子性操作,但是合起来就不是原子性操作了。

同样的,x++和 x = x+1包括3个操作:读取x的值,进行加1操作,写入新的值。

所以上面4个语句只有语句1的操作具备原子性。

也就是说,只有简单的读取、赋值(而且必须是将数字赋值给某个变量,变量之间的相互赋值不是原子操作)才是原子操作。

不过这里有一点需要注意:在32位平台下,对64位数据的读取和赋值是需要通过两个操作来完成的,不能保证其原子性。但是在最新的JDK中,JVM已经保证对64位数据的读取和赋值也是原子性操作了。

从上面可以看出,Java内存模型只保证了基本读取和赋值是原子性操作,如果要实现更大范围操作的原子性,可以通过synchronized和Lock来实现。由于synchronized和Lock能够保证任一时刻只有一个线程执行该代码块,那么自然就不存在原子性问题了,从而保证了原子性。

2.指令重排

计算机在执行程序时,为了提高性能,在不影响 程序执行的结果下,编译器和处理器可能对代码执行的先后顺序进行调整, 这个过程被称为称为指令做重排,指令重排有以下3种情况:

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

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

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

3.可见性

对于可见性,Java提供了volatile关键字来保证可见性。

当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。

而普通的共享变量不能保证可见性,因为普通共享变量被修改之后,什么时候被写入主存是不确定的,当其他线程去读取时,此时内存中可能还是原来的旧值,因此无法保证可见性。

另外,通过synchronized和Lock也能够保证可见性,synchronized和Lock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。

4.有序性

在Java内存模型中,允许编译器和处理器对指令进行重排序,但是重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。在Java里面,可以通过volatile关键字来保证一定的“有序性”。另外可以通过synchronized和Lock来保证有序性,很显然,synchronized和Lock保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性。

除了通过关键字, java内存模型内部肯定也需要一些执行顺序的规则,才能保证程序的正确执行。这些不需要任何外在手段干预就能保证有序性的规则, 被称为happens-before 原则, 主要有八条:

下面就来具体介绍下happens-before原则(先行发生原则):

  • 1.程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作
  • 2.锁定规则:一个unLock操作先行发生于后面对同一个锁额lock操作
  • 3.volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作
  • 4.传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C
  • 5.线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作
  • 6.线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生
  • 7.线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行
  • 8.对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始

这8条原则摘自《深入理解Java虚拟机》。

这8条规则中,前4条规则是比较重要的,后4条规则都是显而易见的。

第二条规则也比较容易理解,也就是说无论在单线程中还是多线程中,同一个锁如果出于被锁定的状态,那么必须先对锁进行了释放操作,后面才能继续进行lock操作。

第三条规则是一条比较重要的规则,也是后文将要重点讲述的内容。直观地解释就是,如果一个线程先去写一个变量,然后一个线程去进行读取,那么写入操作肯定会先行发生于读操作。

第四条规则实际上就是体现happens-before原则具备传递性。

volatile关键字

前面说了这么多, 都是铺垫,我们终于进入了正题:volatile关键字,再次解释下加上了这个关键字的含义:

  • 1、保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。

  • 2、禁止进行指令重排序。

下面介绍些这两个特征。

1.volatile的可见性

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

public class VolatileSafeTest {
    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 VolatileSafeTest {
    public static int i =0;

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

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

public class VolatileVisibilityTest {

    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变量正是通过这种写-读方式实现对其他线程可见。

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;

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

volatile使用场景总结

最后, 再总结一下volatile常用的两个使用场景吧

  • 1、flag状态标记: 前面的例子提到,如果使用Boolean值或者int等基本数据类型做标记, 则最好加上volatile, 否则可能不同步
  • 2、 单例模式的双重检查。

参考资料:

http://www.importnew.com/18126.html

http://tutorials.jenkov.com/java-concurrency/java-memory-model.html

https://blog.csdn.net/javazejian/article/details/72772461

  • 2
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值