Java併發編程(一): 併發編程的挑戰
本文主要內容出自《Java併發編程的藝術》一書,是對該書內容的歸納和理解,有興趣的朋友請購買正版閱讀全部內容。
併發編程的目的是爲了讓程序運行的更快,但是並不是啓動更多的線程,就能讓程序最大限度的併發執行。在進行併發編程時,如果希望通過多線程執行任務讓程序運行的更快,會面臨非常多的挑戰,比如上下文切換的問題,死鎖的問題,以及受限於硬件和軟件的資源限制問題,本章會介紹幾種併發編程的挑戰,以及解決方案。
1. 上下文切換
即使是單核處理器也支持多線程執行代碼,CPU通過給每個線程分配CPU時間片來實現這個機制。時間片是CPU分配給各個線程的時間,因爲時間片非常短,所以CPU通過不停的切換線程執行,讓我們感覺多個線程是同時執行的,時間片一般是幾十毫秒(ms)。
CPU通過時間片分配算法來循環執行任務,當前任務執行一個時間片後會切換到下個任務,但是在切換前會保存上一個任務的狀態,以便下次切換回這個任務時,可以再加載這個任務的狀態。所以任務的保存到再加載的過程就是一次上下文切換。
就像我們同時在讀兩本書,比如當我們在讀一本英文的技術書時,發現某個單詞不認識,於是便打開中英文字典,但是在放下英文技術書之前,大腦必需首先記住這本書讀到了多少頁的第多少行,等查完單詞之後,能夠繼續讀這本書,這樣的切換是會影響讀書效率的,同樣上下文切換也會影響到多線程的執行速度。
1.1.1 多線程一定快嗎
下面的代碼演示串行和併發執行累加操作的時間,請思考下面的代碼併發執行一定比串行執行快些嗎?
public class ConcurrencyTest {
/** 執行次數 */
private static final long count = 10000l;
public static void main(String[] args) throws InterruptedException {
//併發計算
concurrency();
//單線程計算
serial();
}
private static void concurrency() throws InterruptedException {
long start = System.currentTimeMillis();
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
int a = 0;
for (long i = 0; i < count; i++) {
a += 5;
}
System.out.println(a);
}
});
thread.start();
int b = 0;
for (long i = 0; i < count; i++) {
b--;
}
long time = System.currentTimeMillis() - start;
thread.join();
System.out.println("concurrency :" + time + "ms,b=" + b);
}
private static void serial() {
long start = System.currentTimeMillis();
int a = 0;
for (long i = 0; i < count; i++) {
a += 5;
}
int b = 0;
for (long i = 0; i < count; i++) {
b--;
}
long time = System.currentTimeMillis() - start;
System.out.println("serial:" + time + "ms,b=" + b + ",a=" + a);
}
}
答案是不一定,測試結果如表1-1所示:
表1-1 測試結果
循環次數 | 串行執行耗時(單位ms) | 併發執行耗時 | 併發比串行快多少 |
---|---|---|---|
1億 | 130 | 77 | 約1倍 |
1千萬 | 18 | 9 | 約1倍 |
1百萬 | 5 | 5 | 差不多 |
10萬 | 4 | 3 | 慢 |
1萬 | 0 | 1 | 慢 |
從表1-1可以發現當併發執行累加操作不超過百萬次時,速度會比串行執行累加操作要慢。那麼爲什麼併發執行的速度還比串行慢呢?因爲線程有創建和上下文切換的開銷。
1.2 測試上下文切換次數和時長
下面我們來看看有什麼工具可以度量上下文切換帶來的消耗。
- 使用Lmbench3可以測量上下文切換的時長。
- 使用vmstat可以測量上下文切換的次數。
下面是利用vmstat測量上下文切換次數的示例。
$ vmstat 1
procs -----------memory---------- ---swap-- -----io---- --system-- -----cpu-----
r b swpd free buff cache si so bi bo in cs us sy id wa st
0 0 0 127876 398928 2297092 0 0 0 4 2 2 0 0 99 0 0
0 0 0 127868 398928 2297092 0 0 0 0 595 1171 0 1 99 0 0
0 0 0 127868 398928 2297092 0 0 0 0 590 1180 1 0 100 0 0
0 0 0 127868 398928 2297092 0 0 0 0 567 1135 0 1 99 0 0
CS(Content Switch)表示上下文切換的次數,從上面的測試結果中,我們可以看到其中上下文的每一秒鐘切換1000多次。
1.3 如何減少上下文切換
減少上下文切換的方法有無鎖併發編程、CAS算法、單線程編程和使用協程。
- 無鎖併發編程。多線程競爭鎖時,會引起上下文切換,所以多線程處理數據時,可以用一些辦法來避免使用鎖,如將數據用ID進行Hash算法後分段,不同的線程處理不同段的數據。
- CAS算法。Java的Atomic包使用CAS算法來更新數據,而不需要加鎖。
- 使用最少線程。避免創建不需要的線程,比如任務很少,但是創建了很多線程來處理,這樣會造成大量線程都處於等待狀態。
- 協程:在單線程裏實現多任務的調度,並在單線程裏維持多個任務間的切換。
2. 死鎖
鎖是個非常有用的工具,運用場景非常多,因爲其使用起來非常簡單,而且易於理解。但同時它也會帶來一些困擾,那就是可能會引起死鎖,一旦產生死鎖,會造成系統功能不可用。讓我們先來看一段代碼,這段代碼會引起死鎖,線程t1和t2互相等待對方釋放鎖。
public class DeadLockDemo {
/** A鎖 */
private static String A = "A";
/** B鎖 */
private static String B = "B";
public static void main(String[] args) {
new DeadLockDemo().deadLock();
}
private void deadLock() {
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
synchronized (A) {
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (B) {
System.out.println("1");
}
}
}
});
Thread t2 = new Thread(new Runnable() {
@Override
public void run() {
synchronized (B) {
synchronized (A) {
System.out.println("2");
}
}
}
});
t1.start();
t2.start();
}
}
這段代碼只是演示死鎖的場景,在現實中你可能很難會寫出這樣的代碼。但是一些更爲複雜的場景中你可能會遇到這樣的問題,比如t1拿到鎖之後,因爲一些異常情況沒有釋放鎖,比如死循環。又或者是t1拿到一個數據庫鎖,釋放鎖的時候拋了異常,沒釋放掉。
一旦出現死鎖,業務是可感知的,因爲不能繼續提供服務了,那麼只能通過dump線程看看到底是哪個線程出現了問題,以下線程信息告訴我們是DeadLockDemo類的42行和31號引起的死鎖:
"Thread-2" prio=5 tid=7fc0458d1000 nid=0x116c1c000 waiting for monitor entry [116c1b000]
java.lang.Thread.State: BLOCKED (on object monitor)
at com.ifeve.book.forkjoin.DeadLockDemo$2.run(DeadLockDemo.java:34)
- waiting to lock <7fb2f3ec0> (a java.lang.String)
- locked <7fb2f3ef8> (a java.lang.String)
at java.lang.Thread.run(Thread.java:695)
"Thread-1" prio=5 tid=7fc0430f6800 nid=0x116b19000 waiting for monitor entry [116b18000]
java.lang.Thread.State: BLOCKED (on object monitor)
at com.ifeve.book.forkjoin.DeadLockDemo$1.run(DeadLockDemo.java:23)
- waiting to lock <7fb2f3ef8> (a java.lang.String)
- locked <7fb2f3ec0> (a java.lang.String)
at java.lang.Thread.run(Thread.java:695)
現在我們介紹下如何避免死鎖的幾個常見方法。
- 避免一個線程同時獲取多個鎖。
- 避免一個線程在鎖內同時佔用多個資源,儘量保證每個鎖只佔用一個資源。
- 嘗試使用定時鎖,使用tryLock(timeout)來替代使用內部鎖機制。
- 對於數據庫鎖,加鎖和解鎖必須在一個數據庫連接裏,否則會出現解鎖失敗。
3. 資源限制的挑戰
(1)什麼是資源限制?
資源限制是指在進行併發編程時,程序的執行速度受限於計算機硬件資源或軟件資源的限制。比如服務器的帶寬只有2M,某個資源的下載速度是1M每秒,系統啓動十個線程下載資源,下載速度不會變成10M每秒,所以在進行併發編程時,要考慮到這些資源的限制。硬件資源限制有帶寬的上傳下載速度,硬盤讀寫速度和CPU的處理速度。軟件資源限制有數據庫的連接數和Sorket連接數等。
(2)資源限制引發的問題
併發編程將代碼執行速度加速的原則是將代碼中串行執行的部分變成併發執行,但是如果某段串行的代碼併發執行,但是因爲受限於資源的限制,仍然在串行執行,這時候程序不僅不會執行加快,反而會更慢,因爲增加了上下文切換和資源調度的時間。例如,之前看到一段程序使用多線程在辦公網併發的下載和處理數據時,導致CPU利用率100%,任務幾個小時都不能運行完成,後來修改成單線程,一個小時就執行完成了。
(3)如何解決資源限制的問題?
對於硬件資源限制,可以考慮使用集羣並行執行程序,既然單機的資源有限制,那麼就讓程序在多機上運行,比如使用ODPS,hadoop或者自己搭建服務器集羣,不同的機器處理不同的數據,比如將數據ID%機器數,得到一個機器編號,然後由對應編號的機器處理這筆數據。
對於軟件資源限制,可以考慮使用資源池將資源複用,比如使用連接池將數據庫和Sorket連接複用,或者調用對方webservice接口獲取數據時,只建立一個連接。
(4)在資源限制情況下進行併發編程
那麼如何在資源限制的情況下,讓程序執行的更快呢?根據不同的資源限制調整程序的併發度,比如下載文件程序依賴於兩個資源,帶寬和硬盤讀寫速度。有數據庫操作時,要數據庫連接數,如果SQL語句執行非常快,而線程的數量比數據庫連接數大很多,則某些線程會被阻塞住,等待數據庫連接。
4. 小結
本文介紹了在進行併發編程的時候,大家可能會遇到的幾個挑戰,並給出了一些解決建議。有的併發程序寫的不嚴謹,在併發下如果出現問題,定位起來會比較耗時和棘手。所以對於Java開發工程師,筆者強烈建議多使用JDK併發包提供的併發容器和工具類來幫你解決併發問題,因爲這些類都已經通過了充分的測試和優化,解決了本章提到的幾個挑戰。