原文發於公衆號「百川海的小記」,一個菜鳥的自留地,歡迎關注討論java
說在前面:這篇文章的內容沿用整理自本人作的一次內部技術分享,半講稿性質,因此我會用口語的形式表述,並配以一些「編注」進行補充。由於技術分享時間有限,加之本人並不是對本文中說起的全部內容都有深入研究,所以文中部份內容的說明點到爲止。不夠深刻之處,有興趣的同窗請自行查閱相關資料。程序員
本篇的內容太長(打字拼不過說話速度啊),分紅多期編寫,本期爲第一期redis
###################正題#######################算法
併發編程是編程中的難點,這一次分享主要但願將近半年到一年以來的一些學習思考的內容拿出來與各位同窗探討。觀點僅做拋磚引玉之用。編程
首先,分享的標題是併發編程「不徹底指北」,這裏有個雙關:既然是「指北」,各位就不要當作指南,不能做爲金科玉律,可是這個「指北」也不徹底,由於裏面我以爲仍是有些有價值的內容;另外,做爲併發編程的一次主題分享,併發編程的問題之複雜,我不能逐一說明,因此內容是不徹底的。緩存
進入正題,分享主要分爲五塊:安全
併發編程的背景;多線程
併發編程的問題與處理方案;架構
進程/線程的協做關係與協做模型;(編注:由於敘述的主要是Java的實踐,所以下文中部分文字不強調進程,對於Java直接相關的,好比JVM的說明等,討論範圍爲嚴格線程,而在模型和思路的討論中,進程/線程實質上影響不大,意會便可)併發
陷阱——也就是一些性能上的問題或者實際編程裏面會遇到的坑——與優化;
一個實戰例子。
(編注:加插提供一份本文的思惟導圖)
目錄的下面寫了一句話:「面向接口編程,面向思路設計」,這句話是我以前合做過的一位架構師說的,我以爲特別有意思。由於引領實踐做用的只有思路,具體實現是沒法給出指導的。因此我也不太打算去講併發的工具,而是講一些思路性的內容。
背景:併發編程存在的意義
第一個部分,咱們聊聊背景。
第一個問題,咱們爲何要用併發編程?我給出的結論是:由於咱們如今的計算機結構裏面,CPU、內存、IO設備的速度是不平衡的,CPU的速度遠高於其餘硬件的,尤爲是IO設備,對於CPU的高速來講簡直是慢如蝸牛。爲了壓榨CPU的性能,因此工程師想出了多線程的方式,充分利用CPU的速度。換而言之,若是有一天,計算機的全部硬件均可以達到和CPU一致的高速率,其實咱們就不須要考慮什麼併發編程了,由於逐個線程順序執行的性能也能達到整個計算機系統的最高性能。
下一個問題,咱們爲何不要用併發編程?其實很簡單,由於它很複雜,它是反直覺而且難以控制的。往高級點說,它是違反結構化編程準則的。人的思惟是線性的,因此違反線性的程序都是反直覺的,而反直覺的都是容易出錯的,這就是併發編程難以精通的緣由。而另外一個方面,難以控制的特色,使得測試與異常捕捉也變得困難。結構化編程之父Dijkstra提出了順序、選擇、循環三種結構化編程的程序結構,其特色是輸入輸出呈線性結構;他同時指出goto語句這類非結構式的編程語句是「毒藥」。其實從這個角度來講,併發編程可能形成的隨機性結果,危害性比goto語句更甚。
可是,現實很骨感,沒有一個程序員能夠抵抗性能提升的誘惑,因此,我以爲併發編程能夠類比爲飲鴆止渴。這實屬無奈,咱們不得不作出妥協,可是咱們應該意識到併發編程從某程度來講,是有害的。
併發問題與處理方案
接下來咱們討論併發問題和它們的處理方案。不過在聊併發問題以前,咱們先要討論是什麼致使問題出現的。若是可以從前提條件入手處理,問題也就不會發生。
條件一:狀態可變性
併發問題的條件一,是狀態的可變性。狀態可變性指一個狀態在建立以後能夠被修改的特性。咱們知道,讀取一個狀態,不管如何都不會對它形成變化的,狀態都是惟一的、安全的,因此沒有變化,就沒有傷害。因而從這個角度避免併發問題的辦法就很簡單了,只要保證對象的不變性便可。Java裏面構造一個不可變對象,基本能夠分爲如下幾個步驟:
識別成員對象中的可變對象與不可變對象
對象以private修飾,可變對象以final修飾(建議)
提供不可變對象的getter方法,返回其引用
提供可變對象的getter方法,返回其深複製副本
尤爲須要注意的是對可變對象的訪問,對外提供的必須是深複製的副本,淺複製的副本是可變的。構造完成後,不可變對象不對外提供任何修改狀態的辦法,這是不可變對象的基礎。
可是不可變對象就真的徹底不可變了麼?起碼在Java裏面這個仍是不必定的,由於Java的反射機制仍是能夠對不可變對象進行修改操做的,因此對於反射這麼一把雙刃劍,咱們要隨時保持警戒。
條件二:狀態共享性
併發問題的條件二,是狀態的共享性。共享性指一個數據狀態能夠被多個進程/線程訪問的特性。若是一個狀態只被單線程訪問,對它的操做就是串行的,天然不會出現併發的問題。根據這個思路,咱們儘量地將可變對象封閉起來,避免其共享,就能夠避免併發問題的發生。這又衍生出來兩個手段:
可變狀態隔離,即讓可變狀態只面向單一線程,實踐的例子不少,好比Java中的局部變量、ThreadLocal、Netty中的EventLoop機制、Actor模型(JVM的實踐如Akka)等。順帶一提,Actor模型是一個頗有意思的解決併發編程的模型,有興趣請查閱一下相關資料,這裏不展開敘述。
可變狀態封裝。這個主要是面向編程而言的,做爲一種代碼組織的手段。將關聯的可變狀態封裝,這是面向對象編程的自然優點所在,很契合OOP的基本思路,並且進行封裝之後,就有了狀態同步的基礎。
下面是一個半成品的例子。
/** * 限定:下界不大於上界,上界不小於下界 */
public class LimitedMem {
private AtomicInteger lowerLimit; // 下界
private AtomicInteger upperLimit; // 上界
private Vector content; // 數據存儲結構
public void setLowerLimit(int limit) {
if (limit <= upperLimit.get()) {
lowerLimit.compareAndSet(lowerLimit.get(), limit);
}
}
public void setUpperLimit(int limit) {
if (limit >= lowerLimit.get()) {
upperLimit.compareAndSet(upperLimit.get(), limit);
}
}
// TODO add, get, ...
}
複製代碼
咱們假設這是一個具備上界和下界約束的存儲對象,約束條件是「下界不大於上界,上界不小於下界」。若是將下界、上界兩個變量遊離出去,咱們是沒有辦法安全地完成這個約束的,所以咱們將其放到同一個對象中。可是這個代碼還缺了一步,就是約束的斷定和實際的操做是分離的,中間可能由於線程切換而中斷,這就是所謂原子性問題,後面會作進一步討論。在這個例子中,咱們只須要爲兩個方法加上synchronize同步鎖便可,由於咱們已經有了LimitedMem這個對象自己做爲鎖的同步對象了。若是沒有狀態的封裝,這一點是作不到的。
併發三大問題
說完了兩個必要條件,正式來講說併發的三大問題,分別是可見性、有序性、原子性。
問題一:可見性
可見性指當一個線程修改了共享變量後,其餘線程可以當即得知這個修改。強調三點:一是必須有修改操做,二是變量必須是共享的,這兩點和上面兩個前提條件是對應的,三是其餘線程得到通知是當即的,不存在延時的。
要理解可見,就要先了解不可見的緣由,這要追溯到硬件結構。以前提到CPU的運行速度是明顯快過內存的,所以爲了平衡二者的速度差,硬件工程師爲CPU加上了被稱爲「高速緩存」的存儲硬件,它的速度介於CPU與內存之間,並且不僅一個,所緩存的數據也相互獨立。當程序運行時,爲了更快速地處理數據,CPU會從主存將數據加載到高速緩存,而後直接與高速緩存進行交互,而非直接與主存交互。高速緩存的數據回寫到主存的時機不是固定的,所以極可能出現緩存中的數據變動後,主存的數據沒有及時刷新,這時候其餘線程進行數據讀取,讀取到的就是舊的值,出現了數據的「時差」。
說到可見性,我以爲應該補充說明一個常見的誤解。Java裏面有個Happens-Before規則的概念,有人根據字面意思將其理解爲A早於B發生,即A Happens Before B,從而認爲規則的意義只在於約束兩個操做的前後執行順序,這是對Happens-Before的誤解。Happens-Before規則的真實含義應該是「A的操做結果必可見於B」,這並不僅意味着A發生早於B,並且A的修改操做結果,B是必然可見的。這裏的「可見」,和「可見性」中的「可見」是同一個意思,便是A的操做結果,在其後執行的任意線程B都是能夠從主存讀取到的。具體的Happens-Before規則請自行搜索,在jdk1.5以後的語義已經至關清晰完備了,我就不念白直說了。
Java處理可見性問題的方案是經過volatile關鍵字進行修飾。它的其中一個語義是強制變量與主存交互讀寫,避免可見性問題。具體的操做是遵循Happens-Before規則,在指令中添加內存柵欄(編注:有些說法也稱之爲內存屏障),當指令到達內存柵欄的地方,就強制與主存交互,而且另其餘高速緩存的數值無效,從而強制CPU在獲取數據時向內存直接讀取數據。Volatile還有另一個語義,與有序性相關,容後再談,這裏我先討論一下有序性的問題。
問題二:有序性
所謂有序性,指機器指令的實際執行應該遵循代碼邏輯順序。這個邏輯順序,一方面是咱們顯式寫在代碼上的邏輯順序,另外還有一個方面是一個單獨語句隱含的語義,也要遵循邏輯順序。通常狀況下,這個好像是很瓜熟蒂落的,毋庸置疑的,可是對於JVM來講,這個並非必然的。JVM爲了提升自身運行性能,會在一些狀況下對實際執行的程序指令進行從新的排序,咱們稱之爲「指令重排序」(編注:也有簡稱爲「指令重排」或直接稱爲「重排序」的)。JVM對指令的重排不是隨便排的,它是由一個自洽的規則的:指令重排後的運行結果不能與該程序在單線程下、不重排地串行執行獲得的結果有差別,先後二者的結果必須保持一致。這個規則稱爲as-if-serial規則。
可是,JVM給出的這個保證,僅適用於單線程,到了多線程的狀況下,有可能就不適用了。兩個指令能夠進行重排序的條件,亦即知足as-if-serial規則的必要條件,是兩個指令間不存在相互依賴。可是在併發場景中,指令的依賴性是沒法保障的。
舉個例子,以下圖所示:
// thread 1
x = 1; // statement 1
y = 2; // statement 2
// thread 2
if (x == 1)
function(y); // statement 3
複製代碼
對這個例子,thread 1在單線程狀況下執行,statement 1 與 statement 2 是沒有依賴關係的,能夠隨意亂序的;可是加入thread 2的併發執行後,一、2的亂序執行,就會直接對statement 3的結果形成直接的影響,這一點是JVM沒法作出承諾的。
有序性還有一個具備迷惑性的地方,在於通常問題都出於一些語句的隱含語義當中。再舉個例子,下面是一個有問題的單例寫法。
public class Singleton {
private Singleton singleton;
private Singleton (){}
public Singleton getSingleton() {
if (singleton == null) {
synchronized (Singleton.class) {
if (singleton == null) {
singleton = new Singleton();
Collections.synchronizedMap(new HashMap<>());
}
}
}
return singleton;
}
}
複製代碼
它的問題所在,在於Singleton的建立,是可能被重排序的。按照正常的語義,new一個對象的正確順序應該是:
開闢內存空間
在內存空間中初始化對象數據
將棧的指針指向內存空間
然而通過指令重排,實際的執行順序可能變爲:
開闢內存空間
將棧的指針指向內存空間
在內存空間中初始化對象數據
若是這種狀況,一旦另一個線程在第二步的時候執行null判斷,由於棧的指針已經有了內存區域的指向,非空判斷的結果爲非空,因而直接返回,返回的結果則是一個未經初始化的空間對象。這也是指令重排序形成的問題。
上面提到volatile有另一個語義,就是對volatile修飾的變量操做禁止指令重排序。沒了重排序,有序性問題天然從根源上解決了。在jdk1.5以前,例子中的這種雙重檢測鎖的懶漢式單例在Java中是不安全的,直到1.5版本後,volatile的語義獲得增強,這種寫法才成立。處理很簡單,在singleton前面加上volatile修飾就能夠了。不過話分兩頭,指令重排在必定程度上是有利於執行性能的,因此禁止重排序是有損性能的。
另外,順帶一提,final修飾的變量,在初始化階段也是禁止重排序的,爲的就是確保避免上面出現的初始化階段返回空值的問題。
問題三:原子性
最後一個問題,是原子性問題。原子性指一組操做的外部表現必須是完整的,不可中斷的。網上不少說法,將原子性表述爲「不可分割」的,這個在原子的字面意思上的確如此,可是在程序的角度去理解是不許確的。要確保一組操做具有原子性,其實並不必定須要真正意義上的「不可分割」,而只須要在未完成的狀態下,外部的訪問不能「看到」中間的結果就能夠了,因此我將這個特性強調爲「外部表現」的。
原子性問題的出現,根源在於線程/進程切換,而且中間狀態對外暴露,那要解決這個問題,也就要從這兩點入手。要阻止切換,手段就是互斥,這個話題在Java裏面也有不少手段,咱們在下面再聊。另外能夠考慮使用原語。原語能夠簡單理解爲一個具備原子性的操做,若是底層提供了一個可用的原語,上層程序的調用底層原語(編注:在不引入其餘任何操做的狀況下),這個調用操做自己天然也是具有原子性的。而中間狀態對外暴露的問題,本質就是共享性問題,和上面可變狀態隔離的處理思路也一致,這裏就不重複了。
這裏有個例子,代碼如圖所示。
public class IllegalThreadCountIncrease {
private static int num = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() ->{
for (int i=0; i<10000; i++) {
IllegalThreadCountIncrease.num++;
}
});
Thread t2 = new Thread(() ->{
for (int i=0; i<10000; i++) {
IllegalThreadCountIncrease.num++;
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(IllegalThreadCountIncrease.num);
}
}
複製代碼
這是一個常見的例子,我曾經看過在網上的文章中用這個例子來講明volatile的可見性。當時的文章在num前面加上了volatile修飾,就認爲能夠知足輸出20000的需求。你們不妨一試,這麼作依然只能得出一個隨機值。緣由在於,volatile雖然解決了可見性問題,可是沒有解決原子性問題。自增的這個操做,雖然只是一句代碼,可是實際執行的時候分爲3個指令:
取數
自增改數
回寫
而很不幸的是,這3個指令的執行並非原子的,試想這麼一個場景:
A線程取數,值爲0,而後中斷,B線程開始執行取數,值也是爲0,而後B線程中斷,A線程繼續執行自增與回寫,主存中的結果變成1,可是此時對於B線程來講,變量的值仍是0,具體來講這個值是寫在B線程的棧中的,因而B再對這個變量執行自增、回寫,主存中的結果仍是1,問題就出現了。兩種狀況的過程時序如圖所示。
可見性問題是高速緩存形成的,而這種對於線程自行維護的存儲空間中的數據落後問題,或者說數據一致性的問題,咱們通常不理解爲可見性問題,volatile的語義也並無擴展到能夠解決這個範圍的數據問題。所以,volatile不能解決原子性問題,也不能代替互斥,不能將其理解爲「鎖」。
回到這個例子,要得出正確的20000,辦法只能是對num++的這個代碼增長互斥鎖,最簡單的synchronize(IllegalThreadCountIncrease.class) 就能夠了。
終極大招
說了好多關於三大問題的處理,最後不能遺漏一個處理併發問題的終極大招,那就是串行化。串行化完全地避免了併發操做的出現,對全部併發問題都是一個完全的解決方案。請不要由於我已經聊了半天併發,就以爲覺得抹除併發操做自己毫無心義,回憶一下前面的一個基礎現實:併發編程在某程度上來講是有害的。好比咱們經常使用的redis,就是單進程單線程實現的範例,可是它的性能依然至關好。併發不等於好,串行也不必定很差,這是須要根據模型和算法具體考慮的。
三大問題的總結
總結一下,併發問題體現爲三個問題:可見性問題、有序性問題、原子性問題。可見性問題的緣由是CPU高速緩存的讀寫,有序性問題的緣由是JVM的指令重排,這兩個問題的解決方案,都是經過volatile進行聲明修飾。原子性問題的緣由是因爲線程切換,要解決這個問題,辦法就比較多了,能夠嚴格使用底層的原語,能夠實現互斥,也能夠避免線程的共享,這些在實際的項目中都是常見的。最後,請記得一個完全解決辦法的大招:串行化,讓你的程序順序執行,一個一個地。
未完待續……