展開
並發編程之多線程線程安全
為什麼有線程安全問題?
線程安全解決辦法
內置的鎖
同步代碼塊synchronized
非靜態同步方法
靜態同步函數
多線程死鎖
Threadlocal
Threadlocal API
ThreadLoca實現原理
多線程有三大特性
1. 原子性
2. 可見性
3. 有序性
Java內存模型
Volatile
Volatile特性
volatile 性能:
Volatile與Synchronized區別
重排序
數據依賴性
as-if-serial語義
重排序對多線程的影響
為什麼有線程安全問題?
當多個線程同時共享,同一個全局變量或靜態變量,做寫的操作時,可能會發生數據衝突問題,也就是線程安全問題。但是做讀操作是不會發生數據衝突問題。
案例: 需求現在有100張火車票,有兩個窗口同時搶火車票,請使用多線程模擬搶票效果。
- public class ThreadTrain implements Runnable {
private int trainCount = 100;
@Override
public void run() {
while (trainCount > 0) {
try {
Thread.sleep(50);
} catch (Exception e) {
}
sale();
}
}
public void sale() {
if (trainCount > 0) {
System.out.println(Thread.currentThread().getName() + ",出售第" + (100 - trainCount + 1) + "張票");
trainCount--;
}
}
public static void main(String[] args) {
ThreadTrain threadTrain = new ThreadTrain();
Thread t1 = new Thread(threadTrain, "①號");
Thread t2 = new Thread(threadTrain, "②號");
t1.start();
t2.start();
}
}
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
運行結果: 一號窗口和二號窗口同時出售火車第九九張,部分火車票會重複出售。
結論發現,多個線程共享同一個全局成員變量時,做寫的操作可能會發生數據衝突問題。
線程安全解決辦法
內置的鎖
Java提供了一種內置的鎖機制來支持原子性
每一個Java對象都可以用作一個實現同步的鎖,稱為內置鎖,線程進入同步代碼塊之前自動獲取到鎖,代碼塊執行完成正常退出或代碼塊中拋出異常退出時會釋放掉鎖
內置鎖為互斥鎖,即線程A獲取到鎖後,線程B阻塞直到線程A釋放鎖,線程B才能獲取到同一個鎖
內置鎖使用synchronized關鍵字實現,synchronized關鍵字有兩種用法:
1.修飾需要進行同步的方法(所有訪問狀態變量的方法都必須進行同步),此時充當鎖的對象為調用同步方法的對象
2.同步代碼塊和直接使用synchronized修飾需要同步的方法是一樣的,但是鎖的粒度可以更細,並且充當鎖的對象不一定是this,也可以是其它對象,所以使用起來更加靈活.
同步代碼塊synchronized
同步的前提:
1,必須要有兩個或者兩個以上的線程
2,必須是多個線程使用同一個鎖
必須保證同步中只能有一個線程在運行
好處:解決了多線程的安全問題
弊端:多個線程需要判斷鎖,較為消耗資源、搶鎖的資源。
public void sale() {
synchronized (this) {
if (trainCount > 0) {
System.out.println(Thread.currentThread().getName() + ",出售第" + (100 - trainCount + 1) + "張票");
trainCount--;
}
}
}
1
2
3
4
5
6
7
8
非靜態同步方法
在方法上修飾synchronized 稱為同步方法
public synchronized void sale() {
if (trainCount > 0) {
System.out.println(Thread.currentThread().getName() + ",出售第" + (100 - trainCount + 1) + "張票");
trainCount--;
}
}
1
2
3
4
5
6
非靜態同步方法 = 同步代碼塊使用this鎖 它們都是以當前對象作為鎖
public synchronized void sale() {} = synchronized (this){}
this鎖只用在非靜態方法裡面,靜態方法是跟隨類的,不跟隨某個對象this。
證明方式: 一個線程使用同步代碼塊(this明鎖),另一個線程使用同步函數。如果兩個線程搶票不能實現同步,那麼會出現數據錯誤。
class Thread009 implements Runnable {
private int trainCount = 100;
private Object oj = new Object();
public boolean flag = true;
public void run() {
if (flag) {
while (trainCount > 0) {
synchronized (this) {
try {
Thread.sleep(10);
} catch (Exception e) {
}
if (trainCount > 0) {
System.out.println(Thread.currentThread().getName() + "," + "出售第" + (100 - trainCount + 1) + "票");
trainCount--;
}
}
}
} else {
while (trainCount > 0) {
sale();
}
}
}
public synchronized void sale() {
try {
Thread.sleep(10);
} catch (Exception e) {
// TODO: handle exception
}
if (trainCount > 0) {
System.out.println(Thread.currentThread().getName() + "," + "出售第" + (100 - trainCount + 1) + "票");
trainCount--;
}
}
}
public class Test009 {
public static void main(String[] args) throws InterruptedException {
Thread009 threadTrain = new Thread009();
Thread t1 = new Thread(threadTrain, "窗口1");
Thread t2 = new Thread(threadTrain, "窗口2");
t1.start();
Thread.sleep(40);
threadTrain.flag = false;
t2.start();
}
}
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
靜態同步函數
方法上加上static關鍵字,使用synchronized 關鍵字修飾 或者使用類.class文件。
synchronized 修飾靜態方法使用鎖是當前類的字節碼文件(類.class)
**public static synchronized void sale() {}** = **synchronized (類.class){}**
public static void sale() {
synchronized (ThreadTrain3.class) {
if (trainCount > 0) {
System.out.println(Thread.currentThread().getName() + ",出售第" + (100 - trainCount + 1) + "張票");
trainCount--;
}
}
}
1
2
3
4
5
6
7
8
public synchronized static void sale() {
if (trainCount > 0) {
System.out.println(Thread.currentThread().getName() + ",出售第" + (100 - trainCount + 1) + "張票");
trainCount--;
}
}
1
2
3
4
5
6
推薦使用同步代碼塊,不推薦使用同步方法。
同步代碼塊只把可能出現線程安全的代碼包裹起來,
而同步方法不靈活,會把不存在線程安全的代碼也包裹起來。
多線程死鎖
同步中嵌套同步,導致鎖無法釋放
鎖裡面嵌套鎖
A鎖 嵌套 B鎖
線程1 —>拿到A鎖 的同時
線程2 —>拿到B鎖
當線程1再想拿B鎖的時候,發現B鎖被線程2拿了,線程1等待
當線程2再想拿A鎖的時候,發現A鎖被線程1拿了,線程2等待
兩個線程互相等待,產生死鎖。
class Thread009 implements Runnable {
private int trainCount = 100;
private Object oj = new Object();
public boolean flag = true;
public void run() {
if (flag) {
while (trainCount > 0) {
synchronized (oj) {
try {
Thread.sleep(10);
} catch (Exception e) {
}
sale();
}
}
} else {
while (trainCount > 0) {
sale();
}
}
}
public synchronized void sale() {
synchronized (oj) {
try {
Thread.sleep(10);
} catch (Exception e) {
}
if (trainCount > 0) {
System.out.println(Thread.currentThread().getName() + "," + "出售第" + (100 - trainCount + 1) + "票");
trainCount--;
}
}
}
}
public class Test009 {
public static void main(String[] args) throws InterruptedException {
Thread009 threadTrain = new Thread009();
Thread t1 = new Thread(threadTrain, "窗口1");
Thread t2 = new Thread(threadTrain, "窗口2");
t1.start();
Thread.sleep(40);
threadTrain.flag = false;
t2.start();
}
}}
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
Threadlocal
ThreadLocal提高一個線程的局部變量,訪問某個線程擁有自己局部變量。
當使用ThreadLocal維護變量時,ThreadLocal為每個使用該變量的線程提供獨立的變量副本,所以每一個線程都可以獨立地改變自己的副本,而不會影響其它線程所對應的副本。
static ThreadLocal<Integer> threadLocal = new ThreadLocal<Integer>(){}
1
每個線程使用threadLocal拿到當前線程的值。
Threadlocal API
void set(Object value) 設置當前線程的線程局部變量的值。
public Object get() 該方法返回當前線程所對應的線程局部變量。
public void remove() 將當前線程局部變量的值刪除,目的是為了減少內存的占用,該方法是JDK 5.0新增的方法。需要指出的是,當線程結束後,對應該線程的局部變量將自動被垃圾回收,所以顯式調用該方法清除線程的局部變量並不是必須的操作,但它可以加快內存回收的速度。
protected Object initialValue() 返回該線程局部變量的初始值,該方法是一個protected的方法,顯然是為了讓子類覆蓋而設計的。這個方法是一個延遲調用方法,在線程第1次調用get()或set(Object)時才執行,並且僅執行1次。ThreadLocal中的預設實現直接返回一個null。
案例:創建三個線程,每個線程生成自己獨立序列號。
public class Res {
// 生成序列號共享變量
public static Integer count = 0;
public static ThreadLocal<Integer> threadLocal = new ThreadLocal<Integer>() {
protected Integer initialValue() {
return 0;
};
};
public Integer getNum() {
int count = threadLocal.get() + 1;
threadLocal.set(count);
return count;
}
}
public class ThreadLocaDemo2 extends Thread {
private Res res;
public ThreadLocaDemo2(Res res) {
this.res = res;
}
@Override
public void run() {
for (int i = 0; i < 3; i++) {
System.out.println(Thread.currentThread().getName() + "---" + "i---" + i + "--num:" + res.getNum());
}
}
public static void main(String[] args) {
Res res = new Res();
ThreadLocaDemo2 threadLocaDemo1 = new ThreadLocaDemo2(res);
ThreadLocaDemo2 threadLocaDemo2 = new ThreadLocaDemo2(res);
ThreadLocaDemo2 threadLocaDemo3 = new ThreadLocaDemo2(res);
threadLocaDemo1.start();
threadLocaDemo2.start();
threadLocaDemo3.start();
}
}
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
ThreadLoca實現原理
ThreadLoca通過map集合
Map.put(「當前線程」,值);
ThreadLocal 內存泄漏的問題
多線程有三大特性
1. 原子性
即一個操作或者多個操作 要麼全部執行並且執行的過程不會被任何因素打斷,要麼就都不執行。
一個很經典的例子就是銀行帳戶轉帳問題:
比如從帳戶A向帳戶B轉1000元,那麼必然包括2個操作:從帳戶A減去1000元,往帳戶B加上1000元。這2個操作必須要具備原子性才能保證不出現一些意外的問題。
我們操作數據也是如此,比如i = i+1;其中就包括,讀取i的值,計算i,寫入i。這行代碼在Java中是不具備原子性的,則多線程運行肯定會出問題,所以也需要我們使用同步和lock這些東西來確保這個特性了。
原子性其實就是保證數據一致、線程安全一部分
2. 可見性
當多個線程訪問同一個變量時,一個線程修改了這個變量的值,其他線程能夠立即看得到修改的值。
若兩個線程在不同的cpu,那麼線程1改變了i的值還沒刷新到主存,線程2又使用了i,那麼這個i值肯定還是之前的,線程1對變量的修改線程沒看到這就是可見性問題。
3. 有序性
程序執行的順序按照代碼的先後順序執行。
一般來說處理器為了提高程序運行效率,可能會對輸入代碼進行優化,它不保證程序中各個語句的執行先後順序同代碼中的順序一致,但是它會保證程序最終執行結果和代碼順序執行的結果是一致的。如下:
int a = 10; //語句1
int r = 2; //語句2
a = a + 3; //語句3
r = a*a; //語句4
則因為 重排序,他還可能執行順序為 2-1-3-4,1-3-2-4
但絕不可能 2-1-4-3,因為這打破了依賴關係。
顯然重排序對單線程運行是不會有任何問題,而多線程就不一定了,所以我們在多線程編程時就得考慮這個問題了。
Java內存模型
共享內存模型指的就是Java內存模型(簡稱JMM),JMM決定一個線程對共享變量的寫入時,能對另一個線程可見。從抽象的角度來看,JMM定義了線程和主內存之間的抽象關係:線程之間的共享變量存儲在主內存(main memory) 中,每個線程都有一個私有的本地內存(local memory),本地內存中存儲了該線程以讀/寫共享變量的副本。本地內存是JMM的一個抽象概念,並不真實存在。它涵蓋了緩存,寫緩衝區,寄存器以及其他的硬體和編譯器優化。
從上圖來看,線程A與線程B之間如要通信的話,必須要經歷下面2個步驟:
首先,線程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程式設計師提供內存可見性保證。
總結:什麼是Java內存模型:java內存模型簡稱jmm,定義了一個線程對另一個線程可見。共享變量存放在主內存中,每個線程都有自己的本地內存,當多個線程同時訪問一個數據的時候,可能本地內存沒有及時刷新到主內存,所以就會發生線程安全問題。
JMM(Java內存模型比喻)
主內存 相當於 Git Server
線程本地內存 相當於 Git Client
當開啟線程時候,線程使用主內存中的共享變量的時候,線程會pull主線程變量到線程本地內存,如圖,會有一個共享變量的副本。
1.當主線程修改變量,線程內存沒有重新pull的時候,各個線程的副本並不會改變。
2.當各個線程修改變量,沒有push去主內存並通知其它線程,不會影響主內存和其它線程變量值。
給共享變量加上 volatile 關鍵字,當變量改變的時候,會立即push去主線程,保證共享變量的可見性。
Volatile
一旦某個線程修改了該被volatile修飾的變量,它會保證修改的值會立即被更新到主存,當有其他線程需要讀取時,可以立即獲取修改之後的值。
在Java中為了加快程序的運行效率,對一些變量的操作通常是在該線程的寄存器或是CPU緩存上進行的,之後才會同步到主存中,而加了volatile修飾符的變量則是直接讀寫主存。
Volatile 保證了線程間共享變量的及時可見性,但不能保證原子性
public class ThreadVolatileDemo extends Thread {
public boolean flag = true;
@Override
public void run() {
System.out.println("開始執行子線程....");
while (flag) {
}
System.out.println("線程停止");
}
public void setRuning(boolean flag) {
this.flag = flag;
}
}
public class ThreadVolatile {
public static void main(String[] args) throws InterruptedException {
ThreadVolatileDemo threadVolatileDemo = new ThreadVolatileDemo();
threadVolatileDemo.start();
Thread.sleep(3000);
threadVolatileDemo.setRuning(false);
System.out.println("flag 已經設置成false");
Thread.sleep(1000);
System.out.println(threadVolatileDemo.flag);
}
}
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
已經將結果設置為fasle為什麼?還一直在運行呢。
原因: 線程之間是不可見的,讀取的是副本,沒有及時讀取到主內存結果。
(主線程把flag修改成false, 但是子線程本地內存中依舊拿到的是舊的flag值的副本, 線程之間是不可見的)
解決辦法使用Volatile關鍵字將解決線程之間可見性, 強制線程每次讀取該值的時候都去「主內存」中取值
Volatile特性
保證此變量對所有的線程的可見性,這裡的「可見性」,如本文開頭所述,當一個線程修改了這個變量的值,volatile 保證了新值能立即同步到主內存,以及每次使用前立即從主內存刷新。但普通變量做不到這點,普通變量的值在線程間傳遞均需要通過主內存(詳見:Java內存模型)來完成。
禁止指令重排序優化。 有volatile修飾的變量,賦值後多執行了一個「load addl $0x0, (%esp)」操作,這個操作相當於一個內存屏障(指令重排序時不能把後面的指令重排序到內存屏障之前的位置),只有一個CPU訪問內存時,並不需要內存屏障;(什麼是指令重排序:是指CPU採用了允許將多條指令不按程序規定的順序分開發送給各相應電路單元處理)。
volatile 性能:
volatile 的讀性能消耗與普通變量幾乎相同,但是寫操作稍慢,因為它需要在本地代碼中插入許多內存屏障指令來保證處理器不發生亂序執行。
Volatile與Synchronized區別
從而我們可以看出volatile雖然具有可見性但是並不能保證原子性。
Synchronized 可以把一段代碼或者方法鎖起來,讓該段代碼要麼不執行,要麼一起都執行,
保證了多線程的原子性。
Volatile 解決了共享變量在不同線程之間修改後的可見性,立馬把修改後的值push到主內存,它是阻塞式的,只能讓一個線程改變變量值,但是它無法保證某段代碼的原子性。
性能方面,synchronized關鍵字是防止多個線程同時執行一段代碼,就會影響程序執行效率,而volatile關鍵字在某些情況下性能要優於synchronized。
注意: volatile關鍵字是無法替代synchronized關鍵字的,因為volatile關鍵字無法保證操作的原子性。
重排序
數據依賴性
如果兩個操作訪問同一個變量,且這兩個操作中有一個為寫操作,此時這兩個操作之間就存在數據依賴性。數據依賴分下列三種類型:
名稱 代碼示例 說明
寫後讀 a = 1;b = a; 寫一個變量之後,再讀這個位置。
寫後寫 a = 1;a = 2; 寫一個變量之後,再寫這個變量。
讀後寫 a = b;b = 1; 讀一個變量之後,再寫這個變量。
上面三種情況,只要重排序兩個操作的執行順序,程序的執行結果將會被改變。
CPU可能改變兩個沒有依賴關係的變量的執行順序(可能對沒有關係的變量重新排序,改變執行順序,再也不是從上到下依次執行)
Int a = 1;
Int b = 2;
CPU在執行的時候,發現a和b並沒有依賴關係,所以 它會自動根據當前最優的執行順序進行執行,有可能先執行int b = 2;
Int c = a*b
CPU執行的時候,發現c依賴與a,b,所以CPU不會改變int c 和 a,b之間的執行順序。
前面提到過,編譯器和處理器可能會對操作做重排序。編譯器和處理器在重排序時,會遵守數據依賴性,編譯器和處理器不會改變存在數據依賴關係的兩個操作的執行順序。
注意,這裡所說的數據依賴性僅針對單個處理器中執行的指令序列和單個線程中執行的操作,不同處理器之間和不同線程之間的數據依賴性不被編譯器和處理器考慮。
Volatile 可以阻止CUP對代碼的重排序。
as-if-serial語義
s-if-serial語義的意思指: 不管怎麼重排序(編譯器和處理器為了提高並行度),(單線程)程序的執行結果不能被改變。編譯器,runtime 和處理器都必須遵守as-if-serial語義。
為了遵守as-if-serial語義,編譯器和處理器不會對存在數據依賴關係的操作做重排序,因為這種重排序會改變執行結果。但是,如果操作之間不存在數據依賴關係,這些操作可能被編譯器和處理器重排序。為了具體說明,請看下面計算圓面積的代碼示例:
double pi = 3.14; //A
double r = 1.0; //B
double area = pi * r * r; //C
上面三個操作的數據依賴關係如下圖所示:
如上圖所示,A和C之間存在數據依賴關係,同時B和C之間也存在數據依賴關係。因此在最終執行的指令序列中,C不能被重排序到A和B的前面(C排到A和B的前面,程序的結果將會被改變)。但A和B之間沒有數據依賴關係,編譯器和處理器可以重排序A和B之間的執行順序。下圖是該程序的兩種執行順序:
as-if-serial語義把單線程程序保護了起來,遵守as-if-serial語義的編譯器,runtime 和處理器共同為編寫單線程程序的程式設計師創建了一個幻覺:單線程程序是按程序的順序來執行的。as-if-serial語義使單線程程式設計師無需擔心重排序會干擾他們,也無需擔心內存可見性問題。
程序順序規則
根據happens- before的程序順序規則,上面計算圓的面積的示例代碼存在三個happens- before關係:
A happens- before B;
B happens- before C;
A happens- before C;
這裡的第3個happens- before關係,是根據happens- before的傳遞性推導出來的。
這裡A happens- before B,但實際執行時B卻可以排在A之前執行(看上面的重排序後的執行順序)。在第一章提到過,如果A happens- before B,JMM並不要求A一定要在B之前執行。JMM僅僅要求前一個操作(執行的結果)對後一個操作可見,且前一個操作按順序排在第二個操作之前。這裡操作A的執行結果不需要對操作B可見;而且重排序操作A和操作B後的執行結果,與操作A和操作B按happens- before順序執行的結果一致。在這種情況下,JMM會認為這種重排序並不非法(not illegal),JMM允許這種重排序。
在計算機中,軟體技術和硬體技術有一個共同的目標:在不改變程序執行結果的前提下,儘可能的開發並行度。編譯器和處理器遵從這一目標,從happens- before的定義我們可以看出,JMM同樣遵從這一目標。
重排序對多線程的影響
public class ReorderExample {
int a = 0;
boolean flag = false;
public void writer() {
a = 1; //1
flag = true; //2
}
public void reader() {
if (flag) { //3
int i = a * a; //4
……
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
flag變量是個標記,用來標識變量a是否已被寫入。這裡假設有兩個線程A和B,A首先執行writer()方法,隨後B線程接著執行reader()方法。線程B在執行操作4時,能否看到線程A在操作1對共享變量a的寫入?
答案是:不一定能看到。
由於操作1和操作2沒有數據依賴關係,編譯器和處理器可以對這兩個操作重排序;同樣,操作3和操作4沒有數據依賴關係,編譯器和處理器也可以對這兩個操作重排序。讓我們先來看看,當操作1和操作2重排序時,可能會產生什麼效果?請看下面的程序執行時序圖:
如上圖所示,操作1和操作2做了重排序。程序執行時,線程A首先寫標記變量flag,隨後線程B讀這個變量。由於條件判斷為真,線程B將讀取變量a。此時,變量a還根本沒有被線程A寫入,在這裡多線程程序的語義被重排序破壞了!
※註:本文統一用紅色的虛箭線表示錯誤的讀操作,用綠色的虛箭線表示正確的讀操作。
下面再讓我們看看,當操作3和操作4重排序時會產生什麼效果(藉助這個重排序,可以順便說明控制依賴性)。下面是操作3和操作4重排序後,程序的執行時序圖:
在程序中,操作3和操作4存在控制依賴關係。當代碼中存在控制依賴性時,會影響指令序列執行的並行度。為此,編譯器和處理器會 採用猜測(Speculation)執行來克服控制相關性對並行度的影響。以處理器的猜測執行為例, 執行線程B的處理器可以提前讀取並計算a*a,然後把計算結果臨時保存到一個名為重排序緩衝(reorder buffer ROB)的硬體緩存中。 當接下來操作3的條件判斷為真時,就把該計算結果寫入變量i中。
從圖中我們可以看出,猜測執行實質上對操作3和4做了重排序。重排序在這裡破壞了多線程程序的語義!
在單線程程序中,對存在控制依賴的操作重排序,不會改變執行結果(這也是as-if-serial語義允許對存在控制依賴的操作做重排序的原因);但在多線程程序中,對存在控制依賴的操作重排序,可能會改變程序的執行結果。