volatile必懂知识点
摘要:
本来打算写一篇concurrentHashmap的文章,但是其中又因为它是线程安全的,所以又要先复习下volatile,然后复习volatile之前又要先用到java的内存模型,所以就把这篇文章归结jvm中去。
在 Java 并发编程中,要想使并发程序能够正确地执行,必须要保证三条原则,即:原子性、可见性和有序性。只要有一条原则没有被保证,就有可能会导致程序运行不正确。volatile关键字 被用来保证可见性,即保证共享变量的内存可见性以解决缓存一致性问题。一旦一个共享变量被 volatile关键字 修饰,那么就具备了两层语义:内存可见性和禁止进行指令重排序。在多线程环境下,volatile关键字 主要用于及时感知共享变量的修改,并使得其他线程可以立即得到变量的最新值,例如,用于 修饰状态标记量 和 Double-Check (双重检查)中。
volatile关键字 虽然从字面上理解起来比较简单,但是要用好不是一件容易的事情。由于 volatile关键字 是与 内存模型 紧密相关,因此在讲述 volatile关键字 之前,我们有必要先去了解与内存模型相关的概念和知识,然后回头再分析 volatile关键字 的实现原理,最后在给出 volatile关键字 的使用场景
一. 内存模型的相关概念
java内存模型大家都知道,计算机在执行程序时,每条指令都是在 CPU 中执行的,而执行指令过程中,势必涉及到数据的读取和写入。由于程序运行过程中的临时数据是存放在主存(物理内存)当中的,这时就存在一个问题:由于 CPU 执行速度很快,而从内存读取数据和向内存写入数据的过程跟 CPU 执行指令的速度比起来要慢的多,因此如果任何时候对数据的操作都要通过和内存的交互来进行,会大大降低指令执行的速度。因此,在 CPU 里面就有了 高速缓存(寄存器)。
也就是说,在程序运行过程中,会将运算需要的数据从主存复制一份到 CPU 的高速缓存当中,那么, CPU 进行计算时就可以直接从它的高速缓存读取数据和向其中写入数据,当运算结束之后,再将高速缓存中的数据刷新到主存当中。举个简单的例子,比如下面的这段代码:
当线程执行这个语句时,会先从主存当中读取 i 的值,然后复制一份到高速缓存当中,然后CPU执行指令对 i 进行加1操作,然后将数据写入高速缓存,最后将高速缓存中 i 最新的值刷新到主存当中。
这个代码在单线程中运行是没有任何问题的,但是在多线程中运行就会有问题了。在多核 CPU 中,每个线程可能运行于不同的 CPU 中,因此每个线程运行时有自己的高速缓存(对单核CPU来说,其实也会出现这种问题,只不过是以线程调度的形式来分别执行的)。本文我们以多核CPU为例。
比如,同时有两个线程执行这段代码,假如初始时 i 的值为 0,那么我们希望两个线程执行完之后 i 的值变为 2。但是事实会是这样吗?
可能存在下面一种情况:初始时,两个线程分别读取 i 的值存入各自所在的 CPU 的高速缓存当中,然后线程1 进行加 1 操作,然后把 i 的最新值 1 写入到内存。此时线程 2 的高速缓存当中 i 的值还是 0,进行加 1 操作之后,i 的值为 1,然后线程 2 把 i 的值写入内存。
最终结果 i 的值是 1,而不是 2 。这就是著名的 缓存一致性问题 。通常称这种被多个线程访问的变量为 共享变量 。
也就是说,如果一个变量在多个 CPU 中都存在缓存(一般在多线程编程时才会出现),那么就可能存在 缓存不一致 的问题。
为了解决缓存不一致性问题,在 硬件层面 上通常来说有以下两种解决方法:
1)通过在 总线加 LOCK# 锁 的方式 (在软件层面,效果等价于使用 synchronized 关键字);
2)通过 缓存一致性协议 (在软件层面,效果等价于使用 volatile 关键字)。
在早期的 CPU 当中,是通过在总线上加 LOCK# 锁的形式来解决缓存不一致的问题。因为 CPU 和其他部件进行通信都是通过总线来进行的,如果对总线加 LOCK# 锁的话,也就是说阻塞了其他 CPU 对其他部件访问(如内存),从而使得只能有一个CPU能使用这个变量的内存。比如上面例子中, 如果一个线程在执行 i = i + 1,如果在执行这段代码的过程中,在总线上发出了 LCOK# 锁的信号,那么只有等待这段代码完全执行完毕之后,其他 CPU 才能从变量 i 所在的内存读取变量,然后进行相应的操作,这样就解决了缓存不一致的问题。但是上面的方式会有一个问题,由于在锁住总线期间,其他 CPU 无法访问内存,导致效率低下。
所以,就出现了 缓存一致性协议 ,其中最出名的就是 Intel 的 MESI 协议。MESI 协议保证了每个缓存中使用的共享变量的副本是一致的。它核心的思想是: 当 CPU 写数据时,如果发现操作的变量是共享变量,即在其他 CPU 中也存在该变量的副本,会发出信号通知其他 CPU 将该变量的缓存行置为无效状态。因此,当其他 CPU 需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取。
二.并发编程中的三个概念
原子性:一个操作要么全部成功,要么全部不成功。
可见性 :
是指当多个线程访问同一个共享变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
有序性:即程序执行的顺序按照代码的先后顺序执行。
比如上面的代码中,语句1 和 语句2 谁先执行对最终的程序结果并没有影响,那么就有可能在执行过程中, 语句2 先执行而 语句1 后执行。但是要注意,虽然处理器会对指令进行重排序,但是它会保证程序最终结果会和代码顺序执行结果相同,那么它靠什么保证的呢?再看下面一个例子:
那么可不可能是这个执行顺序呢: 语句2 -> 语句1 -> 语句4 -> 语句3
答案是不可能,因为处理器在进行重排序时会考虑指令之间的 数据依赖性,如果一个指令 Instruction 2 必须用到 Instruction 1 的结果,那么处理器会保证 Instruction 1 会在 Instruction 2 之前执行。
从上面可以看出,指令重排序不会影响单个线程的执行,但是会影响到线程并发执行的正确性。也就是说,要想使并发程序正确地执行,必须要保证原子性、可见性以及有序性。只要有一个没有被保证,就有可能会导致程序运行不正确
三. Java内存模型
在前面谈到了一些关于内存模型以及并发编程中可能会出现的一些问题。下面我们来看一下 Java内存模型,研究一下 Java内存模型 为我们提供了哪些保证以及在 Java 中提供了哪些方法和机制来让我们在进行多线程编程时能够保证程序执行的正确性。
在 Java虚拟机规范 中,试图定义一种 Java内存模型(Java Memory Model,JMM) 来屏蔽各个硬件平台和操作系统的内存访问差异,以实现让 Java 程序在各种平台下都能达到一致的内存访问效果。那么,Java内存模型 规定了哪些东西呢,它定义了程序中变量的访问规则,往大一点说是定义了程序执行的次序。注意,为了获得较好的执行性能,Java内存模型并没有限制执行引擎使用处理器的寄存器或者高速缓存来提升指令执行速度,也没有限制编译器对指令进行重排序。也就是说,在 Java内存模型 中,也会存在缓存一致性问题和指令重排序的问题。
Java内存模型 规定所有的变量都是存在主存当中(类似于前面说的物理内存),每个线程都有自己的工作内存(类似于前面的高速缓存)。线程对变量的所有操作都必须在工作内存中进行,而不能直接对主存进行操作,并且每个线程不能访问其他线程的工作内存。
1.原子性、
乍一看,有些朋友可能会说上面的四个语句中的操作都是原子性操作。其实 只有 语句1 是原子性操作,其他三个语句都不是原子性操作。
语句1 是直接将数值 10 赋值给 x,也就是说线程执行这个语句的会直接将数值 10 写入到工作内存中;
语句2 实际上包含两个操作,它先要去读取 x 的值,再将 x 的值写入工作内存。虽然,读取 x 的值以及 将 x 的值写入工作内存这两个操作都是原子性操作,但是合起来就不是原子性操作了;
同样的,x++ 和 x = x+1 包括3个操作:读取 x 的值,进行加 1 操作,写入新的值。
所以,上面四个语句只有 语句1 的操作具备原子性。也就是说,只有简单的读取、赋值(而且必须是将数字赋值给某个变量,变量之间的相互赋值不是原子操作)才是原子操作。
不过,这里有一点需要注意:在32位平台下,对64位数据的读取和赋值是需要通过两个操作来完成的,不能保证其原子性。但是好像在最新的JDK中,JVM 已经保证对64位数据的读取和赋值也是原子性操作了。
从上面可以看出,Java内存模型只保证了基本读取和赋值是原子性操作,如果要实现更大范围操作的原子性,可以通过 synchronized 和 Lock 来实现。由于 synchronized 和 Lock 能够保证任一时刻只有一个线程执行该代码块,那么自然就不存在原子性问题了,从而保证了原子性。
2.可见性
对于可见性,Java 提供了 volatile关键字 来保证可见性。
当一个共享变量被 volatile 修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。而普通的共享变量不能保证可见性,因为普通共享变量被修改之后,什么时候被写入主存是不确定的,当其他线程去读取时,此时内存中可能还是原来的旧值,因此无法保证可见性。
另外,通过 synchronized 和 Lock 也能够保证可见性,synchronized 和 Lock 能保证同一时刻只有一个线程获取锁然后执行同步代码,并且 在释放锁之前会将对变量的修改刷新到主存当中,因此可以保证可见性。
3.有序性
在 Java内存模型中,允许编译器和处理器对指令进行重排序,但是重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。
在 Java 中,可以通过 volatile 关键字来保证一定的“有序性”(具体原理在下一节讲述)。另外,我们千万不能想当然地认为,可以通过synchronized 和 Lock 来保证有序性,也就是说,不能由于 synchronized 和 Lock 可以让线程串行执行同步代码,就说它们可以保证指令不会发生重排序,这根本不是一个粒度的问题。
另外,Java内存模型具备一些先天的“有序性”,即不需要通过任何手段就能够得到保证的有序性,这个通常也称为 happens-before 原则。如果两个操作的执行次序无法从happens-before原则推导出来,那么它们就不能保证它们的有序性,虚拟机可以随意地对它们进行重排序。
下面就来具体介绍下 happens-before原则(先行发生原则):
程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作;
锁定规则:一个unLock操作先行发生于后面对同一个锁额lock操作;
volatile 变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作;
传递规则:如果操作 A 先行发生于操作 B,而操作 B 又先行发生于操作 C,则可以得出操作 A 先行发生于操作 C ;
线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作;
线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生;
线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行;
对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始。
这八条原则摘自《深入理解Java虚拟机》。这八条规则中,前四条规则是比较重要的,后四条规则都是显而易见的。下面我们来解释一下前四条规则:
对于程序次序规则来说,我的理解就是一段程序代码的执行在单个线程中看起来是有序的。注意,虽然这条规则中提到“书写在前面的操作先行发生于书写在后面的操作”,这个应该是程序看起来执行的顺序是按照代码顺序执行的,因为虚拟机可能会对程序代码进行指令重排序。虽然进行重排序,但是最终执行的结果是与程序顺序执行的结果一致的,它只会对不存在数据依赖性的指令进行重排序。因此,在单个线程中,程序执行看起来是有序执行的,这一点要注意理解。事实上,这个规则是用来保证程序在单线程中执行结果的正确性,但无法保证程序在多线程中执行的正确性。
第二条规则也比较容易理解,也就是说无论在单线程中还是多线程中,同一个锁如果出于被锁定的状态,那么必须先对锁进行了释放操作,后面才能继续进行 lock 操作。
第三条规则是一条比较重要的规则,也是后文将要重点讲述的内容。直观地解释就是,如果一个线程先去写一个变量,然后一个线程去进行读取,那么写入操作肯定会先行发生于读操作。
第四条规则实际上就是体现 happens-before 原则具备传递性。
四.深入剖析 volatile 关键字
1、volatile关键字的两层语义
一旦一个共享变量(类的成员变量、类的静态成员变量)被 volatile 修饰后,那么就具备了两层语义:
1)保证了不同线程对共享变量进行操作时的可见性,即一个线程修改了某个变量的值,这个新值对其他线程来说是 立即可见 的;
2)禁止进行指令重排序。
先看一段代码,假如 线程1 先执行,线程2 后执行:
线程2 对变量的修改没有立即刷入到主存当中;
即使 线程2 对变量的修改立即反映到主存中,线程1 也可能由于没有立即知道 线程2 对stop变量的更新而一直循环下去。
这两种情形都会导致 线程1 处于死循环。但是,用 volatile关键字 修饰后就变得不一样了,如下图所示:
① 使用 volatile 关键字会强制将修改的值立即写入主存;
② 使用 volatile 关键字的话,当 线程2 进行修改时,会导致 线程1 的工作内存中缓存变量stop的缓存行无效(反映到硬件层的话,就是CPU的 L1 或者 L2 缓存中对应的缓存行无效);
③ 由于 线程1 的工作内存中缓存变量stop的缓存行无效,所以,线程1 再次读取变量stop的值时会去主存读取。
综上,在 线程2 修改 stop 值时(当然这里包括两个操作,修改 线程2 工作内存中的值,然后将修改后的值写入内存),会使得 线程1 的工作内存中缓存变量 stop 的缓存行无效,然后 线程1 读取时,会发现自己的缓存行无效从而去对应的主存读取最新的值 。简化一下,通过使用 volatile 关键字,如下图所示,线程会及时将变量的新值更新到主存中,并且保证其他线程能够立即读到该值。这样,线程1 读取到的就是最新的、正确的值。
volatile 保证原子性吗?
大家想一下这段程序的输出结果是多少?也许有些朋友认为是 10000。但是事实上运行它会发现每次运行结果都不一致,都是一个 小于 10000 的数字。可能有的朋友就会有疑问,不对啊,上面是对变量 count 进行自增操作,由于 volatile 保证了可见性,那么在每个线程中对 count 自增完之后,在其他线程中都能看到修改后的值啊,所以有 100个 线程分别进行了 100 次操作,那么最终 count 的值应该是 100*100=10000。
这里面就有一个误区了,volatile 关键字能保证可见性没有错,但是上面的程序错在没能保证原子性。可见性只能保证每次读取的是最新的值,但是 volatile 没办法保证对变量的操作的原子性。在前面已经提到过,自增操作是不具备原子性的,它包括 读取变量的原始值、进行加1操作 和 写入工作内存 三个原子操作。那么就是说,这三个子操作可能会分割开执行,所以就有可能导致下面这种情况出现:
假如某个时刻 变量count 的值为 10,线程1 对变量进行自增操作,线程1 先读取了 变量count 的原始值,然后 线程1 被阻塞了;然后,线程2 对变量进行自增操作,线程2 也去读取 变量count 的原始值,由于 线程1 只是对 变量count 进行读取操作,而没有对变量进行修改操作,所以不会导致 线程2 的工作内存中缓存变量 count 的缓存行无效,所以 线程2 会直接去主存读取 count的值 ,发现 count 的值是 10,然后进行加 1 操作。注意,此时 线程2 只是执行了 count + 1 操作,还没将其值写到 线程2 的工作内存中去!此时线程2 被阻塞,线程1 进行加 1 操作时,注意操作数count仍然是 10!然后,线程2 把 11 写入工作内存并刷到主内存。虽然此时 线程1 能感受到 线程2 对count的修改,但由于线程1只剩下对count的写操作了,而不必对count进行读操作了,所以此时 线程2 对count的修改并不能影响到 线程1。于是,线程1 也将 11 写入工作内存并刷到主内存。也就是说,两个线程分别进行了一次自增操作后,count 只增加了 1。下图演示了这种情形:
五. 使用 volatile 关键字的场景
synchronized 关键字是防止多个线程同时执行一段代码,那么就会很影响程序执行效率;而 volatile 关键字在某些情况下性能要优于 synchronized,但是要注意 volatile 关键字是无法替代 synchronized 关键字的,因为 volatile 关键字无法保证操作的原子性。通常来说,使用 volatile 必须具备以下两个条件:
1)对变量的写操作不依赖于当前值;
2)该变量没有包含在具有其他变量的不变式中。
实际上,这些条件表明,可以被写入 volatile 变量的这些有效值 独立于任何程序的状态,包括变量的当前状态。事实上,上面的两个条件就是保证对 该volatile变量 的操作是原子操作,这样才能保证使用 volatile关键字 的程序在并发时能够正确执行。
特别地,关键字 volatile 主要使用的场合是:
在多线程环境下及时感知共享变量的修改,并使得其他线程可以立即得到变量的最新值。
六. 小结
关键字volatile 与内存模型紧密相关,是线程同步的轻量级实现,其性能要比 synchronized关键字 好。在作用对象和作用范围上, volatile 用于修饰变量,而 synchronized关键字 用于修饰方法和代码块,而且 synchronized 语义范围不但包括 volatile拥有的可见性,还包括volatile 所不具有的原子性,但不包括 volatile 拥有的有序性,即允许指令重排序。因此,在多线程环境下,volatile关键字 主要用于及时感知共享变量的修改,并保证其他线程可以及时得到变量的最新值。可用以下文氏图表示 synchronized 和 volatile语义范围: