編寫高質量程式碼:改善Java程式的151個建議(第8章:多執行緒和併發___建議122~125)

阿赫瓦里發表於2016-10-18

建議122:使用執行緒異常處理器提升系統可靠性

  我們要編寫一個Socket應用,監聽指定埠,實現資料包的接收和傳送邏輯,這在早期系統間進行資料互動是經常使用的,這類介面通常需要考慮兩個問題:一個是避免執行緒阻塞,保證接收的資料儘快處理;二是:介面的穩定性和可靠性問題,資料包很複雜,介面服務的系統也很多,一旦守候執行緒出現異常就會導致Socket停止,這是非常危險的,那我們有什麼辦法避免嗎?

  Java1.5版本以後在Thread類中增加了setUncaughtExceptionHandler方法,實現了執行緒異常的捕捉和處理。可能大家會有一個疑問:如果Socket應用出現了不可預測的異常是否可以自動重啟呢?其實使用執行緒異常處理器很容易解決,我們來看一個異常處理器應用例項,程式碼如下: 

class TcpServer implements Runnable {
    // 建立後即執行
    public TcpServer() {
        Thread t = new Thread(this);
        t.setUncaughtExceptionHandler(new TcpServerExceptionHandler());
        t.start();
    }
    @Override
    public void run() {
        for (int i = 0; i < 3; i++) {
            try {
                Thread.sleep(1000);
                System.out.println("系統正常執行:" + i);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        // 丟擲異常
        throw new RuntimeException();
    }

    // 異常處理器
    private static class TcpServerExceptionHandler implements
            Thread.UncaughtExceptionHandler {

        @Override
        public void uncaughtException(Thread t, Throwable e) {
            // 記錄執行緒異常資訊
            System.out.println("執行緒" + t.getName() + " 出現異常,自行重啟,請分析原因。");
            e.printStackTrace();
            // 重啟執行緒,保證業務不中斷
            new TcpServer();
        }

    }
}

  這段程式碼的邏輯比較簡單,在TcpServer類建立時即啟動一個執行緒,提供TCP服務,例如接收和傳送檔案,具體邏輯在run方法中實現。同時,設定了該執行緒出現執行期異常(也就是Uncaught Exception)時,由TcpServerExceptionHandler異常處理器來處理異常。那麼TcpServerExceptionHandler做什麼事呢?兩件事:

  • 記錄異常資訊,以便查詢問題
  • 重新啟動一個新執行緒,提供不間斷的服務

  有了這兩點,TcpServer就可以穩定的執行了,即使出現異常也能自動重啟,客戶端程式碼比較簡單,只需要new TcpServer()即可,執行結果如下:

  

  從執行結果上可以看出,當Thread-0出現異常時,系統自動重啟了Thread-1執行緒,繼續提供服務,大大提高了系統的效能。

  這段程式只是一個示例程式,若要在實際環境中應用,則需要注意以下三個方面:

  • 共享資源鎖定:如果執行緒產生異常的原因是資源被鎖定,自動重啟應用知會增加系統的負擔,無法提供不間斷服務。例如一個即時通訊服務(XMPP Server)出現資訊不能寫入的情況,即使再怎麼啟動服務,也是無法解決問題的。在此情況下最好的辦法是停止所有的執行緒,釋放資源。
  • 髒資料引起系統邏輯混亂:異常的產生中斷了正在執行的業務邏輯,特別是如果正在處理一個原子操作(像即時通訊伺服器的使用者驗證和簽到這兩個事件應該在一個操作中處理,不允許出現驗證成功,但簽到不成功的情況),但如果此時丟擲了執行期異常就有可能會破壞正常的業務邏輯,例如出現使用者認證通過了,但簽到不成功的情況,在這種情境下重啟應用伺服器,雖然可以提供服務,但對部分使用者卻產生了邏輯異常。
  • 記憶體溢位:執行緒異常了,但由該執行緒建立的物件並不會馬上回收,如果再重親啟動新執行緒,再建立一批物件,特別是加入了場景接管,就非常危險了,例如即時通訊服務,重新啟動一個新執行緒必須保證原線上使用者的透明性,即使用者不會察覺服務重啟,在此種情況下,就需要線上程初始化時載入大量物件以保證使用者的狀態資訊,但是如果執行緒反覆重啟,很可能會引起OutOfMemory記憶體洩露問題。

建議123:volatile不能保證資料同步

  volatile關鍵字比較少用,原因無外乎兩點,一是在Java1.5之前該關鍵字在不同的作業系統上有不同的表現,所帶來的問題就是移植性較差;而且比較難設計,而且誤用較多,這也導致它的"名譽" 受損。

  我們知道,每個執行緒都執行在棧記憶體中,每個執行緒都有自己的工作記憶體(Working Memory,比如暫存器Register、高速緩衝儲存器Cache等),執行緒的計算一般是通過工作記憶體進行互動的,其示意圖如下圖所示:

  

  從示意圖上我們可以看到,執行緒在初始化時從主記憶體中載入所需的變數值到工作記憶體中,然後線上程執行時,如果是讀取,則直接從工作記憶體中讀取,若是寫入則先寫到工作記憶體中,之後重新整理到主記憶體中,這是JVM的一個簡答的記憶體模型,但是這樣的結構在多執行緒的情況下有可能會出現問題,比如:A執行緒修改變數的值,也重新整理到了主記憶體,但B、C執行緒在此時間內讀取的還是本執行緒的工作記憶體,也就是說它們讀取的不是最"新鮮"的值,此時就出現了不同執行緒持有的公共資源不同步的情況。

  對於此類問題有很多解決辦法,比如使用synchronized同步程式碼塊,或者使用Lock鎖來解決該問題,不過,Java可以使用volatile更簡單地解決此類問題,比如在一個變數前加上volatile關鍵字,可以確保每個執行緒對本地變數的訪問和修改都是直接與記憶體互動的,而不是與本執行緒的工作記憶體互動的,保證每個執行緒都能獲得最"新鮮"的變數值,其示意圖如下:

  

  明白了volatile變數的原理,那我們思考一下:volatile變數是否能夠保證資料的同步性呢?兩個執行緒同時修改一個volatile是否會產生髒資料呢?我們看看下面程式碼:

class UnsafeThread implements Runnable {
    // 共享資源
    private volatile int count = 0;

    @Override
    public void run() {
        // 增加CPU的繁忙程度,不必關心其邏輯含義
        for (int i = 0; i < 1000; i++) {
            Math.hypot(Math.pow(92456789, i), Math.cos(i));
        }
        count++;
    }

    public int getCount() {
        return count;
    }
}

  上面的程式碼定義了一個多執行緒類,run方法的主要邏輯是共享資源count的自加運算,而且我們還為count變數加上了volatile關鍵字,確保是從記憶體中讀取和寫入的,如果有多個執行緒執行,也就是多個執行緒執行count變數的自加操作,count變數會產生髒資料嗎?想想看,我們已經為count加上了volatile關鍵字呀!模擬多執行緒的程式碼如下:  

public static void main(String[] args) throws InterruptedException {
        // 理想值,並作為最大迴圈次數
        int value = 1000;
        // 迴圈次數,防止造成無限迴圈或者死迴圈
        int loops = 0;
        // 主執行緒組,用於估計活動執行緒數
        ThreadGroup tg = Thread.currentThread().getThreadGroup();
        while (loops++ < value) {
            // 共享資源清零
            UnsafeThread ut = new UnsafeThread();
            for (int i = 0; i < value; i++) {
                new Thread(ut).start();
            }
            // 先等15毫秒,等待活動執行緒為1
            do {
                Thread.sleep(15);
            } while (tg.activeCount() != 1);
            // 檢查實際值與理論值是否一致
            if (ut.getCount() != value) {
                // 出現執行緒不安全的情況
                System.out.println("迴圈到:" + loops + " 遍,出現執行緒不安全的情況");
                System.out.println("此時,count= " + ut.getCount());
                System.exit(0);
            }
        }

    }

  想讓volatite變數"出點醜",還是需要花點功夫的。此段程式的執行邏輯如下:

  • 啟動100個執行緒,修改共享資源count的值
  • 暫停15秒,觀察活動執行緒數是否為1(即只剩下主執行緒再執行),若不為1,則再等待15秒。
  • 判斷共享資源是否是不安全的,即實際值與理想值是否相同,若不相同,則發現目標,此時count的值為髒資料。
  • 如果沒有找到,繼續迴圈,直到達到最大迴圈為止。

執行結果如下:

    迴圈到:40 遍,出現執行緒不安全的情況
    此時,count= 999
  這只是一種可能的結果,每次執行都有可能產生不同的結果。這也說明我們的count變數沒有實現資料同步,在多個執行緒修改的情況下,count的實際值與理論值產生了偏差,直接說明了volatile關鍵字並不能保證執行緒的安全。
  在解釋原因之前,我們先說一下自加操作。count++表示的是先取出count的值然後再加1,也就是count=count+1,所以,在某個緊鄰時間片段內會發生如下神奇的事情:

(1)、第一個時間片段

  A執行緒獲得執行機會,因為有關鍵字volatile修飾,所以它從主記憶體中獲得count的最新值為998,接下來的事情又分為兩種型別:

  • 如果是單CPU,此時排程器暫停A執行緒執行,讓出執行機會給B執行緒,於是B執行緒也獲得了count的最新值998.
  • 如果是多CPU,此時執行緒A繼續執行,而執行緒B也同時獲得了count的最新值998.

(2)、第二個片段

  • 如果是單CPU,B執行緒執行完+1操作(這是一個原子處理),count的值為999,由於是volatile型別的變數,所以直接寫入主記憶體,然後A執行緒繼續執行,計算的結果也是999,重新寫入主記憶體中。
  • 如果是多CPU,A執行緒執行完加1動作後修改主記憶體的變數count為999,執行緒B執行完畢後也修改主記憶體中的變數為999

這兩個時間片段執行完畢後,原本期望的結果為1000,單執行後的值為999,這表示出現了執行緒不安全的情況。這也是我們要說明的:volatile關鍵字並不能保證執行緒安全,它只能保證當前執行緒需要該變數的值時能夠獲得最新的值,而不能保證執行緒修改的安全性。

順便說一下,在上面的程式碼中,UnsafeThread類的消耗CPU計算是必須的,其目的是加重執行緒的負荷,以便出現單個執行緒搶佔整個CPU資源的情景,否則很難模擬出volatile執行緒不安全的情況,大家可以自行模擬測試。

建議124:非同步運算考慮使用Callable介面

  多執行緒應用有兩種實現方式,一種是實現Runnable介面,另一種是繼承Thread類,這兩個方法都有缺點:run方法沒有返回值,不能丟擲異常(這兩個缺點歸根到底是Runnable介面的缺陷,Thread類也實現了Runnable介面),如果需要知道一個執行緒的執行結果就需要使用者自行設計,執行緒類本身也不能提供返回值和異常。但是從Java1.5開始引入了一個新的介面Callable,它類似於Runnable介面,實現它就可以實現多執行緒任務,Callable的介面定義如下:

public interface Callable<V> {
    /**
     * Computes a result, or throws an exception if unable to do so.
     *
     * @return computed result
     * @throws Exception if unable to compute a result
     */
    V call() throws Exception;
}

  實現Callable介面的類,只是表明它是一個可呼叫的任務,並不表示它具有多執行緒運算能力,還是需要執行器來執行的,我們先編寫一個任務類,程式碼如下: 

//稅款計算器
class TaxCalculator implements Callable<Integer> {
    // 本金
    private int seedMoney;

    // 接收主執行緒提供的引數
    public TaxCalculator(int _seedMoney) {
        seedMoney = _seedMoney;
    }

    @Override
    public Integer call() throws Exception {
        // 複雜計算,執行一次需要2秒
        TimeUnit.MILLISECONDS.sleep(2000);
        return seedMoney / 10;
    }
}

  這裡模擬了一個複雜運算:稅款計算器,該運算可能要花費10秒鐘的時間,此時不能讓使用者一直等著吧,需要給使用者輸出點什麼,讓使用者知道系統還在執行,這也是系統友好性的體現:使用者輸入即有輸出,若耗時較長,則顯示運算進度。如果我們直接計算,就只有一個main執行緒,是不可能有友好提示的,如果稅金不計算完畢,也不會執行後續動作,所以此時最好的辦法就是重啟一個執行緒來運算,讓main執行緒做進度提示,程式碼如下:  

public static void main(String[] args) throws InterruptedException,
            ExecutionException {
        // 生成一個單執行緒的非同步執行器
        ExecutorService es = Executors.newSingleThreadExecutor();
        // 執行緒執行後的期望值
        Future<Integer> future = es.submit(new TaxCalculator(100));
        while (!future.isDone()) {
            // 還沒有運算完成,等待200毫秒
            TimeUnit.MICROSECONDS.sleep(200);
            // 輸出進度符號
            System.out.print("*");
        }
        System.out.println("\n計算完成,稅金是:" + future.get() + "  元 ");
        es.shutdown();
    }

  在這段程式碼中,Executors是一個靜態工具類,提供了非同步執行器的建立能力,如單執行緒非同步執行器newSingleThreadExecutor、固定執行緒數量的執行器newFixedThreadPool等,一般它是非同步計算的入口類。future關注的是執行緒執行後的結果,比如沒有執行完畢,執行結果是多少等。此段程式碼的執行結果如下所示:

      **********************************************......

      計算完成,稅金是:10  元

  執行時,"*"會依次遞增,表示系統正在運算,為使用者提供了運算進度,此類非同步計算的好處是:

  • 儘可能多的佔用系統資源,提供快速運算
  • 可以監控執行緒的執行情況,比如是否執行完畢、是否有返回值、是否有異常等。
  • 可以為使用者提供更好的支援,比如例子中的運算進度等。

建議125:優先選擇執行緒池

  在Java1.5之前,實現多執行緒比較麻煩,需要自己啟動執行緒,並關注同步資源,防止出現執行緒死鎖等問題,在1.5版本之後引入了平行計算框架,大大簡化了多執行緒開發。我們知道一個執行緒有五個狀態:新建狀態(NEW)、可執行狀態(Runnable,也叫作執行狀態)、阻塞狀態(Blocked)、等待狀態(Waiting)、結束狀態(Terminated),執行緒的狀態只能由新建轉變為了執行狀態後才能被阻塞或等待,最後終結,不可能產生本末倒置的情況,比如把一個結束狀態的執行緒轉變為新建狀態,則會出現異常,例如如下程式碼會丟擲異常:

    public static void main(String[] args) throws InterruptedException {
        // 建立一個執行緒,新建狀態
        Thread t = new Thread(new Runnable() {
            @Override
            public void run() {
                System.out.println("執行緒正在執行");
            }
        });
        // 執行狀態
        t.start();
        // 是否是執行狀態,若不是則等待10毫秒
        while (!t.getState().equals(Thread.State.TERMINATED)) {
            TimeUnit.MICROSECONDS.sleep(10);
        }
        // 直接由結束轉變為雲心態
        t.start();
    }

  此段程式執行時會報java.lang.IllegalThreadStateException異常,原因就是不能從結束狀態直接轉變為執行狀態,我們知道一個執行緒的執行時間分為3部分:T1為執行緒啟動時間,T2為執行緒的執行時間,T3為執行緒銷燬時間,如果一個執行緒不能被重複使用,每次建立一個執行緒都需要經過啟動、執行、銷燬時間,這勢必增大系統的響應時間,有沒有更好的辦法降低執行緒的執行時間呢?

  T2是無法避免的,只有通過優化程式碼來實現降低執行時間。T1和T2都可以通過執行緒池(Thread Pool)來縮減時間,比如在容器(或系統)啟動時,建立足夠多的執行緒,當容器(或系統)需要時直接從執行緒池中獲得執行緒,運算出結果,再把執行緒返回到執行緒池中___ExecutorService就是實現了執行緒池的執行器,我們來看一個示例程式碼: 

public static void main(String[] args) throws InterruptedException {
        // 2個執行緒的執行緒池
        ExecutorService es = Executors.newFixedThreadPool(2);
        // 多次執行執行緒體
        for (int i = 0; i < 4; i++) {
            es.submit(new Runnable() {
                @Override
                public void run() {
                    System.out.println(Thread.currentThread().getName());
                }
            });
        }
        // 關閉執行器
        es.shutdown();
    }

  此段程式碼首先建立了一個包含兩個執行緒的執行緒池,然後線上程池中多次執行執行緒體,輸出執行時的執行緒名稱,結果如下:

        pool-1-thread-1
        pool-1-thread-2
        pool-1-thread-1
        pool-1-thread-2

   本次程式碼執行了4遍執行緒體,按照我們之前闡述的" 一個執行緒不可能從結束狀態轉變為可執行狀態 ",那為什麼此處的2個執行緒可以反覆使用呢?這就是我們要搞清楚的重點。

  執行緒池涉及以下幾個名詞:

  • 工作執行緒(Worker):執行緒池中的執行緒,只有兩個狀態:可執行狀態和等待狀態,沒有任務時它們處於等待狀態,執行時它們迴圈的執行任務。
  • 任務介面(Task):這是每個任務必須實現的介面,以供工作執行緒排程器排程,它主要規定了任務的入口、任務執行完的場景處理,任務的執行狀態等。這裡有兩種型別的任務:具有返回值(異常)的Callable介面任務和無返回值併相容舊版本的Runnable介面任務。
  • 任務對列(Work Quene):也叫作工作佇列,用於存放等待處理的任務,一般是BlockingQuene的實現類,用來實現任務的排隊處理。

  我們首先從執行緒池的建立說起,Executors.newFixedThreadPool(2)表示建立一個具有兩個執行緒的執行緒池,原始碼如下:

public class Executors {
    //生成一個最大為nThreads的執行緒池執行器
  public static ExecutorService newFixedThreadPool(int nThreads) {
        return new ThreadPoolExecutor(nThreads, nThreads,
                                      0L, TimeUnit.MILLISECONDS,
                                      new LinkedBlockingQueue<Runnable>());
    }

}

  這裡使用了LinkedBlockingQueue作為佇列工作管理員,所有等待處理的任務都會放在該對列中,需要注意的是,此佇列是一個阻塞式的單端佇列。執行緒池建立好了,那就需要執行緒在其中執行了,執行緒池中的執行緒是在submit第一次提交任務時建立的,程式碼如下:

   public Future<?> submit(Runnable task) {
        //檢查任務是否為null
        if (task == null) throw new NullPointerException();
        //把Runnable任務包裝成具有返回值的任務物件,不過此時並沒有執行,只是包裝
        RunnableFuture<Object> ftask = newTaskFor(task, null);
        //執行此任務
        execute(ftask);
        //返回任務預期執行結果
        return ftask;
    }

  此處的程式碼關鍵是execute方法,它實現了三個職責。

  • 建立足夠多的工作執行緒數,數量不超過最大執行緒數量,並保持執行緒處於執行或等待狀態。
  • 把等待處理的任務放到任務佇列中
  • 從任務佇列中取出任務來執行

  其中此處的關鍵是工作執行緒的建立,它也是通過new Thread方式建立的一個執行緒,只是它建立的並不是我們的任務執行緒(雖然我們的任務實現了Runnable介面,但它只是起了一個標誌性的作用),而是經過包裝的Worker執行緒,程式碼如下:  

private final class Worker implements Runnable {
// 執行一次任務
    private void runTask(Runnable task) {
        /* 這裡的task才是我們自定義實現Runnable介面的任務 */
        task.run();
        /* 該方法其它程式碼略 */
    }
    // 工作執行緒也是執行緒,必須實現run方法
    public void run() {
        try {
            Runnable task = firstTask;
            firstTask = null;
            while (task != null || (task = getTask()) != null) {
                runTask(task);
                task = null;
            }
        } finally {
            workerDone(this);
        }
    }
    // 任務佇列中獲得任務
    Runnable getTask() {
        /* 其它程式碼略 */
        for (;;) {
            return r = workQueue.take();
        }
    }
}

  此處為示意程式碼,刪除了大量的判斷條件和鎖資源。execute方法是通過Worker類啟動的一個工作執行緒,執行的是我們的第一個任務,然後改執行緒通過getTask方法從任務佇列中獲取任務,之後再繼續執行,但問題是任務佇列是一個BlockingQuene,是阻塞式的,也就是說如果該佇列的元素為0,則保持等待狀態,直到有任務進入為止,我們來看LinkedBlockingQuene的take方法,程式碼如下:  

public E take() throws InterruptedException {
        E x;
        int c = -1;
        final AtomicInteger count = this.count;
        final ReentrantLock takeLock = this.takeLock;
        takeLock.lockInterruptibly();
        try {
            try {
                // 如果佇列中的元素為0,則等待
                while (count.get() == 0)
                    notEmpty.await();
            } catch (InterruptedException ie) {
                notEmpty.signal(); // propagate to a non-interrupted thread
                throw ie;
            }
            // 等待狀態結束,彈出頭元素
            x = extract();
            c = count.getAndDecrement();
            // 如果佇列數量還多於一個,喚醒其它執行緒
            if (c > 1)
                notEmpty.signal();
        } finally {
            takeLock.unlock();
        }
        if (c == capacity)
            signalNotFull();
        // 返回頭元素
        return x;
    }

  分析到這裡,我們就明白了執行緒池的建立過程:建立一個阻塞佇列以容納任務,在第一次執行任務時建立做夠多的執行緒(不超過許可執行緒數),並處理任務,之後每個工作執行緒自行從任務對列中獲得任務,直到任務佇列中的任務數量為0為止,此時,執行緒將處於等待狀態,一旦有任務再加入到佇列中,即召喚醒工作執行緒進行處理,實現執行緒的可複用性。

  使用執行緒池減少的是執行緒的建立和銷燬時間,這對於多執行緒應用來說非常有幫助,比如我們常用的Servlet容器,每次請求處理的都是一個執行緒,如果不採用執行緒池技術,每次請求都會重新建立一個新的執行緒,這會導致系統的效能符合加大,響應效率下降,降低了系統的友好性。

相關文章