天天看點

帶你讀《Java并發程式設計的藝術》之一:并發程式設計的挑戰第1章

Java核心技術系列 點選這裡檢視第二章 點選這裡檢視第三章

Java并發程式設計的藝術

方騰飛 魏鵬 程曉明 著

北京:機械工業出版社,2015.7

帶你讀《Java并發程式設計的藝術》之一:并發程式設計的挑戰第1章

第1章

并發程式設計的挑戰

并發程式設計的目的是為了讓程式運作得更快,但是,并不是啟動更多的線程就能讓程式最大限度地并發執行。在進行并發程式設計時,如果希望通過多線程執行任務讓程式運作得更快,會面臨非常多的挑戰,比如上下文切換的問題、死鎖的問題,以及受限于硬體和軟體的資源限制問題,本章會介紹幾種并發程式設計的挑戰以及解決方案。

1.1 上下文切換

即使是單核處理器也支援多線程執行代碼,CPU通過給每個線程配置設定CPU時間片來實作這個機制。時間片是CPU配置設定給各個線程的時間,因為時間片非常短,是以CPU通過不停地切換線程執行,讓我們感覺多個線程是同時執行的,時間片一般是幾十毫秒(ms)。

CPU通過時間片配置設定算法來循環執行任務,目前任務執行一個時間片後會切換到下一個任務。但是,在切換前會儲存上一個任務的狀态,以便下次切換回這個任務時,可以再加載這個任務的狀态。是以任務從儲存到再加載的過程就是一次上下文切換。

這就像我們同時讀兩本書,當我們在讀一本英文的技術書時,發現某個單詞不認識,于是便打開中英文字典,但是在放下英文技術書之前,大腦必須先記住這本書讀到了多少頁的第多少行,等查完單詞之後,能夠繼續讀這本書。這樣的切換是會影響讀書效率的,同樣上下文切換也會影響多線程的執行速度。

1.1.1 多線程一定快嗎

下面的代碼示範串行和并發執行并累加操作的時間,請分析:下面的代碼并發執行一定比串行執行快嗎?

public class ConcurrencyTest {

private static f?inal 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;
                            }
                    }
            });
            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所示。

帶你讀《Java并發程式設計的藝術》之一:并發程式設計的挑戰第1章

從表1-1可以發現,當并發執行累加操作不超過百萬次時,速度會比串行執行累加操作要慢。那麼,為什麼并發執行的速度會比串行慢呢?這是因為線程有建立和上下文切換的開銷。

1.1.2 測試上下文切換次數和時長

下面我們來看看有什麼工具可以度量上下文切換帶來的消耗。

使用Lmbench3可以測量上下文切換的時長。

使用vmstat可以測量上下文切換的次數。

下面是利用vmstat測量上下文切換次數的示例。

帶你讀《Java并發程式設計的藝術》之一:并發程式設計的挑戰第1章

CS(Content Switch)表示上下文切換的次數,從上面的測試結果中我們可以看到,上下文每1秒切換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(onobject-monitor)狀态。

帶你讀《Java并發程式設計的藝術》之一:并發程式設計的挑戰第1章

第三步:打開dump檔案檢視處于WAITING(onobjectmonitor)的線程在做什麼。發現這些線程基本全是JBOSS的工作線程,在await。說明JBOSS線程池裡線程接收到的任務太少,大量線程都閑着。

帶你讀《Java并發程式設計的藝術》之一:并發程式設計的挑戰第1章

第四步:減少JBOSS的工作線程數,找到JBOSS的線程池配置資訊,将maxThreads降到100。

帶你讀《Java并發程式設計的藝術》之一:并發程式設計的挑戰第1章

第五步:重新開機JBOSS,再dump線程資訊,然後統計WAITING(onobjectmonitor)的線程,發現減少了175個。WAITING的線程少了,系統上下文切換的次數就會少,因為每一次從WAITTING到RUNNABLE都會進行一次上下文的切換。讀者也可以使用vmstat指令測試一下。

帶你讀《Java并發程式設計的藝術》之一:并發程式設計的挑戰第1章

1.2 死鎖

鎖是個非常有用的工具,運用場景非常多,因為它使用起來非常簡單,而且易于了解。但同時它也會帶來一些困擾,那就是可能會引起死鎖,一旦産生死鎖,就會造成系統功能不可用。讓我們先來看一段代碼,這段代碼會引起死鎖,使線程t1和線程t2互相等待對方釋

放鎖。

public class DeadLockDemo {

privat static String A = "A";
private static String B = "B";

public static void main(String[] args) {

        new DeadLockDemo().deadLock();
}

private void deadLock() {
        Thread t1 = new Thread(new Runnable() {
                @Override
                publicvoid run() {
                        synchronized (A) {
                                try { Thread.currentThread().sleep(2000);
                                } catch (InterruptedException e) {
                                        e.printStackTrace();
                                }
                                synchronized (B) {
                                        System.out.println("1");
                                }
                        }
                }
        });

        Thread t2 = new Thread(new Runnable() {
                @Override
                publicvoid 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)           

現在我們介紹避免死鎖的幾個常見方法。

避免一個線程同時擷取多個鎖。

避免一個線程在鎖内同時占用多個資源,盡量保證每個鎖隻占用一個資源。

嘗試使用定時鎖,使用lock.tryLock(timeout)來替代使用内部鎖機制。

對于資料庫鎖,加鎖和解鎖必須在一個資料庫連接配接裡,否則會出現解鎖失敗的情況。

1.3 資源限制的挑戰

(1)什麼是資源限制

資源限制是指在進行并發程式設計時,程式的執行速度受限于計算機硬體資源或軟體資源。例如,伺服器的帶寬隻有2Mb/s,某個資源的下載下傳速度是1Mb/s每秒,系統啟動10個線程下載下傳資源,下載下傳速度不會變成10Mb/s,是以在進行并發程式設計時,要考慮這些資源的限制。硬體資源限制有帶寬的上傳/下載下傳速度、硬碟讀寫速度和CPU的處理速度。軟體資源限制有資料庫的連接配接數和socket連接配接數等。

(2)資源限制引發的問題

在并發程式設計中,将代碼執行速度加快的原則是将代碼中串行執行的部分變成并發執行,但是如果将某段串行的代碼并發執行,因為受限于資源,仍然在串行執行,這時候程式不僅不會加快執行,反而會更慢,因為增加了上下文切換和資源排程的時間。例如,之前看到一段程式使用多線程在辦公網并發地下載下傳和處理資料時,導緻CPU使用率達到100%,幾個小時都不能運作完成任務,後來修改成單線程,一個小時就執行完成了。

(3)如何解決資源限制的問題

對于硬體資源限制,可以考慮使用叢集并行執行程式。既然單機的資源有限制,那麼就讓程式在多機上運作。比如使用ODPS、Hadoop或者自己搭建伺服器叢集,不同的機器處理不同的資料。可以通過“資料ID%機器數”,計算得到一個機器編号,然後由對應編号的機器處理這筆資料。

對于軟體資源限制,可以考慮使用資源池将資源複用。比如使用連接配接池将資料庫和Socket連接配接複用,或者在調用對方webservice接口擷取資料時,隻建立一個連接配接。

(4)在資源限制情況下進行并發程式設計

如何在資源限制的情況下,讓程式執行得更快呢?方法就是,根據不同的資源限制調整程式的并發度,比如下載下傳檔案程式依賴于兩個資源——帶寬和硬碟讀寫速度。有資料庫操作時,涉及資料庫連接配接數,如果SQL語句執行非常快,而線程的數量比資料庫連接配接數大很多,則某些線程會被阻塞,等待資料庫連接配接。

1.4 本章小結

本章介紹了在進行并發程式設計時,大家可能會遇到的幾個挑戰,并給出了一些解決建議。有的并發程式寫得不嚴謹,在并發下如果出現問題,定位起來會比較耗時和棘手。是以,對于Java開發工程師而言,筆者強烈建議多使用JDK并發包提供的并發容器和工具類來解決并發問題,因為這些類都已經通過了充分的測試和優化,均可解決了本章提到的幾個

挑戰。