Java 多執行緒:並發編程的三大特性
Java 多執行緒:並發編程的三大特性
作者:Grey
原文地址:
可見性
所謂執行緒數據的可見性,指的就是記憶體中的某個數據,假如第一個 CPU 的一個核讀取到了,和其他的核讀取到這個數據之間的可見性。
每個執行緒會保存一份拷貝到執行緒本地快取,使用volatile
,可以保持執行緒之間數據可見性。
如下示例
package git.snippets.juc;
import java.util.concurrent.TimeUnit;
/**
* 並發編程三大特性之:可見性
*
* @author <a href="mailto:[email protected]">Grey</a>
* @since 1.8
*/
public class ThreadVisible {
static volatile boolean flag = true;
public static void main(String[] args) throws Exception {
Thread t = new Thread(() -> {
System.out.println(Thread.currentThread() + " t start");
while (flag) {
// 如果這裡調用了System.out.println()
// 會無論flag有沒有加volatile,數據都會同步
// 因為System.out.println()背後調用的synchronized
// System.out.println();
}
System.out.println(Thread.currentThread() + " t end");
});
t.start();
TimeUnit.SECONDS.sleep(3);
flag = false;
// volatile修飾引用變數
new Thread(a::m, "t2").start();
TimeUnit.SECONDS.sleep(2);
a.flag = false;
// 阻塞主執行緒,防止主執行緒直接執行完畢,看不到效果
System.in.read();
}
private static volatile A a = new A();
static class A {
volatile boolean flag = true;
void m() {
System.out.println("m start");
while (flag) {
}
System.out.println("m end");
}
}
}
程式碼說明:
-
volatile
修飾了flag
變數,主執行緒改了flag
的值,子執行緒可以感知到; -
如在上述程式碼的死循環中增加了
System.out.println()
, 則會強制同步flag
的值,無論flag
本身有沒有加volatile
; -
如果
volatile
修飾一個引用對象,如果對象的屬性(成員變數)發生了改變,volatile
不能保證其他執行緒可以觀察到該變化。
關於三級快取
如上圖,記憶體讀出的數據會在 L3,L2,L1 上都存一份。
在從記憶體中讀取數據的時候,根據的是程式局部性的原理,按塊來讀取,這樣可以提高效率,充分發揮匯流排 CPU 針腳等一次性讀取更多數據的能力。
所以這裡引入了一個快取行的概念,目前一個快取行多用64個位元組來表示。
如何來驗證 CPU 讀取快取行這件事,我們可以通過一個示例來說明:
package git.snippets.juc;
/**
* 快取行對齊
*
* @author <a href="mailto:[email protected]">Grey</a>
* @since 1.8
*/
public class CacheLinePadding {
public static T[] arr = new T[2];
static {
arr[0] = new T();
arr[1] = new T();
}
public static void main(String[] args) throws Exception {
Thread t1 = new Thread(() -> {
for (long i = 0; i < 1000_0000L; i++) {
arr[0].x = i;
}
});
Thread t2 = new Thread(() -> {
for (long i = 0; i < 1000_0000L; i++) {
arr[1].x = i;
}
});
final long start = System.nanoTime();
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println((System.nanoTime() - start) / 100_0000);
System.out.println("arr[0]=" + arr[0].x + " arr[1]=" + arr[1].x);
}
private static class Padding {
public volatile long p1, p2, p3, p4, p5, p6, p7;
}
// T這個類extends Padding與否,會影響整個流程的執行時間,如果繼承了,會減少執行時間,
// 因為繼承Padding後,arr[0]和arr[1]一定不在同一個快取行裡面,所以不需要同步數據,速度就更快一些了。
private static class T /*extends Padding*/ {
public volatile long x = 0L;
}
}
程式碼說明
以上程式碼,T
這個類繼承Padding
類與否,會影響整個流程的執行時間,如果繼承了,會減少執行時間,因為繼承Padding
後,arr[0]
和arr[1]
一定不在同一個快取行裡面,所以不需要同步數據,速度就更快一些了。
Java SE 1.8 增加了一個註解 @Contended
,標註後就不會在同一快取行, 但是這個註解僅適用於 Java SE 1.8,而且還需要增加 JVM 參數-XX:-RestrictContended
CPU 為每個快取行標記四種狀態(使用兩位)
M: 被修改(Modified)
該快取行只被快取在該 CPU 的快取中,並且是被修改過的(dirty
),即與主存中的數據不一致,該快取行中的記憶體需要在未來的某個時間點(允許其它 CPU 讀取請主存中相應記憶體之前)寫回(write back
)主存。
當被寫回主存之後,該快取行的狀態會變成獨享(exclusive
)狀態。
E: 獨享的(Exclusive)
該快取行只被快取在該 CPU 的快取中,它是未被修改過的(clean
),與主存中數據一致。該狀態可以在任何時刻當有其它 CPU 讀取該記憶體時變成共享狀態(shared
)。
同樣地,當 CPU 修改該快取行中內容時,該狀態可以變成Modified
狀態。
S: 共享的(Shared)
該狀態意味著該快取行可能被多個 CPU 快取,並且各個快取中的數據與主存數據一致(clean
),當有一個 CPU 修改該快取行中,其它 CPU 中該快取行可以被作廢(變成無效狀態(Invalid
))。
I: 無效的(Invalid)
該快取是無效的(可能有其它 CPU 修改了該快取行)。
有序性
電腦在執行程式時,為了提高性能,編譯器和處理器常常會對指令做重排。
為什麼指令重排序可以提高性能?
簡單地說,每一個指令都會包含多個步驟,每個步驟可能使用不同的硬體。因此,流水線技術產生了,它的原理是:指令1還沒有執行完,就可以開始執行指令2,而不用等到指令1執行結束之後再執行指令2,這樣就大大提高了效率。
但是,流水線技術最害怕中斷,恢復中斷的代價是比較大的,所以我們要想盡辦法不讓流水線中斷。指令重排就是減少中斷的一種技術。
我們分析一下下面這個程式碼的執行情況:
a = b + c;
d = e - f ;
先載入b、c(注意,既有可能先載入b,也有可能先載入c),但是在執行b + c
的時候,需要等待 b、c 裝載結束才能繼續執行,也就是增加了停頓,那麼後面的指令也會依次有停頓,這降低了電腦的執行效率。
為了減少這個停頓,我們可以先載入 e 和 f ,然後再去載入b + c
,這樣做對程式(串列)結果是沒有影響的,但卻減少了停頓:既然b + c
需要停頓,那還不如去做一些有意義的事情。
綜上所述,指令重排對於提高 CPU 處理性能十分必要。雖然由此帶來了亂序的問題,但是這點犧牲是值得的。
指令重排一般分為以下三種:
第一種:編譯器優化重排
編譯器在不改變單執行緒程式語義的前提下,可以重新安排語句的執行順序。
第二種:指令並行重排
現代處理器採用了指令級並行技術來將多條指令重疊執行。如果不存在數據依賴性(即後一個執行的語句無需依賴前面執行的語句的結果),處理器可以改變語句對應的機器指令的執行順序。
第三種:記憶體系統重排
由於處理器使用快取和讀寫快取沖區,這使得載入( load )和存儲( store )操作看上去可能是在亂序執行,因為三級快取的存在,導致記憶體與快取的數據同步存在時間差。
指令重排可以保證串列語義一致,但是沒有義務保證多執行緒間的語義也一致。所以在多執行緒下,指令重排序可能會導致一些問題。
亂序存在的條件是:不影響單執行緒的最終一致性( as – if – serial )
驗證亂序執行的程式示例
package git.snippets.juc;
/**
* 並發編程的三大特性之:有序性
*
* @author <a href="mailto:[email protected]">Grey</a>
* @since 1.8
*/
public class DisOrder {
private static int x = 0, y = 0;
private static int a = 0, b = 0;
// 以下程式可能會執行比較長的時間
public static void main(String[] args) throws InterruptedException {
int i = 0;
for (; ; ) {
i++;
x = 0;
y = 0;
a = 0;
b = 0;
Thread one = new Thread(() -> {
// 由於執行緒one先啟動,下面這句話讓它等一等執行緒two. 讀著可根據自己電腦的實際性能適當調整等待時間.
shortWait(100000);
a = 1;
x = b;
});
Thread other = new Thread(() -> {
b = 1;
y = a;
});
one.start();
other.start();
one.join();
other.join();
String result = "第" + i + "次 (" + x + "," + y + ")";
if (x == 0 && y == 0) {
// 出現這個分支,說明指令出現了重排
// 否則不可能 x和y同時都為0
System.err.println(result);
break;
} else {
// System.out.println(result);
}
}
}
public static void shortWait(long interval) {
long start = System.nanoTime();
long end;
do {
end = System.nanoTime();
} while (start + interval >= end);
}
}
程式碼說明:
如上示例,如果指令不出現亂序,那麼 x 和 y 不可能同時為 0,通過執行這個程式可以驗證出來,在我本機測試的結果是:
執行到第 385634 次 出現了 x 和 y 同時為 0 的情況,說明出現了亂序。
原子性
程式的原子性是指整個程式中的所有操作,要麼全部完成,要麼全部失敗,不可能滯留在中間某個環節;在多個執行緒一起執行的時候,一個操作一旦開始,就不會被其他執行緒所打斷。
一個示例:
class T {
m =9;
}
對象 T 在創建過程中,背後其實是包含了多條執行語句的,由於有 CPU 亂序執行的情況,所以極有可能會在初始化過程中生成以一個半初始化對象 t,這個 t 的 m 等於 0(還沒有來得及做賦值操作)
所以,不要在某個類的構造方法中啟動一個執行緒,這樣會導致 this 對象逸出:因為這個類的對象可能還來不及執行初始化操作,就啟動了一個執行緒,導致了異常情況。
volatile
一方面可以保證執行緒數據之間的可見性,另外一方面,也可以防止類似這樣的指令重排,所以,單例模式中,DCL
方式的單例一定要加volatile
修飾:
public class Singleton6 {
private volatile static Singleton6 INSTANCE;
private Singleton6() {
}
public static Singleton6 getInstance() {
if (INSTANCE == null) {
synchronized (Singleton6.class) {
if (INSTANCE == null) {
try {
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
INSTANCE = new Singleton6();
}
}
}
return INSTANCE;
}
}
具體可以參考設計模式學習筆記 中單例模式的說明。
說明
本文涉及到的所有程式碼和圖例
更多內容見:Java 多執行緒