410
阿裏雲
技術社區[雲棲]
《Java並發編程的藝術》第一章
第1章並發編程的挑戰
並發編程的目的是為了讓程序運行的更快,但是並不是啟動更多的線程,就能讓程序最大限度的並發執行。在進行並發編程時,如果希望通過多線程執行任務讓程序運行的更快,會麵臨非常多的挑戰,比如上下文切換的問題,死鎖的問題,以及受限於硬件和軟件的資源限製問題,本章會介紹幾種並發編程的挑戰,以及解決方案。
1.1 上下文切換
即使是單核處理器也支持多線程執行代碼,CPU通過給每個線程分配CPU時間片來實現這個機製。時間片是CPU分配給各個線程的時間,因為時間片非常短,所以CPU通過不停的切換線程執行,讓我們感覺多個線程是同時執行的,時間片一般是幾十毫秒(ms)。
CPU通過時間片分配算法來循環執行任務,當前任務執行一個時間片後會切換到下個任務,但是在切換前會保存上一個任務的狀態,以便下次切換回這個任務時,可以再加載這個任務的狀態。所以任務的保存到再加載的過程就是一次上下文切換。
就像我們同時在讀兩本書,比如當我們在讀一本英文的技術書時,發現某個單詞不認識,於是便打開中英文字典,但是在放下英文技術書之前,大腦必需首先記住這本書讀到了多少頁的第多少行,等查完單詞之後,能夠繼續讀這本書,這樣的切換是會影響讀書效率的,同樣上下文切換也會影響到多線程的執行速度。
1.1.1 多線程一定快嗎?
下麵的代碼演示串行和並發執行累加操作的時間,請思考下麵的代碼並發執行一定比串行執行快些嗎?
05 |
* @author tengfei.fangtf
|
06 |
* @version $Id: ConcurrencyTest.java, v 0.1 2014-7-18 下午10:03:31 tengfei.fangtf Exp $
|
08 |
public class ConcurrencyTest {
|
11 |
private static final long count = 10000l;
|
13 |
public static void main(String[] args) throws InterruptedException {
|
20 |
private static void concurrency() throws InterruptedException {
|
21 |
long start = System.currentTimeMillis();
|
22 |
Thread thread = new Thread( new Runnable() {
|
26 |
for ( long i = 0 ; i < count; i++) {
|
29 |
System.out.println(a);
|
34 |
for ( long i = 0 ; i < count; i++) {
|
37 |
long time = System.currentTimeMillis() - start;
|
39 |
System.out.println( "concurrency :" + time + "ms,b=" + b);
|
42 |
private static void serial() {
|
43 |
long start = System.currentTimeMillis();
|
45 |
for ( long i = 0 ; i < count; i++) {
|
49 |
for ( long i = 0 ; i < count; i++) {
|
52 |
long time = System.currentTimeMillis() - start;
|
53 |
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測量上下文切換次數的示例。
03 |
procs -----------memory---------- ---swap-- -----io---- --system-- -----cpu----- |
05 |
r b swpd free buff cache si so bi bo in cs us sy id wa st |
07 |
0 0 0 127876 398928 2297092 0 0 0 4 2 2 0 0 99 0 0 |
09 |
0 0 0 127868 398928 2297092 0 0 0 0 595 1171 0 1 99 0 0 |
11 |
0 0 0 127868 398928 2297092 0 0 0 0 590 1180 1 0 100 0 0 |
13 |
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進程裏的線程都在做什麼。
1 |
sudo -u admin /opt/ifeve/java/bin/jstack 31177 > /home/tengfei.fangtf/dump17 |
第二步:統計下所有線程分別處於什麼狀態,發現300多個線程處於WAITING(onobjectmonitor)狀態。
1 |
[tengfei.fangtf@ifeve ~]$ grep java.lang.Thread.State dump17 | awk '{print $2$3$4$5}' | sort | uniq -c |
3 |
21 TIMED_WAITING(onobjectmonitor) |
4 |
6 TIMED_WAITING(parking) |
5 |
51 TIMED_WAITING(sleeping) |
6 |
305 WAITING(onobjectmonitor) |
第三步:打開dump文件查看處於WAITING(onobjectmonitor)的線程在做什麼。發現這些線程基本全是JBOSS的工作線程在await。說明JBOSS線程池裏線程接收到的任務太少,大量線程都閑著。
1 |
"http-0.0.0.0-7001-97" daemon prio=10 tid=0x000000004f6a8000 nid=0x555e in Object.wait() [0x0000000052423000] |
2 |
java.lang.Thread.State: WAITING (on object monitor)
|
3 |
at java.lang.Object.wait(Native Method)
|
4 |
- waiting on <0x00000007969b2280> (a org.apache.tomcat.util.net.AprEndpoint$Worker)
|
5 |
at java.lang.Object.wait(Object.java:485)
|
6 |
at org.apache.tomcat.util.net.AprEndpoint$Worker.await(AprEndpoint.java:1464)
|
7 |
- locked <0x00000007969b2280> (a org.apache.tomcat.util.net.AprEndpoint$Worker)
|
8 |
at org.apache.tomcat.util.net.AprEndpoint$Worker.run(AprEndpoint.java:1489)
|
9 |
at java.lang.Thread.run(Thread.java:662)
|
第四步:減少JBOSS的工作線程數,找到JBOSS的線程池配置信息,將maxThreads降低到100。
1 |
<maxThreads="250" maxHttpHeaderSize="8192" |
2 |
emptySessionPath="false" minSpareThreads="40" maxSpareThreads="75" maxPostSize="512000" protocol="HTTP/1.1" |
3 |
enableLookups="false" redirectPort="8443" acceptCount="200" bufferSize="16384" |
4 |
connectionTimeout="15000" disableUploadTimeout="false" useBodyEncodingForURI="true"> |
第五步:重啟JBOSS,再dump線程信息,然後再統計WAITING(onobjectmonitor)的線程,發現減少了175。WAITING的線程少了,係統上下文切換的次數就會少,因為從WAITTING到RUNNABLE會進行一次上下文的切換。讀者也可以使用vmstat命令測試下。
1 |
[tengfei.fangtf@ifeve ~]$ grep java.lang.Thread.State dump17 | awk '{print $2$3$4$5}' | sort | uniq -c |
3 |
22 TIMED_WAITING(onobjectmonitor) |
4 |
9 TIMED_WAITING(parking) |
5 |
36 TIMED_WAITING(sleeping) |
6 |
130 WAITING(onobjectmonitor) |
1.2 死鎖
鎖是個非常有用的工具,運用場景非常多,因為其使用起來非常簡單,而且易於理解。但同時它也會帶來一些困擾,那就是可能會引起死鎖,一旦產生死鎖,會造成係統功能不可用。讓我們先來看一段代碼,這段代碼會引起死鎖,線程t1和t2互相等待對方釋放鎖。
06 |
* @author tengfei.fangtf
|
07 |
* @version $Id: DeadLockDemo.java, v 0.1 2015-7-18 下午10:08:28 tengfei.fangtf Exp $
|
09 |
public class DeadLockDemo {
|
12 |
private static String A = "A" ;
|
14 |
private static String B = "B" ;
|
16 |
public static void main(String[] args) {
|
17 |
new DeadLockDemo().deadLock();
|
20 |
private void deadLock() {
|
21 |
Thread t1 = new Thread( new Runnable() {
|
27 |
} catch (InterruptedException e) {
|
31 |
System.out.println( "1" );
|
37 |
Thread t2 = new Thread( new Runnable() {
|
42 |
System.out.println( "2" );
|
這段代碼隻是演示死鎖的場景,在現實中你可能很難會寫出這樣的代碼。但是一些更為複雜的場景中你可能會遇到這樣的問題,比如t1拿到鎖之後,因為一些異常情況沒有釋放鎖,比如死循環。又或者是t1拿到一個數據庫鎖,釋放鎖的時候拋了異常,沒釋放掉。
一旦出現死鎖,業務是可感知的,因為不能繼續提供服務了,那麼隻能通過dump線程看看到底是哪個線程出現了問題,以下線程信息告訴我們是DeadLockDemo類的42行和31號引起的死鎖:
01 |
"Thread-2" prio= 5 tid=7fc0458d1000 nid= 0x116c1c000 waiting for monitor entry [116c1b000]
|
02 |
java.lang.Thread.State: BLOCKED (on object monitor)
|
03 |
at com.ifeve.book.forkjoin.DeadLockDemo$ 2 .run(DeadLockDemo.java: 42 )
|
04 |
- waiting to lock <7fb2f3ec0> (a java.lang.String)
|
05 |
- locked <7fb2f3ef8> (a java.lang.String)
|
06 |
at java.lang.Thread.run(Thread.java: 695 )
|
08 |
"Thread-1" prio= 5 tid=7fc0430f6800 nid= 0x116b19000 waiting for monitor entry [116b18000]
|
09 |
java.lang.Thread.State: BLOCKED (on object monitor)
|
10 |
at com.ifeve.book.forkjoin.DeadLockDemo$ 1 .run(DeadLockDemo.java: 31 )
|
11 |
- waiting to lock <7fb2f3ef8> (a java.lang.String)
|
12 |
- locked <7fb2f3ec0> (a java.lang.String)
|
13 |
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是一個性能分析工具。
最後更新:2017-05-22 15:33:03