教妹學 Java:難以駕馭的多執行緒
00、故事的起源
“二哥,上一篇《集合》的反響效果怎麼樣啊?”三妹對她提議的《教妹學 Java》專欄很關心。
“這篇文章的瀏覽量要比第一篇《泛型》好得多。”
“這是個好訊息啊,說明更多人接受了二哥的創作。”三妹心花怒放了起來。
“也許沒什麼對比性。”
“沒有對比性?我翻看了一下二哥 7 個月前寫的文章,是真的水啊,嘻嘻。”三妹賣了一個萌,繼續說道,“說實話,竟然還有讀者願意看,真的是不可思議。”
“你是想捱揍嗎?”
“別啊。我是說,二哥現在的讀者真的很幸運,因為他們看到了更高質量的文章。”三妹繼續肆無忌憚地說著她的真心話。
“是啊,比以前好多了,但我還要更加地努力,這次的主題是《多執行緒》,三妹你準備好了嗎?”
“早準備好了。讓我繼續來提問吧,二哥你繼續回答。”三妹已經躍躍欲試了。
01、二哥,什麼是執行緒啊?
三妹,聽哥給你慢慢講啊。
要想了解執行緒,得先了解程序,因為執行緒是程序的一個單元。你看,我這臺電腦同時開了很多個程序,比如說打字用的這個輸入法、寫作用的這個瀏覽器,聽歌用的這個音樂播放器。
這些程序同時可能幹幾件事,比如說這個音樂播放器,一邊滾動著歌詞,一邊播放著音訊。也就是說,在一個程序內部,可能同時執行著多個執行緒(Thread),每個執行緒負責著不同的任務。
由於每個程序至少要幹一件事,所以,一個程序至少有一個執行緒。在 Java 的程式當中,至少會有一個 main 方法,也就是所謂的主執行緒。
可以同時執行多個執行緒,執行方式和多個程序是一樣的,都是由作業系統決定的。作業系統可以在多個執行緒之間進行快速地切換,讓每個執行緒交替地執行。切換的時間越短,程式的效率就越高。
程序和執行緒之間的關係可以用一句通俗的話講,就是“程序是爹媽,管著眾多的執行緒兒女。”
02、二哥,為什麼要用多執行緒啊?
三妹,先去給哥泡杯咖啡,再來聽哥給你慢慢地講。
多執行緒作為一種多工、併發的工作方式,好處多多。
第一,減少應用程式的響應時間。
對於計算機來說,IO 讀寫和網路通訊相對是比較耗時的任務,如果不使用多執行緒的話,其他耗時少的任務也必須要等待這些任務結束後才能執行。
第二,充分利用多核 CPU 的優勢。
作業系統可以保證當執行緒數不大於 CPU 數目時,不同的執行緒運行於不同的 CPU 上。不過,即便執行緒數超過了 CPU 數目,作業系統和執行緒池也會盡最大可能地減少執行緒切換花費的時間,最大可能地發揮併發的優勢,提升程式的效能。
第三,相比於多程序,多執行緒是一種更“高效”的多工執行方式。
對於不同的程序來說,它們具有獨立的資料空間,資料之間的共享必須通過“通訊”的方式進行。而執行緒則不需要,同一程序下的執行緒之間共享資料空間。
當然了,如果兩個執行緒存取相同的物件,並且每個執行緒都呼叫了一個修改該物件狀態的方法,將會帶來新的問題。
什麼問題呢?我們來通過下面的示例進行說明。
public class Cmower {
public static int count = 0;
public static int getCount() {
return count;
}
public static void addCount() {
count++;
}
public static void main(String[] args) {
ExecutorService executorService = new ThreadPoolExecutor(10, 1000, 60L, TimeUnit.SECONDS,
new ArrayBlockingQueue<Runnable>(10));
for (int i = 0; i < 1000; i++) {
Runnable r = new Runnable() {
@Override
public void run() {
Cmower.addCount();
}
};
executorService.execute(r);
}
executorService.shutdown();
System.out.println(Cmower.count);
}
}
我們建立了一個執行緒池,通過 for 迴圈讓執行緒池執行 1000 個執行緒,每個執行緒呼叫了一次 Cmower.addCount()
方法,對 count 值進行加 1 操作,當 1000 個執行緒執行完畢後,在控制檯列印 count 的值。
其結果會是什麼呢?
998、997、998、996、996
但幾乎不會是我們想要的答案 1000。
03、二哥,為什麼答案不是 1000 呢?
三妹啊,咖啡泡得太濃了。不過,濃一點的好處是更提神了。
程式在執行過程中,會將運算需要的資料從實體記憶體中複製一份到 CPU 的快取記憶體當中,計算結束之後,再將快取記憶體中的資料重新整理到實體記憶體當中。
拿 count++
來說。當執行緒執行這個語句時,會先從實體記憶體中讀取 count 的值,然後複製一份到快取記憶體當中,CPU 執行指令對 count 進行加 1 操作,再將快取記憶體中 count 的最新值重新整理到實體記憶體當中。
在多核 CPU 中,每個執行緒可能運行於不同的 CPU 中,因此每個執行緒在執行時會有專屬的快取記憶體。假設執行緒 A 正在對 count 進行加 1 操作,此時執行緒 B 的快取記憶體中 count 的值仍然是 0 ,進行加 1 操作後 count 的值為 1。最後兩個執行緒把最新值 1 重新整理到實體記憶體中,而不是理想中的 2。
這種被多個執行緒訪問的變數被稱為共享變數,他們通常需要被保護起來。
04、二哥,那該怎麼保護共享變數呢?
三妹啊,等我喝口咖啡提提神。
針對上例中出現的 count,可以按照下面的方式進行改造。
public static AtomicInteger count = new AtomicInteger();
public static int getCount() {
return count.get();
}
public static void addCount() {
count.incrementAndGet();
}
使用支援原子操作(即一個操作或者多個操作要麼全部執行,並且執行的過程不會被任何因素打斷,要麼就都不執行)的 AtomicInteger
代替基本型別 int。
簡單分析一下 AtomicInteger
類,該類原始碼中可以看到一個有趣的變數 unsafe
。
private static final Unsafe unsafe = Unsafe.getUnsafe();
Unsafe
是一個可以執行不安全、容易犯錯操作的特殊類。AtomicInteger
使用了 Unsafe
的原子操作方法 compareAndSwapInt()
對資料進行更新,也就是所謂的 CAS。
public final native boolean compareAndSwapInt(Object o, long offset,
int expected,
int x);
引數 o 是要進行 CAS 操作的物件(比如說 count),引數 offset 是記憶體位置,引數 expected 是期望的值,引數 x 是需要更新到的值。
一般的同步方法會從地址 offset 讀取值 A,執行一些計算後獲得新值 B,然後使用 CAS 將 offset 的值從 A 改為 B。如果 offset 處的值尚未同時更改,則 CAS 操作成功。
CAS 允許執行“讀-修改-寫”的操作,而無需擔心其他執行緒同時修改了變數,因為如果其他執行緒修改變數,那麼 CAS 會檢測它(並失敗),演算法可以對該操作重新計算。
AtomicInteger
類的原始碼中還有一個值得注意的變數 value
。
private volatile int value;
value
使用了關鍵字 volatile
來保證可見性——當多個執行緒訪問同一個變數時,一個執行緒修改了這個變數的值,其他執行緒能夠立即看得到修改的值。
當一個共享變數被 volatile
修飾後,它被修改後的值會立即更新到實體記憶體中,當有其他執行緒需要讀取時,會去實體記憶體中讀取新值。
而沒有被 volatile
修飾的共享變數不能保證可見性,因為不確定這些變數會在什麼時候被寫入實體記憶體中,當其他執行緒去讀取時,讀到的可能還是原來的舊值。
特別需要注意的是,volatile
關鍵字只保證變數的可見性,不能保證原子性。
05、故事的未完待續
“二哥,《多執行緒》就先講到這吧,再多我就吸收不了了!”三妹的態度很誠懇。
“可以。”
“二哥,我記得上次你說要給大號投稿,結果怎麼樣了?”三妹關切地問。
“唉,都不好意思說,只收獲了兩個點讚的表情符號,可能還是基於同情心。嚇得我不敢再投稿了,先堅持寫吧!”
“結局這麼慘淡嗎,真的沒有一個號要轉載嗎?我看那個投稿群有三百多個公號呢。”三妹很傷心。
“《教妹學 Java》系列可能有點標題黨吧?”
“二哥,既然決定要寫,請不要懷疑自己。至少三妹很喜歡這種風格啊。”聽完三妹語重心長的話,我心底的那種自我懷疑又煙消雲散了。