前言
編寫正确的程式很難,而編寫正确的并發程式則難上加難。與串行程式相比,在并發程式中存在更多容易出錯的地方。那麼,為何我們還要使用并發程式?線程是Java語言中不可或缺的重要功能,它們能使複雜的異步代碼變得簡單,進而極大地簡化了複雜系統的開發。此外,想要充分發揮多處理器系統的強大計算能力,最簡單的方式就是使用線程。随着處理器數量的持續增長,如何高效地使用蝙蝠正變得越來越重要。同時在當今網際網路的時代,大量的網際網路應用都面對着海量的通路請求,是以,并發程式設計在我們的應用中成為越來越不可或缺的一部分。
并發簡史
在早期的計算機中不包含作業系統,它們從頭到尾隻可以運作一個程式,作業系統的出現使得計算機每次能運作多個程式,并且不同的程式都在單獨的程序中運作,之是以在計算機中加入作業系統來實作多個程式的同時執行,主要是基于下面幾個原因:
- 資源使用率 在某些情況下,程式必須等待某個外部操作執行完成,而等待時程式無法執行其他任何工作。是以,如果在等待同時可以運作另一個程式,那麼無疑将提高資源的使用率。
- 公平性 不同的使用者和程式對于計算機上的資源有着同等的使用權。一種高效的運作方式是通過時間分片使使用者和程式可以共享計算機資源,而不是一個程式從頭運作到底,再啟動下一個程式。
- 便利性 通常來說,在計算多個任務時,應該編寫多個程式,每個程式執行一個任務并在必要時互相通信,這比隻編寫一個程式來計算所有任務更容易實作。
線程優勢
如果使用得當,線程可以有效降低程式的開發和維護成本,同時提升複雜應用程式的性能。在伺服器應用程式中,可以提升資源使用率以及系統吞吐率,線程還可以簡化JVM的實作,垃圾收集器通常在一個或多個專門的線程中運作,在許多重要的Java應用中,都在一定程度生用到了線程。
發揮多處理器的強大能力
随着現在多處理器的普及,我們的伺服器目前多數都是多個核心的,由于CPU的基本排程機關是線程,是以如果在程式中隻有一個線程,那麼最多同時隻能在一個處理器上運作。在雙處理器系統上,單線程的程式隻能使用一半的CPU資源,而在擁有100個處理器的系統上,将有99%的資源無法使用。另一方面,多線程程式可同時在多個處理器上執行。如果設計正确,多線程程式可以通過提高處理器資源的使用率來提升系統吞吐率。
異步事件的簡化處理
伺服器應用程式在接受來自多個遠端用戶端的連接配接請求時,如果為每個連接配接都配置設定各自的線程并且使用同步I/O,那麼就會降低這類程式的開發難度。
在單線程應用中,如果在處理某一請求過程中出現阻塞,意味着在這個線程被阻塞的期間,對所有請求的處理都将停頓。為了避免這個問題,單線程伺服器應用程式必須使用非阻塞I/O,這種I/O的複雜性要遠遠高于同步I/O,并且很容易出錯,如果每個請求都擁有自己的處理線程,那麼在處理某個請求時發生的阻塞将不會影響其他請求的處理。
目前主流的Web容器,例如Tomcat,是支援多線程異步非阻塞模型來響應請求的,這樣可以獲得更大的請求吞吐量。
線程帶來的風險
Java對線程的支援其實是一把雙刃劍。雖然Java提供了相應的語言和庫,以及一種明确的跨平台記憶體模型,這些工具簡化的了并發應用程式的開發,但同時也挺高了對開發人員的技術要求,因為在更多的程式中會使用線程。
線程安全問題
線程安全性可能是非常複雜的,在沒有充分同步的情況下,多個線程中的操作執行順序是不可預測的,甚至可能會出現奇怪的結果。下面的例子中,UnsafeSequence類中将産生一個整數值序列,該序列中的每個值都是唯一的。在這個類中簡要的說明了多個線程之間的交替操作将如何導緻不可預料的結果。在單線程環境中,這個類能正确地工作,但在多線程環境中則不可以。
public class UnsafeSequence {
private int value;
public int getNext() {
return value++;
}
public static void main(String[] args) {
UnsafeSequence unsafeSequence = new UnsafeSequence();
Executor executors = Executors.newFixedThreadPool(8);
for (int i = 0; i < 200; i++) {
executors.execute(()-> System.out.println(unsafeSequence.getNext()));
}
}
}
輸出結果:
3 0 4 1 5 0 9 10 11 2 15 13 12 14 8 21 6 7 24 23 22 20 19 29 30 18 17 16 33 32 31 28 27 26 25 41 42 43 44 45 46 47 40 39 38 37 52 36 35 55 34 57 58 56 60 54 53 62 64 65 51 67 50 69 49 71 72 73 48 75 76 74 70 79 68 81 66 83 63 85 86 61 88 89 59 91 92 90 94 87 96 97 84 99 100 82 102 80 78 77 105 104 103 101 98 95 93 112 111 115 110 109 118 108 107 121 106 122 120 119 117 116 114 128 113 129 130 127 126 134 125 124 123 139 138 137 136 135 144 133 132 146 131 149 148 147 145 153 143 142 141 140 158 157 156 155 154 163 152 151 166 167 150 168 170 171 165 164 174 175 176 162 161 179 180 181 182 160 184 185 159 187 186 189 190 183 192 193 194 178 196 197 198 177 173 172 169 195 191 188
上面結果中沒有出現199
UnsafeSequence的問題在于,如果執行時機不對,那麼兩個線程在調用getNext時會得到相同的值,雖然遞增運算value++看上去是單個操作,但事實上它包含三個獨立的操作:讀取value,将value加1,并将計算結果寫入value。由于運作時可能将多個線程之間的操作交替執行,是以這兩個線程可能同時執行讀操作,進而它們得到相同的值,并都将這個值加1,結果就是,在不同線程的調用中傳回了相同的數值。
在UnsafeSequence類中說明的是一種常見的并發安全問題,稱為競态條件。在多線程環境下,getValue是否會傳回唯一的值,要取決于運作時對線程中操作的交替執行方式,這并不是我們希望看到的情況。
由于多個線程要共享相同的記憶體位址空間,而且是并發運作,是以它們可能會通路或修改其他線程正在使用的變量。當然,這是一種極大的便利,因為這種方式比其他線程間通信機制更容易實作資料共享。但是它也帶來了巨大的風險:線程會由于無法預料的資料變化而發生錯誤。當多個線程同時通路和修改相同的變量時,将會在串行程式設計模型中引入非串行因素,而這種非串行性是很難進行分析的。要使多線程程式的行為可以預測,必須對共享變量的通路操作進行協同,這樣才不會線上程之間發生彼此幹擾。幸運的是,Java提供了各種同步機制來協同這種通路。看下面的例子:
public class SafeSequence {
private int value;
public synchronized int getNext() {
return value++;
}
public static void main(String[] args) {
SafeSequence safeSequence = new SafeSequence();
Executor executors = Executors.newFixedThreadPool(8);
for (int i = 0; i < 200; i++) {
executors.execute(()-> System.out.println(safeSequence.getNext()));
}
}
}
活躍性問題
多線程會導緻一些在單線程程式中不會出現的問題,例如活躍性問題。當某個操作無法繼續執行下去時,就會發生活躍性問題。在串行程式中,活躍性問題的形式之一就是無意中造成的無限循環,進而使循環之後的代碼無法得到執行。還有一些其他類型的問題,例如:如果線程A在等待線程B釋放其持有的資源,而線程B永遠都不釋放該資源,那麼A就會永遠的等待下去。這就是通常所說的“死鎖”。
性能問題
在設計良好的并發應用程式中,線程能提升程式的性能,但無論如何,線程總會帶來某種程度的運作時開銷。再多線程程式中,當線程排程器臨時挂起活躍線程并轉而運作另一個線程時,就會頻繁地出現上下文切換操作,這種操作将帶來極大的開銷:儲存和恢複執行上下文,丢失局部性,并且CPU時間将更多地花線上程排程而不是線程運作上。當線程共享資料時,必須使用同步機制,而這些機制往往會抑制某些編譯器優化,使記憶體緩存區中的資料無效,以及增加共享記憶體總線的同步流量。所有這些因素都将帶來額外的性能開銷。
線程無處不在
即使在程式中沒有顯示地建立線程,但在架構中仍可能會建立線程,是以在這些線程中調用的代碼同樣必須是線程安全的。
每個Java應用程式都會使用線程。當JVM啟動時,它将為JVM的内部任務,例如垃圾收集,終結操作等建立背景線程,并建立一個主線程來運作main方法。
并發程式設計中的重要概念
同步VS異步
同步和異步通常用來形容一次方法調用。同步方法調用一開始,調用者必須等待被調用的方法結束後,調用者後面的代碼才能執行。而異步調用,指的是,調用者不用管被調用方法是否完成,都會繼續執行後面的代碼,當被調用的方法完成後會通知調用者。比如,在超市購物,如果一件物品沒了,你得等倉庫人員跟你調貨,直到倉庫人員給你把貨物送過來,你才能繼續去收銀台付款,這就類似同步調用。而異步調用了,就像網購,你在網上付款下單後,什麼事就不用管了,該幹嘛就幹嘛去了,當貨物到達後你收到通知去取就好。
并發與并行
并發和并行是十分容易混淆的概念。并發指的是多個任務交替進行,而并行則是指真正意義上的“同時進行”。實際上,如果系統内隻有一個CPU,而使用多線程時,那麼真實系統環境下不能并行,隻能通過切換時間片的方式交替進行,而成為并發執行任務。真正的并行也隻能出現在擁有多個CPU的系統中。
阻塞和非阻塞
阻塞和非阻塞通常用來形容多線程間的互相影響,比如一個線程占有了臨界區資源,那麼其他線程需要這個資源,就必須進行等待該資源的釋放,會導緻等待的線程挂起,這種情況就是阻塞,而非阻塞就恰好相反,它強調沒有一個線程可以阻塞其他線程,所有的線程都會嘗試地往前運作。
臨界區
臨界區用來表示一種公共資源或者說是共享資料,可以被多個線程使用。但是每個線程使用時,一旦臨界區資源被一個線程占有,那麼其他線程必須等待。