如何彻底理解volatile关键字?

无敌码农 无敌码农

导读


最近面试,你又被volatile关键字虐了吗?这个问题,是不是问得有点扎心了!的确,有很多朋友反馈面试中在涉及考察Java并发编程知识的时候,经常会被问到volatile关键字。对于有些公司如果你能回答出volatile关键字的基本作用及原理,如:"volatile关键字可以实现线程间的可见性,之所以可以实现这一点,原因在于JVM会保证被volatile修饰的变量,在线程栈中被线程使用时都会主动从共享内存(堆内存/主内存)中以实时的方式同步一次;另一方面,如果线程在工作内存中修改了volatile修饰的变量,也会被JVM要求立马刷新到共享内存中去。因此,即便某个线程修改了该变量,其他线程也可以立马感知到变化从而实现可见性"也基本上能够pass这个问题。

但是如果你去阿里这样喜欢刨根问底的公司面试的话,可能这点料就不够用了,因为面试官很可能会问到你更深层次的原理,如果没有彻底理解volatile关键字的话,在这个问题上迟早还是会栽跟头。因此,小码哥决定好好刨一下volatile关键字的根,希望能够对你起到帮助!

初识volatile

下面就让我们循序渐进地逐步了解volatile关键字吧!先了解下volatile的关键字都用在代码的什么地方:"volatile关键字只能修饰类变量和实例变量。方法参数、局部变量、实例常量以及类常量都是不能用volatile关键字进行修饰的"。 问题(1):“为什么volatile关键字只能修饰类变量和实例变量呢?”关于问题,我们可以先进行思考,然后在文章的结尾集中探讨答案。

机器硬件CPU&JAVA内存模型

在深入理解volatile关键字之前,让我们先来回顾下并发问题产生的根本原因,这一点对于我们理解volatile关键字的存在意义是一个基础性问题。我们知道在计算机系统中所有的运算操作都是由CPU来完成的,而CPU运算需要从内存中加载数据,计算完后再将结果同步回内存,但是由于现代计算机的CPU的处理速度要比内存的访问速度牛逼N倍,如果CPU在进行数据运算时直接访问内存的话,由于内存的访问速度慢,这样就会拖慢CPU的运算效率。

为了解决这一问题,伟大的计算机科学家们就想到了一个办法,通过在CPU和内存之间架设一层缓存,CPU不直接访问物理内存,而是将需要运算的数据从主内存中拷贝一份到缓存,运算的结果也通过缓存同步给主内存。

通过这种方法CPU的运行速度就大大提高了,目前主流的CPU都有L1、L2、L3三级缓存。但是,这样的方式也带来了新的问题,那就是在多线程情况下同一份主内存中的数据值,会被拷贝多个副本放入CPU的缓存中,如果两个线程同时对一个变量值进行赋值操作的话,就会产生数据不一致的问题,例如:”变量i的初始值为0,两个线程同时加载到CPU缓存后,同时执行i+1的操作,按照道理说i的值此时应该是变成2,而实际情况主内存的值可能还是1,因为两个线程彼此是不知道对方已经改动了这个变量的值的“。

而为了解决这样一个问题,一些CPU制造商如Intel开发了诸如MESI协议这样的缓存一致性控制协议来解决CPU缓存与主内存之间的数据不一致问题,其基本操作大概就是在某个线程通过CPU缓存写入主内存时,会通过信号的方式通知其他线程中CPU缓存中的值变为失效,从而让其他线程再次从主内存中同步一份数据到CPU缓存中。

以上关于CPU缓存与内存的介绍,并不是为了探讨关于CPU的原理,而是为了说明并发数据不一致问题产生的基本缘由是什么!同理,JAVA内存模型中的定义中,也进行了类似的设计。在JAVA内存模型中,线程与主内存的关系是,线程并不直接操作主内存,而是通过将主内存中的变量拷贝到自己的工作内存中进行计算,完成后再将变量的值同步回主内存这样的方式进行工作。

JAVA内存模型定义了线程与主内存之间的抽象关系,如下:

  • 共享变量(类变量以及对象的全局实例变量等都是共享变量)存储于主内存中,每个线程都可以访问,这里的主内存可以看成是堆内存。
  • 每个线程都有私有的工作内存,这里的工作内存可以看成是栈内存。
  • 工作内存只存储该线程对共享变量的副本。
  • 线程不能直接操作主内存,只有先操作了工作内存之后才能通过工作内存写入主内存。 以上关于工作内存及Java内存模型的概述,只是便于我们去理解JVM内存管理机制的一个抽象的概念,物理上并不是具体的存在。从具体情况上来讲因为Java程序是运行在JVM之上的,并没有直接调用到操作系统的底层接口去操作硬件,所以线程操作数据进行运算最终还是通过JVM调用了受操作系统管理的CPU资源去进行计算。而计算中涉及的CPU缓存与主内存的缓存一致性问题,则是操作系统层面的一层抽象,与Java工作内存彧主内存的划分并没有直接关系,它们是不同层次的设计。

如果非要用一张图来进行下类比,以便于大家好理解的话,那就来一张图吧:

根据图中的描述,Java内存模型的区分的堆、栈内存只是虚拟机对自身使用的物理内存的内部划分,它们对于操作系统管理来说就是一块被JVM使用的物理内存,而这个物理内存如果涉及CPU的运算操作,CPU就会通过硬件指令对数据进行加载运算,最终更改物理内存中相应程序变量所对应的内存区块的值。

并发编程三大特性

volatile关键字说到底是Java中对多线程并发问题提供语法机制之一,而要正确地理解Java多线程问题,要求我们必须深刻的理解“原子性”、“有序性”、“可见性”这三个非常重要和关键的特性。

原子性

所谓原子性是指在一次操作或者多次操作中,要么所有的操作全部都得到执行,要么所有的操作都不执行。这个原子性与数据库事务的原子性特性是一致的。Java内存模型只保证了基本的读取和赋值的原子性操作,其他操作均不保证。

简单操作如:"x=10",这个操作就是原子性的。因为从操作上看执行线程会将x=10这个动作写入工作内存,然后再将其写入主内存;即便在该线程进行数值刷新的过程中,也有其他线程对其进行刷新操作(如x=11)的情况x的最终结果也没有什么不一致的问题,因为最后要么是10,要么是11,两个线程谁先刷新都无所谓,那么在这种情况下我们就说这个操作是原子性的。

其他操作如:"x++",这个操作就是非原子性的。为啥呢?我们来分析下x++这个动作都经过了哪些步骤:

此时y的正确值应该是6,但是线程A最终将y=2同步给了主内存,从而导致主内存中y的值变成了一个脏数据,从而产生了线程安全问题,所以我们说y++的操作不具备原子性,因为它分了三个步骤来执行一个操作。

从上面的例子可以看到原子性是一个排他性的特性,如果需要保证y++具备原子性就需要确保y++动作的三个步骤完成前,不允许其他线程对y变量进行操作。因为Java的内存模型并不确保此类操作的原子性,所以有时候写Java代码时会让人感到代码中处处都充斥着线程不安全的操作,只是现在觉得多数程序员都在从事业务开发,面向的都是数据库编程,加上工程上都是集成了现成的开发框架,所以对于这一点感受并不是特别深刻,只有在少数场景下手动实现多线程编程时才会通过synchronized关键字进行加锁同步操作。

然而,这个特性与volatile关键字有什么关系呢?事实上volatile关键字并不保证被修饰的类变量和实例变量具有原子性。这是因为被volatile关键字修饰的变量并不具备排他性,关于这一点,我们在下面说完另外两个特性后再分析下原因。

可见性

可见性是指,当一个线程对共享变量进行了修改,那么其他线程可以立刻看到修改后的最新值。在Java多线程环境下,线程首次读取要操作的变量时,是先到主内存中获取该变量,然后将其放入工作内存,以后关于该变量的操作都是在以工作内存中的变量值为基准的。之后如果要修改该变量的值,也是直接修改工作内存中的变量,最后会在某一时刻将工作内存中该变量的值刷新同步回主内存,之后其他线程就能感知到该变量的变化,实现可见性了!

只是什么时候将工作内存中的值同步会主内存,这个时间点在自然情况下是不确定的,所以假设线程A修改了变量的值之后,在正式将其同步会主内存之前,线程B获取了主内存中变量的原先值,而过了一会后线程A刷新了主内存,但是此时主内存中的变量值与线程B工作内存中的变量值已经不一致了,这个时候就出现不可见的问题了!

在Java中本文的主角volatile关键字就可以解决变量在线程间不可见的问题。当一个变量被volatile关键字修饰之后,对于共享资源的操作会时刻保持与主内存的数据一致。因为被volatile关键字修饰的变量,如果某个线程对其进行了更改,它就会立马进行一次工作内存刷新同步至主内存的操作;同理,如果某个线程读取volatile关键字修饰的变量,那么该线程返回自己工作内存中的变量时,每次都会被要求从主内存再同步一次到工作内存中。

除此之外synchronized关键字以及JUC包中提供的显示锁Lock也可以保证可见性。原因在于它们可以保证在同一时刻只有一个线程获得锁可以操作共享变量,完成工作内存中的运算在释放锁之前会确保工作内存中变更的变量新值会被刷新到主内存中。

我们再回过头来分析下volatile关键字修饰的变量为什么在保证可见性以后还是不能确保原子性,实现完全的线程安全呢?

我们还是以y++举例,这次变量y被volatile修饰了,有什么变化呢?假设第1步线程A从主内存拷贝了y的副本到工作内存后,此时线程B直接操作了y=5这个动作,那么此时线程A中的副本y的值为1就不对了,因为被volatile修饰了,所以在第2步线程A使用y进行运算时,会再次从主内存中同步一次y的副本(y=5),然后线程A执行y=y+1后,会立马执行第3步把y的值6立马同步刷新会主内存。

初一看感觉volatile的关键字好像解决了y++这个操作的原子性问题,但实际上我们再看看,如果此时线程A已经执行完第2步了,此时线程B更改了变量y的值,虽然此时线程A知道变量y发生了变化,但是由于操作已经执行完,所以还是只能执行第3步把变量y的值覆盖回主内存,从而又造成了错误数据。

所以从这个例子分析,volatile只是解决了y++第1步和第2步的原子性,并没有解决3个步骤的原子性,所以我们说volatile关键字并不能保证解决原子性问题,就是这个道理!

有序性

有序性是指程序代码在执行的过程中要确保有数据依赖关系的代码要有先后顺序。由于代码编译存在指令重排的问题,所以程序编写的顺序与最后实际执行的指令可能先后顺序时错乱的,如果代码编写的先后顺序存在数据依赖关系,那么就有可能导致依赖于某条代码指令在它所依赖的代码指令执行之前就被执行了,从而导致程序出现错误的情况。

在Java中的有序性就是要通过对指令重排的干预,避免掉因为指令重排导致的这种程序错误问题。volatile关键字就可以通过增加内存屏障的方式禁止指令重排,从而实现程序执行的有序性。除此之外的synchronized关键字以及JUC包中提供的显示锁Lock也可以保证有序性,因为同步所以与单线程的情况一样自然能够保证有序性。

此外,Java内存模型本身也会通过一些happens-before原则的推导来确保在进行指令重排时程序代码执行的有序性。这里的happens-before原则有:程序次序规则、锁定规则、volatile变量规则、传递规则、线程启动规则、线程中断规则、线程终结规则、对象的终结规则等。

volatile实现机制

通过上面内容的阅读,详细你对volatile关键字已经有了一定深入的了解了,下面我们再深入分析下volatile的实现机制。通过对OpenJDK中的unsafe.cpp源码的分析,会发现被volatile关键字修饰的变量会存在一个“lock:”的前缀。 这个实际上相当于是一个内存屏障,该内存屏障会为指令的执行提供如下保障:

  • 确保指令重排序时不会将其后面的代码排到内存屏障之前。
  • 同样也会确保重排序是不会将其前面的代码排到内存屏障之后。
  • 确保在执行到内存屏障修饰的指令时前面的代码全部执行完成。
  • 强制将线程的工作内存中值的修改刷新至主内存中。

思考题


以上就是关于volatile关键字的全部要说的内容了!在结束之前,我们先来看看之前文章中提到的一个问题:为什么volatile关键字只能修饰类变量和实例变量?“关于这个问题类似于就是要回答Java语言的语法设计问题了。因为volatile关键字是要解决多线程间共享变量的可见性问题的,只有类变量及实例变量才是Java中的共享变量的类型,方法参数因为时线程私有不存在共享的问题,而常量本身的值是固定的所以不需要被volatile这样的机制修饰”。

再给大家留一个问题:“如果volatile的修饰的是一个引用类型的对象变量,那么对象中的定义的一些变量及方法会收到什么影响呢”?

大家可以先思考下,欢迎发消息留言及加微信入群一起讨论!

—————END—————