《Java併發程式設計的藝術》第一章
作者:方騰飛 本文是樣章 購買本書=》 噹噹 京東 天貓 互動
第1章併發程式設計的挑戰
併發程式設計的目的是為了讓程式執行的更快,但是並不是啟動更多的執行緒,就能讓程式最大限度的併發執行。在進行併發程式設計時,如果希望通過多執行緒執行任務讓程式執行的更快,會面臨非常多的挑戰,比如上下文切換的問題,死鎖的問題,以及受限於硬體和軟體的資源限制問題,本章會介紹幾種併發程式設計的挑戰,以及解決方案。
1.1 上下文切換
即使是單核處理器也支援多執行緒執行程式碼,CPU通過給每個執行緒分配CPU時間片來實現這個機制。時間片是CPU分配給各個執行緒的時間,因為時間片非常短,所以CPU通過不停的切換執行緒執行,讓我們感覺多個執行緒是同時執行的,時間片一般是幾十毫秒(ms)。
CPU通過時間片分配演算法來迴圈執行任務,當前任務執行一個時間片後會切換到下個任務,但是在切換前會儲存上一個任務的狀態,以便下次切換回這個任務時,可以再載入這個任務的狀態。所以任務的儲存到再載入的過程就是一次上下文切換。
就像我們同時在讀兩本書,比如當我們在讀一本英文的技術書時,發現某個單詞不認識,於是便開啟中英文字典,但是在放下英文技術書之前,大腦必需首先記住這本書讀到了多少頁的第多少行,等查完單詞之後,能夠繼續讀這本書,這樣的切換是會影響讀書效率的,同樣上下文切換也會影響到多執行緒的執行速度。
1.1.1 多執行緒一定快嗎?
下面的程式碼演示序列和併發執行累加操作的時間,請思考下面的程式碼併發執行一定比序列執行快些嗎?
package chapter01; /** * 併發和單執行緒執行測試 * @author tengfei.fangtf * @version $Id: ConcurrencyTest.java, v 0.1 2014-7-18 下午10:03:31 tengfei.fangtf Exp $ */ 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.1.2 測試上下文切換次數和時長
下面我們來看看有什麼工具可以度量上下文切換帶來的消耗。
- 使用Lmbench3[1]可以測量上下文切換的時長。
- 使用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.1.3 如何減少上下文切換
減少上下文切換的方法有無鎖併發程式設計、CAS演算法、單執行緒程式設計和使用協程。
- 無鎖併發程式設計。多執行緒競爭鎖時,會引起上下文切換,所以多執行緒處理資料時,可以用一些辦法來避免使用鎖,如將資料用ID進行Hash演算法後分段,不同的執行緒處理不同段的資料。
- CAS演算法。Java的Atomic包使用CAS演算法來更新資料,而不需要加鎖。
- 使用最少執行緒。避免建立不需要的執行緒,比如任務很少,但是建立了很多執行緒來處理,這樣會造成大量執行緒都處於等待狀態。
- 協程:在單執行緒裡實現多工的排程,並在單執行緒裡維持多個任務間的切換。
1.1.4 減少上下文切換實戰
本節描述通過減少線上大量WAITING的執行緒,來減少上下文切換次數。
第一步:用jstack命令 dump執行緒資訊,看看pid是3117程序裡的執行緒都在做什麼。
sudo -u admin /opt/ifeve/java/bin/jstack 31177 > /home/tengfei.fangtf/dump17
第二步:統計下所有執行緒分別處於什麼狀態,發現300多個執行緒處於WAITING(onobjectmonitor)狀態。
[[email protected] ~]$ grep java.lang.Thread.State dump17 | awk '{print $2$3$4$5}' | sort | uniq -c 39 RUNNABLE 21 TIMED_WAITING(onobjectmonitor) 6 TIMED_WAITING(parking) 51 TIMED_WAITING(sleeping) 305 WAITING(onobjectmonitor) 3 WAITING(parking)
第三步:開啟dump檔案檢視處於WAITING(onobjectmonitor)的執行緒在做什麼。發現這些執行緒基本全是JBOSS的工作執行緒在await。說明JBOSS執行緒池裡執行緒接收到的任務太少,大量執行緒都閒著。
"http-0.0.0.0-7001-97" daemon prio=10 tid=0x000000004f6a8000 nid=0x555e in Object.wait() [0x0000000052423000] java.lang.Thread.State: WAITING (on object monitor) at java.lang.Object.wait(Native Method) - waiting on <0x00000007969b2280> (a org.apache.tomcat.util.net.AprEndpoint$Worker) at java.lang.Object.wait(Object.java:485) at org.apache.tomcat.util.net.AprEndpoint$Worker.await(AprEndpoint.java:1464) - locked <0x00000007969b2280> (a org.apache.tomcat.util.net.AprEndpoint$Worker) at org.apache.tomcat.util.net.AprEndpoint$Worker.run(AprEndpoint.java:1489) at java.lang.Thread.run(Thread.java:662)
第四步:減少JBOSS的工作執行緒數,找到JBOSS的執行緒池配置資訊,將maxThreads降低到100。
<maxThreads="250" maxHttpHeaderSize="8192" emptySessionPath="false" minSpareThreads="40" maxSpareThreads="75" maxPostSize="512000" protocol="HTTP/1.1" enableLookups="false" redirectPort="8443" acceptCount="200" bufferSize="16384" connectionTimeout="15000" disableUploadTimeout="false" useBodyEncodingForURI="true">
第五步:重啟JBOSS,再dump執行緒資訊,然後再統計WAITING(onobjectmonitor)的執行緒,發現減少了175。WAITING的執行緒少了,系統上下文切換的次數就會少,因為從WAITTING到RUNNABLE會進行一次上下文的切換。讀者也可以使用vmstat命令測試下。
[[email protected] ~]$ grep java.lang.Thread.State dump17 | awk '{print $2$3$4$5}' | sort | uniq -c 44 RUNNABLE 22 TIMED_WAITING(onobjectmonitor) 9 TIMED_WAITING(parking) 36 TIMED_WAITING(sleeping) 130 WAITING(onobjectmonitor) 1 WAITING(parking)
1.2 死鎖
鎖是個非常有用的工具,運用場景非常多,因為其使用起來非常簡單,而且易於理解。但同時它也會帶來一些困擾,那就是可能會引起死鎖,一旦產生死鎖,會造成系統功能不可用。讓我們先來看一段程式碼,這段程式碼會引起死鎖,執行緒t1和t2互相等待對方釋放鎖。
package chapter01; /** * 死鎖例子 * * @author tengfei.fangtf * @version $Id: DeadLockDemo.java, v 0.1 2015-7-18 下午10:08:28 tengfei.fangtf Exp $ */ 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:42) - 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:31) - waiting to lock <7fb2f3ef8> (a java.lang.String) - locked <7fb2f3ec0> (a java.lang.String) at java.lang.Thread.run(Thread.java:695)
現在我們介紹下如何避免死鎖的幾個常見方法。
- 避免一個執行緒同時獲取多個鎖。
- 避免一個執行緒在鎖內同時佔用多個資源,儘量保證每個鎖只佔用一個資源。
- 嘗試使用定時鎖,使用tryLock(timeout)來替代使用內部鎖機制。
- 對於資料庫鎖,加鎖和解鎖必須在一個數據庫連線裡,否則會出現解鎖失敗。
1.3 資源限制的挑戰
(1)什麼是資源限制?
資源限制是指在進行併發程式設計時,程式的執行速度受限於計算機硬體資源或軟體資源的限制。比如伺服器的頻寬只有2M,某個資源的下載速度是1M每秒,系統啟動十個執行緒下載資源,下載速度不會變成10M每秒,所以在進行併發程式設計時,要考慮到這些資源的限制。硬體資源限制有頻寬的上傳下載速度,硬碟讀寫速度和CPU的處理速度。軟體資源限制有資料庫的連線數和Sorket連線數等。
(2)資源限制引發的問題
併發程式設計將程式碼執行速度加速的原則是將程式碼中序列執行的部分變成併發執行,但是如果某段序列的程式碼併發執行,但是因為受限於資源的限制,仍然在序列執行,這時候程式不僅不會執行加快,反而會更慢,因為增加了上下文切換和資源排程的時間。例如,之前看到一段程式使用多執行緒在辦公網併發的下載和處理資料時,導致CPU利用率100%,任務幾個小時都不能執行完成,後來修改成單執行緒,一個小時就執行完成了。
(3)如何解決資源限制的問題?
對於硬體資源限制,可以考慮使用叢集並行執行程式,既然單機的資源有限制,那麼就讓程式在多機上執行,比如使用ODPS,hadoop或者自己搭建伺服器叢集,不同的機器處理不同的資料,比如將資料ID%機器數,得到一個機器編號,然後由對應編號的機器處理這筆資料。
對於軟體資源限制,可以考慮使用資源池將資源複用,比如使用連線池將資料庫和Sorket連線複用,或者呼叫對方webservice介面獲取資料時,只建立一個連線。
(4)在資源限制情況下進行併發程式設計
那麼如何在資源限制的情況下,讓程式執行的更快呢?根據不同的資源限制調整程式的併發度,比如下載檔案程式依賴於兩個資源,頻寬和硬碟讀寫速度。有資料庫操作時,要資料庫連線數,如果SQL語句執行非常快,而執行緒的數量比資料庫連線數大很多,則某些執行緒會被阻塞住,等待資料庫連線。
1.4 本章小結
本章介紹了在進行併發程式設計的時候,大家可能會遇到的幾個挑戰,並給出了一些解決建議。有的併發程式寫的不嚴謹,在併發下如果出現問題,定位起來會比較耗時和棘手。所以對於Java開發工程師,筆者強烈建議多使用JDK併發包提供的併發容器和工具類來幫你解決併發問題,因為這些類都已經通過了充分的測試和優化,解決了本章提到的幾個挑戰。
[1] Lmbench3是一個性能分析工具。