【并发编程】volatile关键字最全详解,看这一篇就够了
目录
一、引入
1.1 可见性问题:
public class ReaderAndUpdater {
final static int MAX=5;
static int init_value=0;
public static void main(String[] args) {
new Thread(()->{
int localValue=init_value;
while(localValue<MAX){
if(localValue!=init_value){
System.out.println("Reader:"+init_value);
localValue=init_value;
}
}
},"Reader").start();
new Thread(()->{
int localValue=init_value;
while(localValue<MAX){
System.out.println("updater:"+(++localValue));
init_value=localValue;
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
},"Updater").start();
}
}
一个读线程,一个写线程。有一个全局静态变量init_value,每一个线程还有一个自己的局部变量localValue。写线程将自己的局部变量localValue自增,然后赋值给全局静态变量init_value。读线程当发现自己的本地局部变量与全局静态变量值不相同,则读入最新的全局静态变量init_value,然后更新自己的本地局部变量。
运行结果:
updater:1
Reader:1
updater:2
updater:3
updater:4
updater:5
由结果可发现读线程感知不到写线程对init_value变量的更新,写线程读取到的全局静态变量一直没有被更新,还是旧值。这就出现了并发编程的可见性问题,读线程对写线程的数据修改结果不可见,使程序出现了问题。
1.2 重排序问题:
public class NoVisibility {
private static boolean ready;
private static int number;
private static class ReaderThread extends Thread {
@Override
public void run() {
while(!ready) {
Thread.yield();
}
System.out.println(number);
}
}
public static void main(String[] args) {
new ReaderThread().start();
number = 42;
ready = true;
}
}
NoVisibility可能会持续循环下去,因为读线程可能永远都看不到ready的值,这就是上面讲到的可见性问题。也有可能NoVisibility可能会输出0,因为读线程虽然看到了写入ready的值,但却没有看到之后写入number的值,在主线程对number和ready的赋值给颠倒了,这种现象被称为“重排序”。只要在某个线程中无法检测到重排序情况(即使在其他线程中可以明显地看到该线程中的重排序),那么就无法确保线程中的操作将按照程序中指定的顺序来执行。当主线程首先写入number,然后在没有同步的情况下写入ready,那么读线程看到的顺序可能与写入的顺序完全相反。
在没有同步的情况下,编译器、处理器以及运行时等都可能对操作的执行顺序进行一些意想不到的调整。在缺乏足够同步的多线程程序中,要想对内存操作的执行春旭进行判断,无法得到正确的结论。
二、前言
以上就是在并发编程中可能出现的问题,今天我们就在讲解一下使用volatile关键字来解决这些问题。
我们知道volatile关键字的作用是保证变量在多线程之间的可见性,它是java.util.concurrent包的核心,没有volatile就没有这么多的并发类给我们使用。
本文详细解读一下volatile关键字如何保证变量在多线程之间的可见性,在此之前,有必要讲解一下CPU缓存的相关知识,掌握这部分知识一定会让我们更好地理解volatile的原理,从而更好、更正确地地使用volatile关键字。
三、CPU缓存
3.1 CPU多级缓存架构
CPU缓存的出现主要是为了解决CPU运算速度与内存读写速度不匹配的矛盾,因为CPU运算速度要比内存读写速度快得多,举个例子:
- 一次主内存的访问通常在几十到几百个时钟周期
- 一次L1高速缓存的读写只需要1~2个时钟周期
- 一次L2高速缓存的读写也只需要数十个时钟周期
这种访问速度的显著差异,导致CPU可能会花费很长时间等待数据到来或把数据写入内存。
基于此,现在CPU大多数情况下读写都不会直接访问内存(CPU都没有连接到内存的管脚),取而代之的是CPU缓存(cache),CPU缓存是位于CPU与内存之间的临时存储器,它的容量比内存小得多但是交换速度却比内存快得多。而缓存中的数据是内存中的一小部分数据,但这一小部分是短时间内CPU即将访问的,当CPU调用大量数据时,就可先从缓存中读取,从而加快读取速度。
按照读取顺序与CPU结合的紧密程度,CPU缓存可分为:
- 一级缓存:简称L1 Cache,位于CPU内核的旁边,是与CPU结合最为紧密的CPU缓存
- 二级缓存:简称L2 Cache,分内部和外部两种芯片,内部芯片二级缓存运行速度与主频相同,外部芯片二级缓存运行速度则只有主频的一半
- 三级缓存:简称L3 Cache,部分高端CPU才有
每一级缓存中所存储的数据全部都是下一级缓存中的一部分,这三种缓存的技术难度和制造成本是相对递减的,所以其容量也相对递增。
当CPU要读取一个数据时,首先从一级缓存中查找,如果没有再从二级缓存中查找,如果还是没有再从三级缓存中或内存中查找。一般来说每级缓存的命中率大概都有80%左右,也就是说全部数据量的80%都可以在一级缓存中找到,只剩下20%的总数据量才需要从二级缓存、三级缓存或内存中读取。
3.2 使用CPU缓存带来的问题
用一张图表示一下CPU-->CPU缓存-->主内存数据读取之间的关系:
当系统运行时,CPU执行计算的过程如下:
- 程序以及数据被加载到主内存
- 指令和数据被加载到CPU缓存
- CPU执行指令,把结果写到高速缓存
- 高速缓存中的数据写回主内存
如果服务器是单核CPU,那么这些步骤不会有任何的问题,但是如果服务器是多核CPU,每个CPU都有自己独享的Cache,那么问题来了,以Intel Core i7处理器的高速缓存概念模型为例(图片摘自《深入理解计算机系统》):
试想下面一种情况:
- 核0读取了一个字节,根据局部性原理,它相邻的字节同样被被读入核0的缓存
- 核3做了上面同样的工作,这样核0与核3的缓存拥有同样的数据
- 核0修改了那个字节,被修改后,那个字节被写回核0的缓存,但是该信息并没有写回主存
- 核3访问该字节,由于核0并未将数据写回主存,数据不同步
为了解决这个问题,CPU制造商制定了一个规则(MESI):当一个CPU修改缓存中的字节时,服务器中其他CPU会被通知,它们的缓存将视为无效,即将数据所对应的Cache Line置为无效。于是,在上面的情况下,核3发现自己的缓存中数据已无效,核0将立即把自己的数据写回主存,然后核3重新读取该数据。
反汇编Java字节码,查看汇编层面对volatile关键字做了什么
有了上面的理论基础,我们可以研究volatile关键字到底是如何实现的。首先写一段简单的代码:
public class LazySingleton {
private static volatile LazySingleton instance = null;
public static LazySingleton getInstance() {
if (instance == null) {
instance = new LazySingleton();
}
return instance;
}
public static void main(String[] args) {
LazySingleton.getInstance();
}
}
首先反编译一下这段代码的.class文件,看一下生成的字节码:
没有任何特别的。要知道,字节码指令,比如上图的getstatic、ifnonnull、new等,最终对应到操作系统的层面,都是转换为一条一条指令去执行,我们使用的PC机、应用服务器的CPU架构通常都是IA-32架构的,这种架构采用的指令集是CISC(复杂指令集),而汇编语言则是这种指令集的助记符。
因此,既然在字节码层面我们看不出什么端倪,那下面就看看将代码转换为汇编指令能看出什么端倪。
代码生成的汇编指令为:
1 Java HotSpot(TM) 64-Bit Server VM warning: PrintAssembly is enabled; turning on DebugNonSafepoints to gain additional output
2 CompilerOracle: compileonly *LazySingleton.getInstance
3 Loaded disassembler from D:\JDK\jre\bin\server\hsdis-amd64.dll
4 Decoding compiled method 0x0000000002931150:
5 Code:
6 Argument 0 is unknown.RIP: 0x29312a0 Code size: 0x00000108
7 [Disassembling for mach='amd64']
8 [Entry Point]
9 [Verified Entry Point]
10 [Constants]
11 # {method} 'getInstance' '()Lorg/xrq/test/design/singleton/LazySingleton;' in 'org/xrq/test/design/singleton/LazySingleton'
12 # [sp+0x20] (sp of caller)
13 0x00000000029312a0: mov dword ptr [rsp+0ffffffffffffa000h],eax
14 0x00000000029312a7: push rbp
15 0x00000000029312a8: sub rsp,10h ;*synchronization entry
16 ; - org.xrq.test.design.singleton.LazySingleton::getInstance@-1 (line 13)
17 0x00000000029312ac: mov r10,7ada9e428h ; {oop(a 'java/lang/Class' = 'org/xrq/test/design/singleton/LazySingleton')}
18 0x00000000029312b6: mov r11d,dword ptr [r10+58h]
19 ;*getstatic instance
20 ; - org.xrq.test.design.singleton.LazySingleton::getInstance@0 (line 13)
21 0x00000000029312ba: test r11d,r11d
22 0x00000000029312bd: je 29312e0h
23 0x00000000029312bf: mov r10,7ada9e428h ; {oop(a 'java/lang/Class' = 'org/xrq/test/design/singleton/LazySingleton')}
24 0x00000000029312c9: mov r11d,dword ptr [r10+58h]
25 0x00000000029312cd: mov rax,r11
26 0x00000000029312d0: shl rax,3h ;*getstatic instance
27 ; - org.xrq.test.design.singleton.LazySingleton::getInstance@16 (line 17)
28 0x00000000029312d4: add rsp,10h
29 0x00000000029312d8: pop rbp
30 0x00000000029312d9: test dword ptr [330000h],eax ; {poll_return}
31 0x00000000029312df: ret
32 0x00000000029312e0: mov rax,qword ptr [r15+60h]
33 0x00000000029312e4: mov r10,rax
34 0x00000000029312e7: add r10,10h
35 0x00000000029312eb: cmp r10,qword ptr [r15+70h]
36 0x00000000029312ef: jnb 293135bh
37 0x00000000029312f1: mov qword ptr [r15+60h],r10
38 0x00000000029312f5: prefetchnta byte ptr [r10+0c0h]
39 0x00000000029312fd: mov r11d,0e07d00b2h ; {oop('org/xrq/test/design/singleton/LazySingleton')}
40 0x0000000002931303: mov r10,qword ptr [r12+r11*8+0b0h]
41 0x000000000293130b: mov qword ptr [rax],r10
42 0x000000000293130e: mov dword ptr [rax+8h],0e07d00b2h
43 ; {oop('org/xrq/test/design/singleton/LazySingleton')}
44 0x0000000002931315: mov dword ptr [rax+0ch],r12d
45 0x0000000002931319: mov rbp,rax ;*new ; - org.xrq.test.design.singleton.LazySingleton::getInstance@6 (line 14)
46 0x000000000293131c: mov rdx,rbp
47 0x000000000293131f: call 2907c60h ; OopMap{rbp=Oop off=132}
48 ;*invokespecial <init>
49 ; - org.xrq.test.design.singleton.LazySingleton::getInstance@10 (line 14)
50 ; {optimized virtual_call}
51 0x0000000002931324: mov r10,rbp
52 0x0000000002931327: shr r10,3h
53 0x000000000293132b: mov r11,7ada9e428h ; {oop(a 'java/lang/Class' = 'org/xrq/test/design/singleton/LazySingleton')}
54 0x0000000002931335: mov dword ptr [r11+58h],r10d
55 0x0000000002931339: mov r10,7ada9e428h ; {oop(a 'java/lang/Class' = 'org/xrq/test/design/singleton/LazySingleton')}
56 0x0000000002931343: shr r10,9h
57 0x0000000002931347: mov r11d,20b2000h
58 0x000000000293134d: mov byte ptr [r11+r10],r12l
59 0x0000000002931351: lock add dword ptr [rsp],0h ;*putstatic instance
60 ; - org.xrq.test.design.singleton.LazySingleton::getInstance@13 (line 14)
61 0x0000000002931356: jmp 29312bfh
62 0x000000000293135b: mov rdx,703e80590h ; {oop('org/xrq/test/design/singleton/LazySingleton')}
63 0x0000000002931365: nop
64 0x0000000002931367: call 292fbe0h ; OopMap{off=204}
65 ;*new ; - org.xrq.test.design.singleton.LazySingleton::getInstance@6 (line 14)
66 ; {runtime_call}
67 0x000000000293136c: jmp 2931319h
68 0x000000000293136e: mov rdx,rax
69 0x0000000002931371: jmp 2931376h
70 0x0000000002931373: mov rdx,rax ;*new ; - org.xrq.test.design.singleton.LazySingleton::getInstance@6 (line 14)
71 0x0000000002931376: add rsp,10h
72 0x000000000293137a: pop rbp
73 0x000000000293137b: jmp 2932b20h ; {runtime_call}
74 [Stub Code]
75 0x0000000002931380: mov rbx,0h ; {no_reloc}
76 0x000000000293138a: jmp 293138ah ; {runtime_call}
77 [Exception Handler]
78 0x000000000293138f: jmp 292fca0h ; {runtime_call}
79 [Deopt Handler Code]
80 0x0000000002931394: call 2931399h
81 0x0000000002931399: sub qword ptr [rsp],5h
82 0x000000000293139e: jmp 2909000h ; {runtime_call}
83 0x00000000029313a3: hlt
84 0x00000000029313a4: hlt
85 0x00000000029313a5: hlt
86 0x00000000029313a6: hlt
87 0x00000000029313a7: hlt
这么长的汇编代码,可能大家不知道CPU在哪里做了手脚,没事不难,定位到59、60两行:
0x0000000002931351: lock add dword ptr [rsp],0h ;
*putstatic instance; - org.xrq.test.design.singleton.LazySingleton::getInstance@13 (line 14)
之所以定位到这两行是因为这里结尾写明了line 14,line 14即volatile变量instance赋值的地方。后面的add dword ptr [rsp],0h都是正常的汇编语句,意思是将双字节的栈指针寄存器+0,这里的关键就是add前面的lock指令,lock是汇编语言的命令,后面详细分析一下lock指令的作用和为什么加上lock指令后就能保证volatile关键字的内存可见性。
3.3 lock指令做了什么
这里的Lock是汇编语言指令,不是之前写过的Java内存模型指定的8中操作中的lock
lock指令的几个作用:
- 锁总线,其它CPU对内存的读写请求都会被阻塞,直到锁释放,不过实际后来的处理器都采用锁缓存替代锁总线(降低粒度,以前锁了总线,使总线只能被一个CPU独享,所有的CPU公用一条总线,那么所有的CPU就都不能使用,这样大大降低了吞吐量,影响效率。锁缓存行只是将该数据的缓存行锁住,让对其操作的CPU独占,其他的缓存行不受影响,其他CPU还能对别的数据进行操作),因为锁总线的开销比较大,锁总线期间其他CPU没法访问内存。(计算机组成原理中学的总线结构)(不管是锁总线还是锁缓存行,其根本目的就是使CPU对内存中某个数据的操作是独占的,在CPU1对一个数据操作时lock指令会锁总线或者通过缓存一致性协议来锁缓存行来使其他的CPU无法对该数据进行操作,使CPU1能独享给数据的操作权)
- lock后的写操作会向主内存中回写已修改的数据,同时让其它CPU相关缓存行(Cache Line)失效,从而重新从主存中加载最新的数据
- 不是内存屏障却能完成类似内存屏障的功能,阻止屏障两遍的指令重排序。
以上可以看出lock指令就可以实现可见性和有序性。
第一条中写了由于效率问题,实际后来的处理器都采用锁缓存来替代锁总线,这种场景下多缓存的数据一致是通过缓存一致性协议来保证的,我们来看一下什么是缓存一致性协议。
3.3 缓存一致性协议
讲缓存一致性之前,先说一下缓存行(Cache Line)的概念:
缓存是分段(line)的,一个段对应一块存储空间,我们称之为缓存行(Cache Line),它是CPU缓存中可分配的最小存储单元,大小32字节、64字节、128字节不等,这与CPU架构有关,通常来说是64字节。当CPU看到一条读取内存的指令时,它会把内存地址传递给一级数据缓存,一级数据缓存会检查它是否有这个内存地址对应的缓存段,如果没有就把整个缓存段从内存(或更高一级的缓存)中加载进来。注意,这里说的是一次加载整个缓存段,这就是上面提过的局部性原理
上面说了,LOCK会锁总线,实际上这不现实,因为锁总线效率太低了。因此最好能做到:使用多组缓存,但使它们的行为看起来只有一组缓存那样。缓存一致性协议就是为了做到这一点而设计的,就像名称所暗示的那样,这类协议就是要使多组缓存的内容保持一致。
缓存一致性协议有多种,但是日常处理的大多数计算机设备都属于"嗅探(snooping)"协议,它的基本思想是:
- 所有内存的传输都发生在一条共享的总线上,而所有的处理器都能看到这条总线:缓存本身是独立的,但是内存是共享资源,所有的内存访问都要经过仲裁(同一个指令周期中,只有一个CPU缓存可以读写内存)。
- CPU缓存不仅仅在做内存传输的时候才与总线打交道,而是不停在嗅探总线上发生的数据交换,跟踪其他缓存在做什么。所以当一个缓存代表它所属的处理器去读写内存时,其它处理器都会得到通知,它们以此来使自己的缓存保持同步。只要某个处理器一写内存,其它处理器马上知道这块内存在它们的缓存段中已失效。
MESI协议是当前最主流的缓存一致性协议,在MESI协议中,每个缓存行有4个状态,可用2个bit表示,它们分别是:
这里的I、S和M状态已经有了对应的概念:失效/未载入、干净以及脏的缓存段。所以这里新的知识点只有E状态,代表独占式访问,这个状态解决了"在我们开始修改某块内存之前,我们需要告诉其它处理器"这一问题:只有当缓存行处于E或者M状态时,处理器才能去写它,也就是说只有在这两种状态下,处理器是独占这个缓存行的。当处理器想写某个缓存行时,如果它没有独占权,它必须先发送一条"我要独占权"的请求给总线,这会通知其它处理器把它们拥有的同一缓存段的拷贝失效(如果有)。只有在获得独占权后,处理器才能开始修改数据----并且此时这个处理器知道,这个缓存行只有一份拷贝,在我自己的缓存里,所以不会有任何冲突。
反之,如果有其它处理器想读取这个缓存行(马上能知道,因为一直在嗅探总线),独占或已修改的缓存行必须先回到"共享"状态。如果是已修改的缓存行,那么还要先把内容回写到内存中。
简单来说,MESI协议就是:
- 读操作:不做任何事情,把Cache中的数据读到寄存器
- 写操作:发出信号通知其他的CPU将该变量的Cache line置为无效,其他的CPU要访问这个变量的时候,只能从主内存中获取。
3.4 内存屏障(Memory Barrier)
Memory barrier 能够让 CPU 或编译器在内存访问上有序。一个 Memory barrier 之前的内存访问操作必定先于Memory barrier之后的操作完成。
Memory barrier是一种CPU指令,用于控制特定条件下的重排序和内存可见性问题。Java编译器也会根据内存屏障的规则禁止重排序。
有的处理器的重排序规则较严,无需内存屏障也能很好的工作,Java编译器会在这种情况下不放置内存屏障。
Memory Barrier可以被分为以下几种类型:
- LoadLoad屏障:对于这样的语句Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
- StoreStore屏障:对于这样的语句Store1; StoreStore; Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。
- LoadStore屏障:对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。
- StoreLoad屏障:对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。它的开销是四种屏障中最大的。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能
3.4.1 volatile语义中的内存屏障
- 在每个volatile写操作前插入StoreStore屏障,在写操作后插入StoreLoad屏障;
- 在每个volatile读操作前插入LoadLoad屏障,在读操作后插入LoadStore屏障;
volatile的内存屏障策略非常严格保守,保证了线程可见性。
3.4.2 final语义中的内存屏障
- 新建对象过程中,构造体中对final域的初始化写入(StoreStore屏障)和这个对象赋值给其他引用变量,这两个操作不能重排序;
- 初次读包含final域的对象引用和读取这个final域(LoadLoad屏障),这两个操作不能重排序;
3.5 汇编指令LOCK与内存屏障之间的关系?
LOCK汇编命令使相应的机器码指令中添加了相关内存屏障指令,也就是说汇编层面LOCK指令的功能是通过CPU层面的内存屏障机器码实现的。
四、JMM与多线程通信
Java线程之间的通信由Java内存模型(JMM)控制,JMM决定一个线程对共享变量的写入何时对另一个线程可见。
从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(main memory)中,每个线程都有一个私有的本地内存(local memory),本地内存中存储了该线程以读/写共享变量的副本。本地内存是JMM的一个抽象概念,并不真实存在,它涵盖了缓存,写缓冲区,寄存器以及其他的硬件和编译器优化。Java内存模型的抽象示意图如下:
线程间通信的步骤:
- 首先,线程A把本地内存A中更新过的共享变量刷新到主内存中去。
- 然后,线程B到主内存中去读取线程A之前已更新过的共享变量。
本地内存A和B有主内存***享变量x的副本。
- 假设初始时,这三个内存中的x值都为0。线程A在执行时,把更新后的x值(假设值为1)临时存放在自己的本地内存A中。
- 当线程A和线程B需要通信时(如何激发?--隐式),线程A首先会把自己本地内存中修改后的x值刷新到主内存中,此时主内存中的x值变为了1。
- 随后,线程B到主内存中去读取线程A更新后的x值,此时线程B的本地内存的x值也变为了1。
从整体来看,这两个步骤实质上是线程A在向线程B发送消息,而且这个通信过程必须要经过主内存。JMM通过控制主内存与每个线程的本地内存之间的交互,来为java程序员提供内存可见性保证。
五、Volatile关键字的语义分析
5.1 volatile的原理
由lock指令回看volatile变量读写。可以知道volatile实际是靠lock指令(这是汇编语言的LOCK指令,不是JMM中的lock操作,JMM中的lock操作是加锁,它是synchronized的实现基础)为基础来实现的。
相信有了上面对于lock的解释,以及对CPU多级缓存架构以及JAVA内存模型的理解,volatile关键字的实现原理应该是一目了然了。由上面JMM的结构图可知,工作内存Work Memory其实就是对CPU寄存器和高速缓存的抽象,或者说每个线程的工作内存也可以简单理解为CPU寄存器和高速缓存。
那么当写两条线程Thread-A与Threab-B同时操作主存中的一个volatile变量i时
- Thread-A写了变量i,那么:
- Thread-A发出LOCK#指令
- 发出的LOCK#指令锁总线(或锁缓存行),同时让Thread-B高速缓存中的缓存行内容失效
- Thread-A向主存回写最新修改的i
- Thread-B读取变量i,那么:
- Thread-B发现对应地址的缓存行被锁了,等待锁的释放,缓存一致性协议会保证它读取到最新的值,也就是当锁释放之后Thread-B发现对应的Cache Line已经失效了,只能去主内存中读取最新的值。
由此可以看出,volatile关键字的读和普通变量的读取相比基本没差别,差别主要还是在变量的写操作上。
当对volatile变量进行写操作的时候,JVM会向处理器发送一条lock前缀的指令,将这个缓存中的变量回写到系统主存中。
但是就算写回到内存,如果其他处理器缓存的值还是旧的,再执行计算操作就会有问题,所以在多处理器下,为了保证各个处理器的缓存是一致的,就会实现上面所讲的缓存一致性协议
缓存一致性协议:每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器要对这个数据进行修改操作的时候,会强制重新从系统内存里把数据读到处理器缓存里。
所以,如果一个变量被volatile所修饰的话,在每次数据变化之后,其值都会被强制刷入主存。而其他处理器的缓存由于遵守了缓存一致性协议,也会把这个变量的值从主存加载到自己的缓存中。这就保证了一个volatile在并发编程中,其值在多个缓存中是可见的。
总结:
volatile可以保证线程可见性且提供了一定的有序性,但是无法保证原子性。在JVM底层volatile是采用“内存屏障”来实现的。观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令,lock前缀指令实际上相当于一个内存屏障(也称内存栅栏),内存屏障会提供3个功能:
- 它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;(保证有序性)
- 它会强制将对缓存的修改操作立即写入主存;(保证可见性)
- 如果是写操作,它会导致其他CPU中对应的缓存行无效。(保证可见性)
5.2 volatile的用法
volatile通常被比喻成"轻量级的synchronized",也是Java并发编程中比较重要的一个关键字。和synchronized不同,volatile是一个变量修饰符,只能用来修饰变量。无法修饰方法及代码块等。
volatile的用法比较简单,只需要在声明一个可能被多线程同时访问的变量时,使用volatile修饰就可以了。
5.3 volatile的作用
让其他线程能够马上感知到某一线程多某个变量的修改(两种作用对应的实现原理见总结):
- 保证可见性
- 保证有序性
volatile不能保证原子性
线程写volatile变量的过程:
- 改变线程工作内存中volatile变量副本的值
- 将改变的副本的值从工作内存中刷新到主内存中
线程读volatile变量的过程:
- 1.从主内存中读取volatile变量的最新值到工作内存中
- 2.从工作内存中读取volatile变量的副本
5.3.1 volatile与可见性
可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
Java内存模型规定了所有的变量都存储在主内存中,每条线程还有自己的工作内存,线程的工作内存中保存了该线程中是用到的变量的主内存副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量的传递均需要自己的工作内存和主存之间进行数据同步进行。所以,就可能出现线程1改了某个变量的值,但是线程2不可见的情况。
前面的关于volatile的作用中介绍过了,Java中的volatile关键字提供了一个功能,那就是被其修饰的变量在被修改后可以立即同步到主内存,被其修饰的变量在每次是用之前都从主内存刷新。因此,可以使用volatile来保证多线程操作时变量的可见性。
现在回到最开始引入的第一个代码出现的问题,就可以用volatile关键字来解决。将init_value变量用volatile修饰
public class ReaderAndUpdater {
final static int MAX=5;
static volatile int init_value=0;
public static void main(String[] args) {
new Thread(()->{
int localValue=init_value;
while(localValue<MAX){
if(localValue!=init_value){
System.out.println("Reader:"+init_value);
localValue=init_value;
}
}
},"Reader").start();
new Thread(()->{
int localValue=init_value;
while(localValue<MAX){
System.out.println("updater:"+(++localValue));
init_value=localValue;
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
},"Updater").start();
}
}
运行结果:
updater:1
Reader:1
updater:2
Reader:2
updater:3
Reader:3
updater:4
Reader:4
updater:5
Reader:5
可以看出现在代码就实现了同步,读线程能够感知到写线程对init_value变量的修改,也就保证了可见性。
5.3.2 volatile与有序性
有序性即程序执行的顺序按照代码的先后顺序执行。
除了引入了时间片以外,由于处理器优化和指令重排等,CPU还可能对输入代码进行乱序执行,比如load->add->save 有可能被优化成load->save->add 。这就是可能存在有序性问题。
而volatile除了可以保证数据的可见性之外,还有一个强大的功能,那就是它可以禁止指令重排优化等。
普通的变量仅仅会保证在该方法的执行过程中所依赖的赋值结果的地方都能获得正确的结果,而不能保证变量的赋值操作的顺序与程序代码中的执行顺序一致。
volatile可以禁止指令重排,这就保证了代码的程序会严格按照代码的先后顺序执行。这就保证了有序性。被volatile修饰的变量的操作,会严格按照代码顺序执行,load->add->save 的执行顺序就是:load、add、save。
即执行到volatile变量时,其前面的所有语句都执行完,后面所有语句都未执行。且前面语句的结果对volatile变
量及其后面语句可见。
5.3.3 volatile与原子性
原子性是指一个操作是不可中断的,要全部执行完成,要不就都不执行。
线程是CPU调度的基本单位。CPU有时间片的概念,会根据不同的调度算法进行线程调度。当一个线程获得时间片之后开始执行,在时间片耗尽之后,就会失去CPU使用权。所以在多线程场景下,由于时间片在线程间轮换,就会发生原子性问题。
为了保证原子性,需要通过字节码指令monitorenter和monitorexit,但是volatile和这两个指令之间是没有任何关系的。
所以,volatile是不能保证原子性的。
我们来看一下volatile和原子性的例子:
public class Test {
public volatile int inc = 0;
public void increase() {
inc++;
}
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++)
test.increase();
};
}.start();
}
while(Thread.activeCount()>1) //保证前面的线程都执行完
Thread.yield();
System.out.println(test.inc);
}
}
以上代码比较简单,就是创建10个线程,然后分别执行1000次i++操作。正常情况下,程序的输出结果应该是10000,但是,多次执行的结果都小于10000。这其实就是volatile无法满足原子性的原因。
为什么会出现这种情况呢,那就是因为虽然volatile可以保证inc在多个线程之间的可见性。但是无法inc++的原子性。
六、Volatile的适用场景
使用volatile修饰的变量最好满足以下条件:
1.运算结果并不依赖变量的当前值,或者能够确保只有单一的线程会修改变量的值。
- 不满足:num++、count = count * 5
- 满足:boolean值变量,记录温度变化的变量等等
2.该变量没有包含在具有其他变量的不变式中
- 不满足:low < up
如果满足以上的条件的任意一个,就可以不用synchronized,用volatile就可以。一般的应用场景很多会不满足其中一个,所以volatile的使用没有synchronized这么广泛。
这里举几个比较经典的场景:
- 状态标记量(开关模式),就是前面引入中重排序问题的例子.
public class ShutDowsnDemmo extends Thread{
private volatile boolean started=false;
@Override
void run() {
while(started){
dowork();
}
}
public void shutdown(){
started=false;
}
}
状态标记两可以用作某种操作的开关,一个作业线程在关闭状态无法执行,它的开关标记是一个volatile修饰的变量,另一个线程修改该变量值,作业线程就能立刻感知到开关被修改,就能进入运行状态。
- 一次性安全发布.双重检查锁定问题(单例模式的双重检查 double-checked-locking DCL).
public class Singleton {
private volatile static Singleton instance;
public static Singleton getInstance(){
if(instance==null){
synchronized (Singleton.class){
instance=new Singleton();
}
}
return instance;
}
}
- 独立观察.如果系统需要使用最后登录的人员的名字,这个场景就很适合.
- 开销较低的“读-写锁”策略.当读操作远远大于写操作,可以结合使用锁和volatile来提升性能.
七、Volatile不适用的场景
不满足前面所讲的Volatile适用场景的条件的话,就说明需要保证原子性,就需要加锁(使用synchronized、lock或者java.util.concurrent中的Atomic原子类)来保证并发中的原子性。
volatile不适合复合操作
例如,inc++不是一个原子性操作,可以由读取、加、赋值3步组成,所以结果并不能达到10000。
public class Test {
public volatile int inc = 0;
public void increase() {
inc++;
}
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++)
test.increase();
};
}.start();
}
while(Thread.activeCount()>1) //保证前面的线程都执行完
Thread.yield();
System.out.println(test.inc);
}
}
解决方法:
1.采用synchronized
2.采用Lock
3.采用java并发包中的原子操作类,原子操作类是通过CAS循环的方式来保证其原子性的
八、volatile与synchronized的区别
8.1 使用上的区别
Volatile只能修饰变量,使用范围较小;synchronized可以修饰方法和语句块,作用域可以是对象或者类,适用范围更广
8.2 对原子性的保证
synchronized可以保证原子性,Volatile不能保证原子性
8.3 对可见性的保证
都可以保证可见性,但实现原理不同
Volatile对变量加了lock,synchronized使用monitorEnter和monitorexit monitor JVM
8.4 对有序性的保证
Volatile能保证有序,synchronized可以保证有序性,但是代价(重量级)并发退化到串行
8.5 性能上的区别
synchronized是靠加锁实现的,引起阻塞
volatile是靠Lock指令实现的,不需要加锁,不会引起阻塞
性能上volatile比synchronized要好,volatile 的读性能消耗与普通变量几乎相同,但是写操作稍慢,因为它需要在本地代码中插入许多内存屏障指令来保证处理器不发生乱序执行。但是volatile的性能比synchronized加锁好很多。
九、总结
volatile是轻量级同步机制,与synchronized相比,他的开销更小一些,同时安全性也有所降低,在一些特定的场景下使用它可以在完成并发目标的基础上有一些性能上的优势.但是同时也会带来一些安全上的问题,且比较难以排查,使用时需要谨慎.volatile并不能保证操作的原子性,想要保证原子性请使用synchronized关键字加锁.
其他相关文章:【并发编程】synchronized关键字最全详解,看这一篇就够了
【并发编程】线程安全和线程不安全的定义以及实现线程安全的方法有哪些
【并发编程】Java中的锁有哪些?各自都有什么样的特性?
参考链接:
http://www.cnblogs.com/xrq730/p/7048693.html
https://blog.csdn.net/vking_wang/article/details/8574376
https://blog.csdn.net/u012723673/article/details/80682208
【Java内存模型】Java内存模型(JMM)详解以及并发编程的三个重要特性(原子性,可见性,有序性)
深入理解Java虚拟机:JVM高级特性与最佳实践(第3版) - 周志明