多线程进阶-volatile
Table of Contents generated with DocToc (opens new window)
# 4、volatile
对volatile的理解
volatile是java虚拟机提供的轻量级的同步机制
保证可见性
demo:
public class VolatileDemo { // private static volatile int number = 0; private static int number = 0; public static void main(String[] args) throws InterruptedException { new Thread(()->{ while(number == 0){ } }).start(); TimeUnit.SECONDS.sleep(1); //主线程将number修改为1,理想状况此时上面的线程应该停止循环 //但因为此时的number的改变对其不可见,故一直循环 //number用volatile修饰后,即可保证所有线程对number的修改对任何一个 //其他线程都是可见的 number = 1; System.out.println("number = " + number); } }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
不保证原子性
public class VolatileDemo02 { private static volatile int number = 0; public static void add(){ number++; } public static void main(String[] args) { //正确结果为10000 for (int i = 0; i < 10; i++) { new Thread(()->{ for (int j = 0; j < 1000; j++) { add(); } }).start(); } //大于2是为了判断除了main线程和gc线程外还是否有上面的线程没执行完 while(Thread.activeCount()>2){ //如果有,主线程让出CPU执行权 Thread.yield(); } System.out.println("number = " + number); } }
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多次运行上述代码可以发现,number为正确结果的概率微乎其微,这就是因为 number++实际并不是一个原子操作,它在字节码文件中被拆分成3步:获取值->对number+1->写回值,当一个线程执行这其中一步的时候,可能其他线程也正在对number进行操作,这就导致了number++的非原子性------->volatile虽然可以保证可见性,但并不能保证原子性。
在不使用lock和synchronized的情况下,我们可以使用juc包下的atomic包中的各个原子类来实现原子性
以下代码即可保证原子性
public class VolatileDemo02 { // private static volatile int number = 0; private static AtomicInteger number = new AtomicInteger(0); public static void add(){ // number++; //相当于number++ number.getAndIncrement(); //相当于++number // number.incrementAndGet(); } public static void main(String[] args) { //正确结果为10000 for (int i = 0; i < 10; i++) { new Thread(()->{ for (int j = 0; j < 1000; j++) { add(); } }).start(); } //大于2是为了判断除了main线程和gc线程外还是否有上面的线程没执行完 while(Thread.activeCount()>2){ //如果有,主线程让出CPU执行权 Thread.yield(); } System.out.println("number = " + number); } }
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禁止指令重排
指令重排的概念:
在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序。重排序分三种类型:
- 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
- 指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应的机器指令的执行顺序。
- 内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行
三种重排序:
从java源代码到最终实际执行的指令序列,会分别经历下面三种重排序: 上述的1属于编译器重排序,2和3属于处理器重排序。这些重排序都可能会导致多线程程序出现内存可见性问题。对于编译器,JMM的编译器重排序规则会禁止特定类型的编译器重排序(不是所有的编译器重排序都要禁止)。对于处理器重排序,JMM的处理器重排序规则会要求java编译器在生成指令序列时,插入特定类型的内存屏障(memory barriers,intel称之为memory fence)指令,通过内存屏障指令来禁止特定类型的处理器重排序(不是所有的处理器重排序都要禁止)。
JMM属于语言级的内存模型,它确保在不同的编译器和不同的处理器平台之上,通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。
# 处理器重排序与内存屏障指令
现代的处理器(物理处理器即CPU)使用写缓冲区来临时保存向内存写入的数据。写缓冲区可以保证指令流水线持续运行,它可以避免由于处理器停顿下来等待向内存写入数据而产生的延迟。同时,通过以批处理的方式刷新写缓冲区,以及合并写缓冲区中对同一内存地址的多次写,可以减少对内存总线的占用。虽然写缓冲区有这么多好处,但每个处理器上的写缓冲区,仅仅对它所在的处理器可见。这个特性会对内存操作的执行顺序产生重要的影响:处理器排序后对内存的读/写操作的执行顺序,不一定与内存实际发生的读/写操作顺序一致!
线程A 线程B a=1;//A1 b=2;//B1 x=b;//A2 y=a;//B2 初始状态:a=b=0;//A3 处理器允许执行后得到结果:x=y=0 指令可能按照这样运行:A3->A2-B2->A1-A2;
最后得到的结果是x=y=0,与预想中x=2,y=1不符
这里的关键是,由于写缓冲区仅对自己的处理器可见,它会导致处理器执行内存操作的顺序可能会与内存实际的操作执行顺序不一致。由于现代的处理器都会使用写缓冲区,因此现代的处理器都会允许对写-读操做重排序。
为了保证内存可见性,java编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序。JMM把内存屏障指令分为下列四类:
屏障类型 指令示例 说明 LoadLoad Barriers Load1; LoadLoad; Load2 确保Load1数据的装载,之前于Load2及所有后续装载指令的装载。 StoreStore Barriers Store1; StoreStore; Store2 确保Store1数据对其他处理器可见(刷新到内存),之前于Store2及所有后续存储指令的存储。 LoadStore Barriers Load1; LoadStore; Store2 确保Load1数据装载,之前于Store2及所有后续的存储指令刷新到内存。 StoreLoad Barriers Store1; StoreLoad; Load2 确保Store1数据对其他处理器变得可见(指刷新到内存),之前于Load2及所有后续装载指令的装载。StoreLoad Barriers会使该屏障之前的所有内存访问指令(存储和装载指令)完成之后,才执行该屏障之后的内存访问指令。 StoreLoad Barriers是一个"全能型"的屏障,它同时具有其他三个屏障的效果。现代的多处理器大都支持该屏障(其他类型的屏障不一定被所有处理器支持)。执行该屏障开销会很昂贵,因为当前处理器通常要把写缓冲区中的数据全部刷新到内存中(buffer fully flush)。
# 数据依赖性
如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。数据依赖分下列三种类型:
名称 代码示例 说明 写后读 a = 1; b = a; 写一个变量之后,再读这个位置。 写后写 a = 1;a = 2; 写一个变量之后,再写这个变量。 读后写 a = b;b = 1; 读一个变量之后,再写这个变量。 上面三种情况,只要重排序两个操作的执行顺序,程序的执行结果将会被改变。
前面提到过,编译器和处理器可能会对操作做重排序。编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。 注意,这里所说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑。
# as-if-serial语义
as-if-serial语义的意思指:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器,runtime 和处理器都必须遵守as-if-serial语义。
\
# 先行发生(happens-before)原则:
前面所述的内存交互操作必须要满足一定的规则,而happens-before就是定义这些规则的一个等效判断原则。happens-before是JMM定义的2个操作之间的偏序关系:如果操作A线性发生于操作B,则A产生的影响能被操作B观察到,“影响”包括修改了内存中共享变量的值、发送了消息、调用了方法等。如果两个操作满足happens-before原则,那么不需要进行同步操作,JVM能够保证操作具有顺序性,此时不能够随意的重排序。否则,无法保证顺序性,就能进行指令的重排序。
Java内存模型中定义的两项操作之间的次序关系,如果说操作A先行发生于操作B,操作A产生的影响能被操作B观察到,“影响”包含了修改了内存中共享变量的值、发送了消息、调用了方法等。 下面是Java内存模型下一些”天然的“happens-before关系,这些happens-before关系无须任何同步器协助就已经存在,可以在编码中直接使用。如果两个操作之间的关系不在此列,并且无法从下列规则推导出来的话,它们就没有顺序性保障,虚拟机可以对它们进行随意地重排序。 a.程序次序规则(Pragram Order Rule):在一个线程内,按照程序代码顺序,书写在前面的操作先行发生于书写在后面的操作。准确地说应该是控制流顺序而不是程序代码顺序,因为要考虑分支、循环结构。 b.管程锁定规则(Monitor Lock Rule):一个unlock操作先行发生于后面对同一个锁的lock操作。这里必须强调的是同一个锁,而”后面“是指时间上的先后顺序。 c.volatile变量规则(Volatile Variable Rule):对一个volatile变量的写操作先行发生于后面对这个变量的读取操作,这里的”后面“同样指时间上的先后顺序。 d.线程启动规则(Thread Start Rule):Thread对象的start()方法先行发生于此线程的每一个动作。 e.线程终于规则(Thread Termination Rule):线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过Thread.join()方法结束,Thread.isAlive()的返回值等作段检测到线程已经终止执行。 f.线程中断规则(Thread Interruption Rule):对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测是否有中断发生。 g.对象终结规则(Finalizer Rule):一个对象初始化完成(构造方法执行完成)先行发生于它的finalize()方法的开始。 g.传递性(Transitivity):如果操作A先行发生于操作B,操作B先行发生于操作C,那就可以得出操作A先行发生于操作C的结论。 一个操作”时间上的先发生“不代表这个操作会是”先行发生“,那如果一个操作”先行发生“是否就能推导出这个操作必定是”时间上的先发生 “呢?也是不成立的,一个典型的例子就是指令重排序。所以时间上的先后顺序与happens-before原则之间基本没有什么关系,所以衡量并发安全问题一切必须以happens-before 原则为准。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21注意:不同操作时间先后顺序与先行发生原则之间没有关系,二者不能相互推断,衡量并发安全问题不能受到时间顺序的干扰,一切都要以happens-before原则为准
示例:
private int value = 0; public void setValue(int value) { this.value = value; } public int getValue() { return this.value; } /*对于上面的代码,假设线程A在时间上先调用setValue(1),然后线程B调用getValue()方法,那么线程B收到的返回值一定是1吗? 按照happens-before原则,两个操作不在同一个线程、没有通道锁同步、线程的相关启动、终止和中断以及对象终结和传递性等规则都与此处没有关系,因此这两个操作是不符合happens-before原则的,这里的并发操作是不安全的,返回值并不一定是1。 对于该问题的修复,可以使用lock或者synchronized套用“管程锁定规则”实现先行发生关系;或者将value定义为volatile变量(两个方法的调用都不存在数据依赖性),套用“volatile变量规则”实现先行发生关系。如此一来,就能保证并发安全性。 */
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15