Java内存模型与volatile关键字解析

提示:文末有本篇提纲

一.Java内存模型

1.硬件的效率与一致性(硬件层面)

在讲解java虚拟机并发知识之前,我们首先应该了解一下物理计算机中(硬件层面)的中的并发问题,它和下面要讲的软件层面的并发模型有一定的相似之处。我们知道,现代计算机大多是基于冯·诺伊曼结构来设计的,这种结构将CPU与存储器分开。由于CPU需要和储存器进行读写(“I/0”)操作,而计算机的储存设备与处理器的运算速度有几个数量级的差距,为了减少在“I/0”操作上面浪费的时间,现代计算机不得不加入一层读写速度尽可能接近处理器运算速度的高速缓存将运算所需要的数据复制到高速缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存中,这样处理器就无需等待缓慢的内存读写了。

处理器、高速缓存、主内存之间的关系如图:

Java内存模型与volatile关键字解析_第1张图片
处理器、高速缓存、主内存之间的关系.png

基于高速缓存的储存结构很好的解决了处理器与内存的速度矛盾,但是也带来了另一个问题——缓存一致性问题。在多处理器系统中,每个处理器都有自己的高速缓存,而他们又共享一个主内存,当多个处理器的运算任务都涉及同一块内存区域时,将可能导致各自的缓存数据不一致。为了解决缓存一致性问题,我们需要各个处理器在访问主内存和缓存时都要遵守一些协议,这里边比较著名的有:MESI、MSI协议等。

2.并发编程模型的分类(软件层面)

在并发编程中,我们需要处理的两个关键的问题是:线程之间如何通信以及线程之间如何同步(线程!线程!线程!)
  其中通信是指线程之间以何种方式交换信息,在命令式编程中,通信的方式有两种:共享内存信息传递。在共享内存的并发模型中,线程之间共享程序的公共状态,线程之间通过读-写内存中的公共状态来进行隐式通信;在消息传递的并发模型中,线程之间没有公共状态,线程之间必须通过明确的发送消息来显示的通信(类似于Android中的Handler)
  同步是指程序用于控制不同线程之间操作发生顺序的机制。在共享内存模型中,同步是显示执行的,程序员必须显示的指定某个方法或者某段代码需要在线程之间互斥的执行(如Java的关键字synchronized用法)。在消息传递的并发模型中,由于消息的发送必须在消息接收之前,因此同步是隐式进行的。

3.Java内存模型

(1)什么是Java内存模型

不同架构的物理机器可以拥有不一样的内存模型,而Java虚拟机也有自己的内存模型。这里的“内存模型”可以理解成在特定的操作协议下,对特定内存或者高速缓存进行读写访问的过程的抽象。Java并发采用的是上述共享内存模型
  在Java中,所有的实例域数组元素储存在堆内存中,堆内存在线程之间共享;而局部变量、异常处理参数存在于方法栈中,不会在线程之间共享,他们不会有内存可见性问题,也不受内存模型的影响。

(2)Java内存模型是用来干什么的

Java线程之间的通信由Java内存模型(Java Memory Model,简称JMM)控制,JMM的主要目标是定义程序中各个共享变量的访问规则,即通过在虚拟机中将共享变量储存到内存和从内存中取出变量这样的底层细节,来屏蔽各个硬件平台和操作系统的内存访问差异,以实现让Java程序在各个平台下都能达到一致的内存访问效果。
  我们再强调一遍,这里以及下面要讲的“变量”,都指的是“共享变量”,也就是我们上面说的储存在堆内存中的实例域数组元素,而不包括局部变量等。

(3)“工作内存”的概念

JMM中Java线程与主内存的通信过程,非常类似于最开始我们提到的处理器与主内存之间的通信过程,只不过一个是软件层面,一个是硬件层面。Java线程之间的共享变量储存在主内存中,每个线程有一个私有的工作内存,工作内存中储存了该线程需要操作的共享变量的主内存副本拷贝线程对共享变量的操作均必须在工作内存中进行,而不能直接读写主内存中的变量;不同线程之间也无法直接访问对方工作内存中的变量,线程间变量的值的传递均需要通过主内存来传递。

上面这几段话中,我们需要解释两点:
  ①“工作内存”是JMM的一个抽象概念,并不是真实的存在,它涵盖了:缓存、写缓冲区、寄存器以及其他的硬件和编译优化。
  ②对于工作内存中主内存副本拷贝,并不是说真的会把一整个对象拷贝出来(当然要整个拷贝也可以,但是没有虚拟机会这么蠢的实现),而是将这个对象的引用对象在某个线程中被访问到的字段被拷贝出来。

线程、工作内存的、主内存关系如下图:

Java内存模型与volatile关键字解析_第2张图片
![线程、工作内存的、主内存之间的关系]

二.并发编程中的三个概念

上面我们已经讲了JMM的一些基本的东西,下面我们来聊一聊JMM中进行的一些具体的操作。

1.原子性

原子性是指:即一个或多个操作,作为单独的不可分割的单元运行,中间不能被打断, 直到语句执行完毕。这句话应该很好理解,不做过多说明,下面会举例子。Java中定义了8种原子操作。分别是:

①lock(锁定):作用于主内存中的变量,把一个变量标识为一条线程独自占用的状态。
②unlock(解锁):作用于主内存中的变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程使用。
③read(读取):作用于主内存中的变量,它把一个变量的值从主内存中传输到线程的工作内存,以便随后的load动作使用。
④load(载入):作用于工作内存中的变量,它把read操作从主内存中得到的变量值放入工作内存的副本中。
⑤use(使用):作用于工作内存中的变量,它把工作内存中的一个变量的值传递给执行引擎。
⑥assgin(赋值):作用于工作内存中的变量,它把一个从执行引擎中接受到的值,赋给工作内存中的变量。
⑦store(储存):作用于工作内存中的变量,它把工作内存中的一个变量的值传送到主内存中,以便随后的write操作使用。
⑧write(写入):作用于主内存中的变量,它把store操作中从工作内存中得到的变量的值放入主内存中的变量中。

举个例子:int a = 10;这句代码是一个赋值操作,属于上面8中原子操作中的一种,线程执行这个语句会直接将数值10写入工作内存中,没有什么问题;
  那么我们再看b = a(b为int型)这句代码,他是不是原子语句呢?答案是否定的,为什么呢?因为这句代码实际上包含两个操作:第一步,先去读取的变量a的值(read);第二步,将a的值写入工作内存,虽然这两步操作都是原子操作,但是合起来就不是原子操作了。同样的,a = a + 10也不是原子操作,它包括三个过程:读取内存中a的值;进行加操作;像内存中写入新值。

上面说了一些最基本的读取、赋值等操作是原子操作,如果要实现更大范围内的的原子语句,可以通过synchronized操作来实现。对于synchronized关键字,我们在Android设计模式之——单例模式这篇文章中“双重检查锁”一段中已经做了较为详细的讲解,这里再强调一下,synchronized可以保证任一时刻只有一个线程执行该代码块,那么自然就不存在原子性问题了,从而保证了原子性。

2.可见性

可见性是指当多个线程需要使用同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改后的值。举个例子,假设现在有两个线程,线程1执行:

int a = 0;
a = 1;

线程2执行:

b = a

当线程1执行a = 1;这条语句时,会把变量a的初始值0加载到线程1的工作内存中,然后赋值为1,这个时候工作内存中的a的值已经为1了,但是还没有来得及刷新主内存中变量a的值,此时线程2开始执行b = a这条语句,这个时候由于主内存中a的值仍然是0,因此b最终得到的值仍然是0。
  这就是可见性问题,线程1对主存中的变量的值做了修改,但是线程2并没有及时的得到反馈。JMM是通过“在变量修改后将新值同步回主内存中,在变量读取前从内存中刷新变量”这种方法来实现可见性的。无论是普通变量还是后文要讲的volatile变量,均是如此。
  Java中的volatile变量能够实现变量的可见性的原因是,volatile的特殊规则保证了新值能够立即同步到主内存,以及每次使用前立即从主内存刷新。而普通变量什么时候刷新回主内存,这个是不确定的,因此不能保证可见性。
  除了volatile之外,Java中可以通过synchronizedfinal关键字来实现可见性。但是两者实现的原理不同,synchronized是通过同步块代码的“原子性”,也就是对一个变量执行unLock之前,必须把此变量同步回主内存中,之后其他线程才允许访问该段代码,来实现的。
  final关键字的可见性是指:被final修饰的字段,在构造器中一旦完成初始化操作,那么这个变量在主存中的值就确定了,无论其他几个线程何时访问,它的值都是不变的。

3.有序性

(1)有序性及指令重排

有序性:即程序按照代码的先后顺序执行。在执行程序时为了提高性能,编译器处理器常常会对指令做重排序重排序分三种类型:
  ①.编译器优化重排序。编译器在不改变单线程程序语义的前提下,可重新安排语句的执行顺序。
  ②.指令集并行的重排序。现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
  ③.内存系统的重排序。由于处理器使用缓存读/写缓冲区,这使得加载和储存操作看上去可能是在乱序执行。

上述的三种重排序的情况,第一种是属于编译器重排序,2和3属于处理器重排序。JMM中允许编译器和处理器对指令进行重排序,但是重排序的过程不会影响到单线程的执行,却会影响到多线程并发执行的正确的性。上面的定义比较难以理解,下面我们举几个例子来说明:
假如我们的程序中有下面一段代码:

int a = 10; //①
int b = 1;  //②
a = a + b;  //③
b = b - a;  //④

如果程序遵守有序性执行的原则,这段代码的执行顺序应当是“①②③④”,但是一般来说,处理器为了提高程序的运行效率,就会对输入的代码执行顺序进行优化,他不保证程序中各个语句的执行顺序同代码中的顺序一致,但是保证程序最终的执行结果和代码顺序的执行结果是一样的,我们举例来看一下:
  上面的代码中的①②句,int a = 10;int b = 1;这两个语句谁先执行对程序的结果并没有什么影响,那么在执行的过程中就可能发生执行顺序的互换;但是可不可以把③④两句,a = a + b;b = b - a;的顺序互换呢?答案是否定的,因为第四句对第三句的结果有数据依赖性,因为第四句要用到第三句的结果,如果两者一互换,显然最终的结果会发生改变。
  虽然处理器的重排序不会影响单线程的执行结果,但是在多线程中就可能出现问题,这里我们选取《深入理解Java虚拟机》中的一段代码来举例明:

Map configOptions;
char[] configText;
boolean initialized = flase;

/**
 *该段代码在A线程中执行,模拟读取配置信息,读取完后将initialized设置为true以通知其他线程可用
 */
configOptions = new HashMap();
configText = readConfigFile(fileName);  ①
processConfigOptions(configText,configOptions);
initialized = true;     ②

/**
 *假设以下代码在B线程中执行,等待initialized为true之后,代表线程A已经把配置信息初始化完成
 */
while(!initialized){
    sleep();
}
dosomethingWithConfig();    //使用A线程初始化配置好的信息

在该段代码中,①与②处的代码没有数据依赖性,因此可能被互换顺序,假设此时真的发生了互换,那么在还没有读取配置信息的时候,initialized的值变为了true,线程B中的while()循环以为已经配置信息已经初始化完成,结束sleep开始做事情,这个时候程序就要炸了。
  因此,指令重排序不会影响单个线程的执行,但是会影响到线程并发执行的正确性。也就是说,要想并发程序正确地执行,必须要保证原子性、可见性以及有序性。只要有一个没有被保证,就有可能会导致程序运行不正确。

(2)JMM中的有序性相关

很多时候,Java中的有序性可以用synchronizedvolatile关键字实现,关于他们的实现机制,我们在下文中会讲解。
  另外,在Java内存模型中存在一些先天的“有序性“,这些先天的有序性总结一下就是happens-before(线性发生)原则。如果两个操作的执行次序不符合happens-before原则中的任意一条,那么它们就不能保证它们的有序性,虚拟机可以随意地对它们进行重排序。
ppens-before原则总结起来有8条(摘自《深入理解Java虚拟机》):

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

上面的8条规则都比较好理解~~这里需要说下第①点,实际上这点就是我们之前将的有序性问题,这里再强调一下:虽然这里说“书写在前面的操作先行发生于书写在后面的操作”,但实际上,虚拟机可能会对程序进行指令重排。在单个线程中(这点一定要强调),虽然进行重排序,但是最终执行的结果是与程序顺序执行的结果一致的,它只会对不存在数据依赖性的指令进行重排序。这个规则是用来保证程序在单线程中执行结果的正确性,但无法保证程序在多线程中执行的正确性。

三.volatile关键字解析

1.volatile关键字的作用(保证可见性和有序性)

一旦一个共享变量被volatile修饰之后,那么就具备了两层语义:
(1)保证了这个变量对所有线程的可见性。
  一个共享变量被volatile修饰之后,假设有多个线程用到了这个变量。如果一个线程中对这个变量的值做了修改。那么第一步:该线程中修改后的值立即刷新同步主内存中对应的值;第二步:通知其他线程,他们的工作内存中原来缓存的该变量已过期无效,需重新从主内存中读取
(2)禁止进行指令重排序优化,即保证了有序性
  ①当程序执行到volatile变量的读操作或者写操作时,在其前面的操作的更改肯定全部已经进行,且结果已经对后面的操作可见;在其后面的操作肯定还没有进行;
  ②在进行指令优化时,不能将在对volatile变量访问的语句放在其后面执行,也不能把volatile变量后面的语句放到其前面执行。
但是应当注意,volatile关键字并不一定能保证原子性。
这里还是举刚才的例子来说明这个问题:

Map configOptions;
char[] configText;
volatile boolean initialized = flase;   //注意initialized变量申明为了volatile类型

/**
 *该段代码在A线程中执行,模拟读取配置信息,读取完后将initialized设置为true以通知其他线程可用
 */
configOptions = new HashMap();
configText = readConfigFile(fileName);  ①
processConfigOptions(configText,configOptions);
initialized = true;     ②

/**
 *假设以下代码在B线程中执行,等待initialized为true之后,代表线程A已经把配置信息初始化完成
 */
while(!initialized){
    sleep();
}
dosomethingWithConfig();    //使用A线程初始化配置好的信息

这段代码相比前面的,我们将initialized变量申明为了volatile类型,这个时候就不会存在上面说的可见性以及指令重排序问题了。

2.volatile关键字不一定能保证原子性

仍然采用《深入理解Java虚拟机》上面的一个例子来说:

public class Test  {
    public static volatile int race = 0;

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

    public static void main(String[] args) {
        final Test test = new Test();
        for(int i=0;i<10;i++){
            new Thread(){
                public void run() {
                    for(int j=0;j<1000;j++)
                        increase();
                };
            }.start();
        }

        while(Thread.activeCount()>1)  //保证前面的线程都执行完
            Thread.yield();
        System.out.println(test.inc);
    }
}

如果我们运行上面的代码,会发现每次运行结果都不一致,都是一个小于10000的数字。这个时候我们可能就会疑惑,上面不是说了volatile关键字可以保证可见性吗?也就是说,当一个线程中的这个变量被修改这之后,会通知其他线程的工作内存中缓存的该变量无效,其他线程就会回到主内存中去读取该变量的值~~那么这里为什么还会出现这个问题呢?
  问题就出在这个"race++"上面,自增操作不是一个原子操作,他仍然分为三个步骤:先读race的值,然后进行加1操作,然后再更新主内存中的值。假如某一个线程1读取了这个变量,但是还没来得及改变这个变量的值,那么此时主内存中的值当然也是不变的;此时又有一个线程2读取这个变量的值,那么线程2读取到的值仍然是原来没有改变的值,一次类推,最后的结果自然也就不对了。
  由于volatile关键字只保证可见性,所以在不符合下面两条运算规则的场景中,我们仍然需要通过加锁(使用synchronized)来保证原子性(这也是一般情况下单独使用volatile关键字的场景)。
  ①运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量。
  ②变量不需要与其他状态变量共同参与不变式。
这两个条件的定义比较拗口,简单来说,多线程中,只要你们保证代码块的原子性,就可以单独使用volatile关键字。

我们给上面的代码加锁之后得:

public class Test  {
    public static volatile int race = 0;

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

    public static void main(String[] args) {
        final Test test = new Test();
        for(int i=0;i<10;i++){
            new Thread(){
                public void run() {
                    for(int j=0;j<1000;j++)
                        increase();
                };
            }.start();
        }

        while(Thread.activeCount()>1)  //保证前面的线程都执行完
            Thread.yield();
        System.out.println(test.inc);
    }
}

这样就保证了increase()方法中race++;操作的原子性。

3.volatile的原理和实现机制

关于volatile实现的原理和机制,这里用《深入理解Java虚拟机》中的一段话来解释:
  "...通过对比就会发现,关键变化在于有volatile修饰的变量,赋值后多了一个‘lock add1 $0x0, (%esp)'操作,这个操作相当于一个内存屏障..."内存屏障会提供3个功能:
  ①它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;
  ②它会强制将对缓存的修改操作立即写入主存;
  ③如果是写操作,它会导致其他CPU中对应的缓存行无效。
文止

Java内存模型与volatile关键字解析_第3张图片
提纲.png

为什么提纲要放在文末呢?好吧是因为放在题头实在太丑了;为什么要放张图片上去呢?好吧因为不支持MD提纲语法啊~~我能有什么办法,我也很绝望啊......

站在巨人的肩膀上摘苹果:
  《深入理解Java虚拟机》
  《深入理解Java内存模型》

你可能感兴趣的:(Java内存模型与volatile关键字解析)