logo头像
Snippet 博客主题

【Java并发编程实战】-浅谈volatile内存可见性

本文于651天之前发表,文中内容可能已经过时

volatile这个关键字大家都听过,看过许多源码也用过,这个关键字备受争议,很多人基于表面理解而导致在实际开发过生中大肆误用,暴露出各式各样的问题,让人摸不着头脑,今天我们来好好理解一下volatile。

  • 百度百科:volatile是一个类型修饰符.volatile的作用是作为指令关键字,确保本条指令不会因编译器的优化而省略,且要求每次直接读值;
  • volatile是Java虚拟机提供的轻量级同步机制的关键字,使被修饰的变量在多个线程可见;
  • volatile变量具有 synchronized 的可见性特性,与synchronize有相似的同步功能,但不具备原子特性,在多线程并发下是不安全的。

两种特性

特性归纳

当一个变量被定义成volatile之后,它将具备如下两种特性:

保证此变量对所有线程的可见性。即指当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即感知的,而相比之下,普通变量不能做到这点,变量值在线程间传递均需要通过主内存来完成。比如线程1修改a的值,然后向主内存进行回写,另外一条线程2在线程1回写完成了之后再从主内存进行读取操作,新变量的值才会对线程2可见。

禁止指令重排序优化。普通变量仅仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值操作的顺序与程序代码中的执行顺序一致。因为在一个线程的方法执行过程中无法感知到这点,这就是所谓的“线程内表现为串行的语义”(Within-Thread As-If-Serial Semantics)。

代码示例

多线程并发安全

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
/**
* @author tong.li
* @description: volatile只保证内存间线程可见性,并不保证线程安全性
* @date 2018-06-12 11:11:23
*/
public class VolatileTest {

//共享变量
public volatile int num = 0;

//线程数量
public static final int THREAD_COUNT = 20;

public void add() {
//num进行非原子操作
num++;
}

public static void main(String[] args) {
final VolatileTest test = new VolatileTest();
//创建20个线程
Thread[] threads = new Thread[THREAD_COUNT];
for (int i = 0; i < THREAD_COUNT ; i++) {
threads[i] = new Thread(() -> {
//当前线程操作10000次对共享变量num进行自加操作
for (int j = 0; j < 10000; j++) {
test.add();
}
});
//启动当前线程
threads[i].start();
}
//活动线程的当前线程的线程组中的数量大于1时,执行线程让步,让活动的线程尽可能执行完
// while (Thread.activeCount() > 1) {
// Thread.yield();
//}
//打印最终结果,结果多数小于200000,为什么呢?
System.out.println("num = " + test.num);
}
}

运行结果如下:

  • 第1次

    1
    num = 132203
  • 第2次

    1
    num = 160740
  • 第3次

    1
    num = 187482

上述代码创建20个线程,每个线程对num进行10000次自增操作,如果上述代码正确并发(不考虑线程安全问题)的情况下,最后的结果应该是200000,但是为实际三次运行结果令人匪夷所思,多次的运行结果大部分情况下都是小于200000,这是为什么呢?

我们都知道num++是非原子性操作,我们可以尝试进行javap反编译上述代码一探究竟:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
Yeamin:java mac$ javap -c VolatileTest

Compiled from "VolatileTest.java"

public class VolatileTest {

public volatile int num;

public static final int THREAD_COUNT;

public VolatileTest();

Code:
0: aload_0

1: invokespecial #1 // Method java/lang/Object."<init>":()V

4: aload_0

5: iconst_0

6: putfield #2 // Field num:I

9: return

public void add();

Code:
0: aload_0

1: dup

2: getfield #2 // Field num:I

5: iconst_1

6: iadd

7: putfield #2 // Field num:I

10: return

public static void main(java.lang.String[]);

Code:

0: new #3 // class VolatileTest

3: dup

4: invokespecial #4 // Method "<init>":()V

7: astore_1

8: bipush 20

10: anewarray #5 // class java/lang/Thread

13: astore_2

14: iconst_0

15: istore_3

16: iload_3

17: bipush 20

19: if_icmpge 52

22: aload_2

23: iload_3

24: new #5 // class java/lang/Thread

27: dup

28: new #6 // class VolatileTest$1

31: dup

32: aload_1

33: invokespecial #7 // Method VolatileTest$1."<init>":(LVolatileTest;)V

36: invokespecial #8 // Method java/lang/Thread."<init>":(Ljava/lang/Runnable;)V

39: aastore

40: aload_2

41: iload_3

42: aaload

43: invokevirtual #9 // Method java/lang/Thread.start:()V

46: iinc 3, 1

49: goto 16

52: invokestatic #10 // Method java/lang/Thread.activeCount:()I

55: iconst_1

56: if_icmple 65

59: invokestatic #11 // Method java/lang/Thread.yield:()V

62: goto 52

65: getstatic #12 // Field java/lang/System.out:Ljava/io/PrintStream;

68: new #13 // class java/lang/StringBuilder

71: dup

72: invokespecial #14 // Method java/lang/StringBuilder."<init>":()V

75: ldc #15 // String num =

77: invokevirtual #16 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;

80: aload_1

81: getfield #2 // Field num:I

84: invokevirtual #17 // Method java/lang/StringBuilder.append:(I)Ljava/lang/StringBuilder;

87: invokevirtual #18 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;

90: invokevirtual #19 // Method java/io/PrintStream.println:(Ljava/lang/String;)V

93: return

}

在上述反编译的字节码分析得知,num++不是一个原子操作,即线程不安全,可以将num++简单分解成以下几个步骤:

  1. 从内存中取出num的值;

  2. 计算num的值;

  3. 将num的值刷新到主内存中

add()方法有6条指令(每个指令可能含有多个底层指令操作,只为研究,并不准确,要想准确研究建议使用-XX:+PrintAssembly参数输出反汇编),其中return指令可以忽略不计,因为不是num++产生的。

  1. aload_0:在非静态方法中,aload_0 表示对this的操作,在static 方法中,aload_0表示对方法的第一参数的操作;
  2. dup:复制操作数栈顶值,并将其压入栈顶,在这里是将当前VolatileTest下new对象压入栈顶;
  3. getfield/getstatic:将栈顶的对象第二个实例字段压入栈顶,即将num的值压入栈顶,此时num的值是最新的;
  4. iconst_1:将int型(1)推送至栈顶,即把常量1压入栈顶;
  5. iadd:把操作数栈中的前两个int值出栈并相加,操作过后只在stack中保留结果,即使num当前值+1保留在栈中,这里多线程时候可能出现问题,其他线程相加的结果会覆盖当前栈顶的结果,因而返回较小的结果
  6. putfield:将最终的栈顶结果的值刷新到主内存中;
  7. return:栈中的数据返回后,结束方法

禁止指令重排序

volatile关键字禁止指令重排序有两层意思:

  • 当程序执行到volatile变量的读操作或者写操作时,在其前面的操作的更改肯定全部已经进行,且结果已经对后面的操作可见;在其后面的操作肯定还没有进行;
  • 在进行指令优化时,不能将在对volatile变量访问的语句放在其后面执行,也不能把volatile变量后面的语句放到其前面执行。

举个实例,请看下面的代码:

1
2
3
4
5
6
7
//x、y为非volatile变量
//flag为volatile变量
int x = 2; //语句1
int y = 0; //语句2
volatile boolean flag = true; //语句3
int x = 4; //语句4
int y = -1; //语句5

由于flag变量为volatile变量,那么在进行指令重排序的过程的时候,不会将语句3放到语句1、语句2前面,也不会讲语句3放到语句4、语句5后面。但是要注意语句1和语句2的顺序、语句4和语句5的顺序是不作任何保证的。

并且volatile关键字能保证,执行到语句3时,语句1和语句2必定是执行完毕了的,且语句1和语句2的执行结果对语句3、语句4、语句5是可见的。

我们在看看一个例子:

1
2
3
4
5
6
7
8
//线程1:
ApplicationConext context = loadContext(); //语句1
boolean inited = true; //语句2
//线程2:
while(!inited ){
sleep()
}
doSomethingwithconfig(context);

上述代码有可能语句2会在语句1之前执行,因为语句2并不依赖依赖语句2,可能会重排序优化,那么久可能导致context还没被初始化,而线程2中就使用未初始化的context去进行操作,导致程序出错。

这里如果用volatile关键字对inited变量进行修饰,就不会出现这种问题了,因为当执行到语句2时,必定能保证context已经初始化完毕。

对变量特殊的定义规则

假设T表示一个线程,V和W表示两个volatile变量,那么在进行read、load、use、assign、store和write操作时需要满足如下的规则:

  • 只有一个当线程T对变量V执行的前一个动作是load的时候,T才能对变量V执行use动作,并且当对V的后一个执行是use动作时,T才能对V执行load动作。T对V的use动作其实是衔接T对V的load和read动作,load、read必须一起连续出现(这条规则要求在工作内存每次使用V前都必须先从主内存刷新最新值,使得保证能看见其他线程对变量V的最新更改)。
  • 只有一个当线程T对变量V执行的前一个动作是assign的时候,T才能对变量V执行store动作,并且当对V的后一个执行是store动作时,T才能对V执行assign动作。T对V的assign动作其实是衔接T对V的store和write动作,store、write必须一起连续出现(这条规则要求在工作内存每次修改V后都必须先必须同步到主内中,使得保证其他线程能看见TT对变量V的最新更改)。
  • 假设动作A是线程T对变量VV实施的use或assign动作,假定动作F是与动作A相关联的load和store动作,假定动作PP是与动作FF相应的对V的read和load动作;类似地,假定动作B是线程TT对变量W实施的use和assign动作,假定动作GG是和动作BB相关联的load或store动作,假定动作Q是与动作G相应的对变量W的read和write动作。如果A先于B,那么P先于Q(这条规则要求volatile修饰的变量不会被指令重排序优化,保证代码的执行顺序与程序的顺序相同),即是Happen-before原则。

内存屏障其volatile原理

内存屏障定义

在研究volatile原理时候,我们来谈谈内存屏障或者内存栅栏。

内存屏障/内存栅栏(Memory Barrier或Menory Fence):重排序时不能把后面的指令重排序到内存屏障之前的位置,指令重排序无法越过内存屏障,单例模式的DCL双锁检查可以体现出来。

只有一个CPU访问内存时,并不需要内存屏障,但如果有多个CPU访问同一块内存,且其中有一个在观测另一个,就需要内存屏障来保证一致。

从硬件架构上讲,指令重排序是指CPU采取可允许将多条指令不按程序规定的顺序分开发送给各相应单路处理单元。但并不是指令任意重排,CPU需要能正确处理指令依赖情况以保障程序能够得出正确的执行结果。

由于现代的操作系统都是多处理器.而每一个处理器都有自己的缓存,并且这些缓存并不是实时都与内存发生信息交换.这样就可能出现一个cpu上的缓存数据与另一个cpu上的缓存数据不一致的问题。而这样在多线程开发中,就有可能导致出现一些异常行为。而操作系统底层为了这些问题,提供了一些内存屏障用以解决这样的问题,目前有4种屏障:

  • LoadLoad屏障:对于这样的语句Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
  • StoreStore屏障:对于这样的语句Store1; StoreStore; Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。
  • LoadStore屏障:对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。
  • StoreLoad屏障:对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。它的开销是四种屏障中最大的。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能。

内存屏障JAVA应用

  • 通过 Synchronized关键字包住的代码区域,当线程进入到该区域读取变量信息时,保证读到的是最新的值。这是因为在同步区内对变量的写入操作,在离开同步区时就将当前线程内的数据刷新到内存中,而对数据的读取也不能从缓存读取,只能从内存中读取,保证了数据的读有效性,这就是插入了StoreStore屏障。
  • 使用了volatile修饰变量,则对变量的写操作,会插入StoreLoad屏障。
  • 其余的操作,则需要通过Unsafe这个类来执行。UNSAFE.putOrderedObject类似这样的方法,会插入StoreStore内存屏障,Unsafe.putVolatiObject 则是插入了StoreLoad屏障

适用场景

由于volatile变量只保证可见性,在不符合以下两点,我们仍然要通过加锁(使用同步锁synchronize或java.util.concurrent中的原子类)来保证原子性。

  • 运算结果以及对变量的写操作并不依赖变量的当前值,或能够确保只有单一的线程修改变量的值;
  • 变量不需要与其他的状态变量共享参与不变约束;
  • 适用于读多写少的场景;
  • 可用作状态标志。

JDK中volatie应用:JDK中ConcurrentHashMap的Node的val和next被声明为volatile,AtomicInteger等原子类中的value被声明为volatile。AtomicInteger通过CAS原理(暂可理解为乐观锁)保证了原子性。这些应用后文讲解。

volatile与synchronized

上述说到的volatile的语义问题,其实volatile变量读操作的性能与普通变量几乎没有什么差别,但是写操作会稍慢点,因为volatile需要在执行中插入许多内存屏障指令来保证处理器不发生乱序执行。

  • 在某些情况下volatile的同步机制的性能的确要优于锁(synchronize同步锁或J.U.C包的锁),但是JDK1.6以后对锁实行了许多消除和优化,使得我们很难量化认为volatile比锁快多少。
  • 多线程访问volatile不会发生阻塞,而synchronized会阻塞;
  • volatile只能保证数据的可见性,不能保证原子性,而synchronized两者都可以保证,因为它会将私有内存和公共内存中的数据做同步

对long和double型变量的特殊规则

内存的八个操作虽然说都是原子性的,但是对于64位的数据类型:long和double来讲,允许虚拟机将没有被volatile修饰的64位数据的读写操作划分为两次32位的操作来进行,这就是所谓的long和double的非原子性协定。虽然规定为规定,但是目前大部分商业虚拟机将这两种数据类型作64位的原子性操作对待,因为我们不需要将long和double类型的变量专门什么为volatile变量。

参考资料

  • 《深入理解Java虚拟机(第2版)》周志明著
  • 《Java多线程编程核心技术》高洪岩著
支付宝打赏 微信打赏

请作者喝杯咖啡吧