Java多线程 -- JUC包源码分析3-- volatile/final语义
2017-07-12 10:04
399 查看
volatile应用1 – 内存可见性 – JMM内存模型
-volatile应用2 – 原子性
-volatile应用3 – 构造函数逸出/DCL问题(Double Checking Locking)
-final应用1 – 避免构造函数重排序
-final应用2 – CopyOnWrite
-atomic数组/volatile数组/final数组/
-指令重排序,happen before语义
在讲述抽象的理论之前,先看2个案例:
案例1:
2
3
4
5
6
7
8
9
10
11
12
13
14
1
2
3
4
5
6
7
8
9
10
11
12
13
14
案例2:
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
答案:在上面的例子里面,线程B未必能读到线程A写入的值。案例2有可能死循环。
这要从现代多CPU说起: 在现代的CPU架构中,每个CPU都会有自己的缓存(L1缓存,L2缓存。关于CPU缓存,后续会详细阐述,此处只是提及)。如图所示:
其对应的JVM的抽象内存模型JMM,如下图所示:
线程A,线程B有各自的local内存。在把变量从主内存读到自己的工作内存,修改之后,不一定会立即写入主存,因此另一个线程不可见。
要保证上述案例可以完全正确执行,需要在变量前加volatile。
volatile变量可以保证:每次对该变量的写,必定刷回到主存;每次对该变量的读,必定从主存读取。从而可以保证,一个线程对共享变量的写,对其他线程可见。
案例3:
2
3
4
5
6
7
8
9
10
11
12
13
14
1
2
3
4
5
6
7
8
9
10
11
12
13
14
案例3和案例1相比,只是int换成了long。
由于JMM并不要求对一个64位的long/double型的变量写入具有原子性,在32位的机器上,对一个long型变量的写入,可能会分成高32位,低32位2次写入。此时,另一个线程去读取时,可能读到“写了一半”的无效值!
要解决上述问题,可以加锁,也可以加volatile关键字。
可见,在对单个变量的读写中,volatile变量起到了锁同样的作用。
也正因为如此,在AtomicInteger/AtomicLong中,其get()/set()函数,都未加锁,却是线程安全的!!
线程安全的单例模式中,有一种经典写法,即DCL(Doule Checking Locking),如下所示:
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
上述的new Instance(),底层可以分为3个操作: 分配内存,在内存上初始化成员变量,把instance指向内存。
这3个操作,可能重排序,即先把instance指向内存,再初始化成员变量。
此时,另外一个线程就会拿到一个未完全初始化的对象。这时直接访问里面的成员变量,就可能出错。而这就是典型的“构造函数溢出”问题。
要解决此问题,只要在instance前加volatile就可以了!
当然,还有另外1种经典的线程安全的单例模式 – 基于类加载器的方案
2
3
4
5
6
7
8
9
10
11
12
1
2
3
4
5
6
7
8
9
10
11
12
案例4
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
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
答案是:a, b 未必一定等于1,2。因为这里的i, j都是非volatile变量,线程A的重排序,可能使得i, j的赋值,在构造函数之后执行!!也就是说,线程B拿到obj的时候,obj的i, j变量可能赋值还未完成!
解决办法是:给i, j 加上final
final的语义: 保证final变量的初始化,一定在构造函数返回之前完成!
在上1篇 NumberRange例子中,我们看到lower, power都是final类型,这也确保了lower, power只可能被赋值1次。后续要想再改变值,只能拷贝一份出来改!
所以,通常应用CopyOnWrite的地方,也会相应的使用final!
关于atomic,volatile, final的数组类型,很容易存在着如下误解:
只是说里面的每个元素是原子的,而不是整个数组是原子的!比如说,你一个for循环,set每1个值,这整个for循环,并不是原子的。
2
3
4
5
6
1
2
3
4
5
6
2
3
4
1
2
3
4
从上述各种案例可以看出,问题主要出在“指令重排序”上。
为什么要指令重排序呢?
从程序员角度来讲,最好是不要有任何的指令重排,这样程序最容易理解;但从CPU和编译器角度,希望在不改变单线程程序语义的情况下,尽可能的重排序,最大程度的提高执行效率。
而对于多线程程序,因为重排序导致的线程之间的不同步,则由程序员自己处理!
volatile和final的底层原理,就是一定程度上禁止重排序,从而实现多线程程序的同步。
关于重排序和happen before的深入阐释,且看下回分解。
-volatile应用2 – 原子性
-volatile应用3 – 构造函数逸出/DCL问题(Double Checking Locking)
-final应用1 – 避免构造函数重排序
-final应用2 – CopyOnWrite
-atomic数组/volatile数组/final数组/
-指令重排序,happen before语义
volatile应用1 – 内存可见性
在讲述抽象的理论之前,先看2个案例: 案例1:
public class Example1 { private int a = 0; public void set(int a) //线程A调用set(100) { this.a = a; } public int get() //线程B调用get(),返回值是不是一定是100? { return this.a; } }1
2
3
4
5
6
7
8
9
10
11
12
13
14
1
2
3
4
5
6
7
8
9
10
11
12
13
14
案例2:
public class Example2 { private boolean flag = true; public void stop() //线程A调用stop() { flag = false; } public void run() //线程B调用stop()之后,线程2是否一定会停止? { while(flag) { //do something } } ... }1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
答案:在上面的例子里面,线程B未必能读到线程A写入的值。案例2有可能死循环。
这要从现代多CPU说起: 在现代的CPU架构中,每个CPU都会有自己的缓存(L1缓存,L2缓存。关于CPU缓存,后续会详细阐述,此处只是提及)。如图所示:
其对应的JVM的抽象内存模型JMM,如下图所示:
线程A,线程B有各自的local内存。在把变量从主内存读到自己的工作内存,修改之后,不一定会立即写入主存,因此另一个线程不可见。
要保证上述案例可以完全正确执行,需要在变量前加volatile。
volatile变量可以保证:每次对该变量的写,必定刷回到主存;每次对该变量的读,必定从主存读取。从而可以保证,一个线程对共享变量的写,对其他线程可见。
volatile应用2 – 原子性
案例3:public class Example1 { private long a = 0; public void set(long a) //线程A调用set(100) { this.a = a; } public long get() //线程B调用get(),返回值是不是一定是100? { return this.a; } }1
2
3
4
5
6
7
8
9
10
11
12
13
14
1
2
3
4
5
6
7
8
9
10
11
12
13
14
案例3和案例1相比,只是int换成了long。
由于JMM并不要求对一个64位的long/double型的变量写入具有原子性,在32位的机器上,对一个long型变量的写入,可能会分成高32位,低32位2次写入。此时,另一个线程去读取时,可能读到“写了一半”的无效值!
要解决上述问题,可以加锁,也可以加volatile关键字。
可见,在对单个变量的读写中,volatile变量起到了锁同样的作用。
也正因为如此,在AtomicInteger/AtomicLong中,其get()/set()函数,都未加锁,却是线程安全的!!
volatile应用3 – 避免构造函数逸出/DCL问题
线程安全的单例模式中,有一种经典写法,即DCL(Doule Checking Locking),如下所示:public class Sington { private static Sington instance; public static Sington getInstance() { if(instance == null) //DCL { synchronized(Sington.class) { if(instance == null) instance = new Instance(); //有问题的代码!!! } } return instance; }1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
上述的new Instance(),底层可以分为3个操作: 分配内存,在内存上初始化成员变量,把instance指向内存。
这3个操作,可能重排序,即先把instance指向内存,再初始化成员变量。
此时,另外一个线程就会拿到一个未完全初始化的对象。这时直接访问里面的成员变量,就可能出错。而这就是典型的“构造函数溢出”问题。
要解决此问题,只要在instance前加volatile就可以了!
当然,还有另外1种经典的线程安全的单例模式 – 基于类加载器的方案
public class Instance { private static class InstanceHolder { public static Instance instance = new Instance(); } public static Instance getInstance() { return InstanceHolder.instance; } }1
2
3
4
5
6
7
8
9
10
11
12
1
2
3
4
5
6
7
8
9
10
11
12
final应用1 – 避免构造函数重排序
案例4public class Example4 { private int i; private int j; private static Example4 obj; public Example4() { i=1; j=2; } public static void write() //线程A先执行write() { obj = new Example4() } public static void read() //线程B再执行read() { if(obj!=null) { int a = obj.i; int b = obj.j; //请问,a, b是否一定等于1,2? } } }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
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
答案是:a, b 未必一定等于1,2。因为这里的i, j都是非volatile变量,线程A的重排序,可能使得i, j的赋值,在构造函数之后执行!!也就是说,线程B拿到obj的时候,obj的i, j变量可能赋值还未完成!
解决办法是:给i, j 加上final
final的语义: 保证final变量的初始化,一定在构造函数返回之前完成!
final应用2 – CopyOnWrite
在上1篇 NumberRange例子中,我们看到lower, power都是final类型,这也确保了lower, power只可能被赋值1次。后续要想再改变值,只能拷贝一份出来改!所以,通常应用CopyOnWrite的地方,也会相应的使用final!
atomic数组/volatile数组/final数组
关于atomic,volatile, final的数组类型,很容易存在着如下误解:
AtomicIntegerArray, AtomicLongArray
只是说里面的每个元素是原子的,而不是整个数组是原子的!比如说,你一个for循环,set每1个值,这整个for循环,并不是原子的。
volatile数组
private volatile Object[] a; a = new Object[100]; //a是原子的,对a的修改,立即对其他线程可见。在ConcurrentHashMap里面,rehash的时候,会用到这个特性,后面会详细阐述。 a = new Object[200]; a[0] = new Object(); //但a[x]并不是原子的,对a[x]的修改,并不会对其他线程可见。此问题,在后续ConcurrentHashMap的剖析中,会详细阐述1
2
3
4
5
6
1
2
3
4
5
6
final 数组
private final Object[] a = new Object[100]; //a是final的,只能一次赋值。意味着a数组是固定长度 a[0] = new Object(); //但a[x]并不是final的,可以多次赋值 a[0] = b1
2
3
4
1
2
3
4
指令重排序/happen before
从上述各种案例可以看出,问题主要出在“指令重排序”上。为什么要指令重排序呢?
从程序员角度来讲,最好是不要有任何的指令重排,这样程序最容易理解;但从CPU和编译器角度,希望在不改变单线程程序语义的情况下,尽可能的重排序,最大程度的提高执行效率。
而对于多线程程序,因为重排序导致的线程之间的不同步,则由程序员自己处理!
volatile和final的底层原理,就是一定程度上禁止重排序,从而实现多线程程序的同步。
关于重排序和happen before的深入阐释,且看下回分解。
相关文章推荐
- Java多线程 -- JUC包源码分析1 -- CAS/乐观锁
- Java多线程 -- JUC包源码分析4 -- 各种锁与无锁
- Java多线程 -- JUC包源码分析17 -- 弱一致性与无锁队列
- Java多线程 -- JUC包源码分析4 -- 各种锁与无锁
- Java多线程 -- JUC包源码分析12 -- ThreadPoolExecutor源码分析
- Java多线程 -- JUC包源码分析18 -- ConcurrentSkipListMap(Set)/TreeMap(Set)/无锁链表
- Java多线程 -- JUC包源码分析14 -- ScheduledThreadPoolExecutor与DelayQueue源码分析
- Java多线程 -- JUC包源码分析2 -- Copy On Write/CopyOnWriteArrayList/CopyOnWriteArraySet
- Java多线程 -- JUC包源码分析1 -- CAS/乐观锁
- Java多线程 -- JUC包源码分析10 -- ConcurrentLinkedQueue源码分析
- Java多线程 -- JUC包源码分析6 -- ConcurrentHashMap
- Java多线程 -- JUC包源码分析19 -- ForkJoinPool/ForkJoinTask
- Java多线程 -- JUC包源码分析3-- volatile/final语义
- Java多线程 -- JUC包源码分析9 -- AbstractQueuedSynchronizer深入分析-- Semaphore与CountDownLatch
- Java多线程 -- JUC包源码分析8 -- 对happen before的深刻理解
- Java多线程 -- JUC包源码分析15 -- SynchronousQueue与CachedThreadPool
- Java多线程 -- JUC包源码分析11 -- CyclicBarrier源码分析
- Java多线程 -- JUC包源码分析16 -- Exchanger源码分析
- Java多线程 -- JUC包源码分析13 -- Callable/FutureTask源码分析
- Java多线程 -- JUC包源码分析7 -- 对Interrupt的深刻理解