目录
volatile
1、定义
volatile 关键字是 Java 虚拟机提供的的最轻量级的同步机制,它作为一个修饰 符出现,用来修饰变量,保证变量对所有线程可见性,但是这里不包括局部变量。
2、代码示例
public class ApiTest { public static void main(String[] args) { Optional.empty(); final VT vt = new VT(); Thread Thread01 = new Thread(vt); Thread Thread02 = new Thread(new Runnable() { public void run() { try { Thread.sleep(3000); } catch (InterruptedException ignore) { } vt.sign = true; System.out.println("vt.sign = true 通知 while (!sign) 结束!"); } }); Thread01.start(); Thread02.start(); } } class VT implements Runnable { private Logger logger = LoggerFactory.getLogger(ApiTest.class); public boolean sign = false; public void run() { while (!sign) { } System.out.println("你坏!"); } }
这段代码,是两个线程操作一个变量,程序期望当 sign 在线程 Thread01 被操作 vt.sign = true 时,Thread02 输出你坏。 但实际上这段代码永远不会输出你坏,而是一直处于死循环。而当给sign变量添加volatile 关键字后程序才会输出你坏。
3、volatile 怎么保证的可见性
3.1 无 volatile 时,内存变化
3.2 有 volatile 时,内存变化
当我们把变量使用 volatile 修饰时 public volatile boolean sign = false;, 线程 01 对变量进行操作时,会把变量变化的值强制刷新的到主内存。当线程 02获取值时,会把自己的内存里的 sign 值过期掉,之后从主内存中读取。所以添加关键字后程序如预期输出结果。
4、并发编程的三个基本概念
4.1 可见性
定义:指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
在多线程环境下,一个线程对共享变量的操作对其他线程是不可见的。Java提供了volatile来保证可见性,当一个变量被volatile修饰后,表示着线程本地内存无效,当一个线程修改共享变量后他会立即被更新到主内存中,其他线程读取共享变量时,会直接从主内存中读取。当然,synchronize和Lock都可以保证可见性。synchronized和Lock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。
4.2 原子性
定义:即一个操作或者多个操作要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。 原子性是拒绝多线程操作的,不论是多核还是单核,具有原子性的量,同一时刻只能有一个线程来对它进行操作。简而言之,在整个操作过程中不会被线程调度器中断的操作,都可认为是原子性。例如 a=1是原子性操作,但是at+和a +=1就不是原子性操作。Java中的原子性操作包括: (1)基本类型的读取和赋值操作,且赋值必须是值赋给变量,变量之间的相互赋值不是原子性操作。
(2)所有引用reference的赋值操作。
(3) java.concurrent.Atomic.*包中所有类的一切操作。
4.3 有序性
定义:即程序执行的顺序按照代码的先后顺序执行。 Java内存模型中的有序性可以总结为:如果在本线程内观察,所有操作都是有序的;如果在一个线程中观察另一个线程,所有操作都是无序的。前半句是指"线程内表现为串行语义",后半句是指'指令重排序"现象和"工作内存中主内存同步延迟"现象。 在Java内存模型中,为了效率是允许编译器和处理器对指令进行重排序,当然重排序不会影响单线程的运行结果,但是对多线程会有影响。Java提供volatile来保证一定的有序性。最著名的例子就是单例模式里面的DCL(双重检查锁)。另外,可以通过synchronized和Lock来保证有序性,synchronized和Lock保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性。
5、查看汇编指令(如何保证内存可见性)
我们首先使用反编译查看JVM指令(javap -v -p VT),发现从 JVM 指令码中只会发现多了,ACC_VOLATILE,并没有什么其他的点。所以,也 不能看出是怎么实现的可见性。
于是我们查看汇编指令,编译后的汇编指令中,有 volatile 关键字和没有 volatile 关键字,主要差别在 于多了一个 lock addl $0x0,(%rsp),也就是 lock 的前缀指令。 lock 指令相当于一个内存屏障,它保证如下三点:
-
将当前处理器缓存行的数据写回到系统内存。(这个写回内存的操作会使在其他CPU里缓存了该内存地址的数据无效。)
-
重排序时不能把后面的指令重排序到内存屏障之前的位置。
-
如果是写入动作会导致其他处理器中对应的内存无效。
这里的 1、3 就是用来保证被修饰的变量,保证内存可见性。
为了提高处理速度,处理器不直接和内存进行通信,而是先将系统内存的数据读到内部缓存后再进行操作,但是操作完了不知道什么时候写回内存。而对声明了volatile关键字的变量进行写操作,JVM会向处理器发送一条lock前缀的指令,将这个变量所在的缓存行立即写回系统内存。并且为了保证各个处理器的缓存是一致的,实现了缓存一致性协议
,各个处理通过嗅探
在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态
,那么下次对这个数据进行操作,就会重新从系统内存中获取最新的值。对应JMM来说就是:
1. Lock前缀的指令让线程工作内存中的值写回主内存中; 2. 通过缓存一致性协议,其他线程如果工作内存中存了该共享变量的值,就会失效; 3. 其他线程会重新从主内存中获取最新的值;
6、不加volatile也可见
修改VT的代码如下:
class VT implements Runnable { public boolean sign = false; public void run() { while (!sign) { System.out.println("你好"); } System.out.println("你坏"); } }
结果:
你好 你好 你好 vt.sign = true 通知 while (!sign) 结束! 你坏
这是因为在没 volatile 修饰时,jvm 也会尽量保证可见性。有 volatile 修饰 的时候,一定保证可见性。
7、volatile面试题
7.1 说说你对volatile关键字的理解
volatile是java虚拟机提供的轻量级的同步机制,它作为一个修饰符出现,用来修饰变量,保证变量对所有线程可见性,但是这里不包括局部变量。
-
保证可见性
-
不保证原子性
-
保证有序性
-
禁止指令重排序
7.2 能不能详细说下什么是内存可见性,什么又是重排序呢?
7.2.1 内存可见性
Java 内存模型(JMM)规定,对于多个线程共享的变量,存储在主内存当中,每个线程都有自己独立的工作内存,并且线程只能访问自己的工作内存,不可以访问其它线程的工作内存。工作内存中保存了主内存中共享变量的副本,线程要操作这些共享变量,只能通过操作工作内存中的副本来实现,操作完毕之后再同步回到主内存当中,其 JVM 模型大致如下图:
JVM 模型规定:
1)线程对共享变量的所有操作必须在自己的内存中进行,不能直接从主内存中读写;
2)不同线程之间无法直接访问其它线程工作内存中的变量,线程间变量值的传递需要通过主内存来完成。
这样的规定可能导致得到后果是:线程对共享变量的修改没有即时更新到主内存,或者线程没能够即时将共享变量的最新值同步到工作内存中,从而使得线程在使用共享变量的值时,该值并不是最新的。这就引出了内存可见性。
内存可见性:
当一个线程修改了某个状态对象后,其它线程能够看到发生的状态变化。比如线程 1 修改了变量 A 的值,线程 2 能立即读取到变量 A 的最新值,否则线程 2 如果读取到的是一个过期的值,也许会带来一些意想不到的后果。那么如果要保证内存可见性,必须得保证以下两点:
-
线程修改后的共享变量值能够及时刷新从工作内存中刷新回主内存;
-
其它线程能够及时的把共享变量的值从主内存中更新到自己的工作内存中;
为此,Java 提供了一种稍弱的同步机制,即 volatile 变量,用来确保将变量的更新操作通知到其它线程。当把共享变量声明为 volatile 类型后,线程对该变量修改时会将该变量的值立即刷新回主内存,同时会使其它线程中缓存的该变量无效,从而其它线程在读取该值时会从主内中重新读取该值(参考缓存一致性<MESI>)。因此在读取 volatile 类型的变量时总是会返回最新写入的值。 除了使用 volatile 关键字来保证内存可见性之外,使用 synchronized 或其它加锁也能保证变量的内存可见性。只是相比而言使用 volatile 关键字开销更小,但是 volatile 并不能保证原子性,大致原理如下:
JAVA内存模型规定工作内存与主内存之间的交互协议,其中包括8种原子操作:
-
lock:将主内存中的变量锁定,为一个线程所独占
-
unclock:将lock加的锁定解除,此时其它的线程可以有机会访问此变量
-
read:将主内存中的变量值读到工作内存当中
-
load:将read读取的值保存到工作内存中的变量副本中。
-
use:将值传递给线程的代码执行引擎
-
assign:将执行引擎处理返回的值重新赋值给变量副本
-
store:将变量副本的值存储到主内存中。
-
write:将store存储的值写入到主内存的共享变量当中。
其中lock和unlock定义了一个线程访问一次共享内存的界限,而其它操作下线程的工作内存与主内存的交互大致如下图所示:
从上图可以看出:
-
read and load 主要是将主内存中数据复制到工作内存中
-
use and assign 则主要是使用数据,并将改变后的值写入到工作内存
-
store and write 则是用工作内存数据刷新主存相关内容
但是以上的一系列操作并不是原子的,也既是说在 read and load 之后,如果主内存中变量的值发生了改变,这时再 use and assign 并不是取的最新的值(读取的是副本值,不是主内存中的值)。所以尽管 volatile 会强制工作内存与主内存的缓存更新,但是却仍然无法保证其原子性。
7.2.2 指令重排序
线程A: content = initContent(); //(1) isInit = true; //(2)
线程B while (isInit) { //(3) content.operation(); //(4) }
从常规的理解来看,上面的代码是不会出问题的,但是JVM可以对它们在不改变数据依赖关系的情况下进行任意排序以提高程序性能(遵循as-if-serial语义,即不管怎么重排序,单线程程序的执行结果不能被改变),而这里所说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不会被编译器和处理器考虑,也即是说对于线程A,代码(1)和代码(2)是不存在数据依赖性的,尽管代码(3)依赖于代码(2)的结果,但是由于代码(2)和代码(3)处于不同的线程之间,所以JVM可以不考虑线程B而对线程A中的代码(1)和代码(2)进行重排序,那么假设线程A中被重排序为如下顺序:
线程A: isInit = true; //(2) content = initContent(); //(1)
对于线程B,则可能在执行代码(4)时,content并没有被初始化,而造成程序错误。那么应该如何保证绝对的代码(2) happens-before 代码(3)呢?没错,仍然可以使用volatile关键字。
volatile关键字除了之前提到的保证变量的内存可见性之外,另外一个重要的作用便是局部阻止重排序的发生,即保证被volatile关键字修饰的变量编译后的顺序,也即是说如果对isInit使用了volatile关键字修饰,那么在线程A中,就能保证绝对的代码(1) happens-before 代码(2),也便不会出现因为重排序而可能造成的异常。
7.3 volatile是如何保证有序性的(原理)?
为了性能优化,JVM会在不改变数据依赖性
的情况下,允许编译器和处理器对指令序列进行重排序
,而有序性问题指的就是程序代码执行的顺序与程序员编写程序的顺序不一致,导致程序结果不正确的问题。而加了volatile修饰的共享变量,则通过内存屏障
解决了多线程下有序性问题。
7.3.1 JMM层面的内存屏障(4类)
屏障类型 | 指令示例 | 说明 |
---|---|---|
LoadLoad Barries | Load1;LoadLoad;Load2 | 确保Load1数据的装载先于Load2以及后续装载指令的装载。(在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。) |
StoreStore Barries | Store1;StoreStore;Store2 | 确保Store1数据刷新到内存先于Store2以及后续存储指令的存储。(在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。) |
LoadStore Barries | Load1;LoadStore;Store2 | 确保Load1数据的装载先于Store2数据刷新到内存以及后续存储指令的存储。(在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。) |
StoreLoad Barries | Store1;StoreLoad;Load2 | 确保Store1数据刷新到内存先于Load2数据的装载以及后续装载指令的装载。(在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。) |
volatile
是通过编译器在生成字节码时,在指令序列中添加“内存屏障”来禁止指令重排序的。
JVM的实现会在volatile读写前后均加上内存屏障,在一定程度上保证有序性。如下所示:
StoreStore Barrier volatile 写操作 StoreLoad Barrier
volatile 读操作 LoadLoad Barrier LoadStore Barrier
7.4 as-if-serial规则
as-if-serial语义指:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义。 为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作可能被编译器和处理器重排序。示例代码如下:
int a=1; int b=2; int c=a+b;
a和c之间存在数据依赖关系,同时b和c之间也存在数据依赖关系。因此在最终执行的指令序列中,c不能被重排序到A和B的前面(c排到a和b的前面,程序的结果将会被改变)。但a和b之间没有数据依赖关系,编译器和处理器可以重排序a和b之间的执行顺序。
7.5 happens-before(先行发生)规则
JMM可以通过happens-before关系向程序员提供跨线程的内存可见性保证(如果A线程的写操作a与B线程的读操作b之间存在happens-before关系,尽管a操作和b操作在不同的线程中执行,但JMM向程序员保证a操作将对b操作可见)。具体的定义为:
-
如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。
-
两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须要按照happens-before关系指定的顺序来执行。如果重排序之后的执行结果,与按happens-before关系来执行的结果一致,那么JMM允许这种重排序。
7.6 MESI缓存一致性协议
MESI协议缓存状态——即数据在缓存中的状态,一共有四种;
M 修改 (Modified):该Cache line有效,数据被修改了,和内存中的数据不一致,数据只存在于本Cache中。
E 独享、互斥 (Exclusive): 该Cache line有效,数据和内存中的数据一致,数据只存在于本Cache中。
S 共享 (Shared): 该Cache line有效,数据和内存中的数据一致,数据存在于很多Cache中
I 无效 (Invalid):该Cache line无效。
7.7 什么时候使用volatile?
1.状态标志
当一个变量被多个线程共享,并且该变量的值会被一个线程修改,其他线程需要立即看到最新的值时,可以使用volatile关键字来声明该变量。
2.一次性安全发布
在缺乏同步的情况下,可能会遇到某个对象引用的更新值(由另一个线程写入)和该对象状态的旧值同时存在。
这就是造成著名的双重检查锁定(double-checked-locking)问题的根源,其中对象引用在没有同步的情况下进行读操作,产生的问题是您可能会看到一个更新的引用,但是仍然会通过该引用看到不完全构造的对象。
如果不用volatile,则因为内存模型允许所谓的“无序写入”,可能导致失败。——某个线程可能会获得一个未完全初始化的实例。
在单例模式中,双重检查锁定是一种常见的延迟初始化技术。在双重检查锁定中,需要使用volatile关键字来修饰单例对象的引用变量,以确保多线程环境下单例对象的唯一性和可见性。
3.独立观察
安全使用 volatile 的另一种简单模式是:定期 “发布” 观察结果供程序内部使用。【例如】假设有一种环境传感器能够感觉环境温度。一个后台线程可能会每隔几秒读取一次该传感器,并更新包含当前文档的 volatile 变量。然后,其他线程可以读取这个变量,从而随时能够看到最新的温度值。
使用该模式的另一种应用程序就是收集程序的统计信息。【例】如下代码展示了身份验证机制如何记忆最近一次登录的用户的名字。将反复使用lastUser
引用来发布值,以供程序的其他部分使用。
4.“volatile bean” 模式
volatile bean 模式的基本原理是:很多框架为易变数据的持有者(例如 HttpSession
)提供了容器,但是放入这些容器中的对象必须是线程安全的。
在 volatile bean 模式中,JavaBean 的所有数据成员都是 volatile 类型的,并且 getter 和 setter 方法必须非常普通——即不包含约束!
5.开销较低的“读-写锁”策略
如果读操作远远超过写操作,您可以结合使用内部锁和 volatile 变量来减少公共代码路径的开销。
如下显示的线程安全的计数器,使用 synchronized
确保增量操作是原子的,并使用 volatile
保证当前结果的可见性。如果更新不频繁的话,该方法可实现更好的性能,因为读路径的开销仅仅涉及 volatile 读操作,这通常要优于一个无竞争的锁获取的开销。
1. @ThreadSafe 2. public class CheesyCounter { 3. // Employs the cheap read-write lock trick 4. // All mutative operations MUST be done with the 'this' lock held 5. @GuardedBy("this") private volatile int value; 7. //读操作,没有synchronized,提高性能 8. public int getValue() { 9. return value; 10. } 12. //写操作,必须synchronized。因为x++不是原子操作 13. public synchronized int increment() { 14. return value++; 15. }
使用锁进行所有变化的操作,使用 volatile 进行只读操作。 其中,锁一次只允许一个线程访问值,volatile 允许多个线程执行读操作。
8.总结
-
volatile会控制被修饰的变量在内存操作上主动把值刷新到主内存,JMM 会把该线程对应的 CPU 内存设置过期,从主内存中读取最新值。
-
volatile 如何防止指令重排也是内存屏障,volatile 的内存屏障是在读写操作的前后各添加屏障,也就是四个位置,来保证重排序时不能把内存屏障后面的指令重排序到内存屏障之前的位置。
-
另外 volatile 并不能解决原子性,如果需要解决原子性问题,需要使用 synchronzied 或者 lock。