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

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

  多執行緒技術可以更好地利用系統資源,減少使用者的響應時間,提高系統的效能和效率,但同時也增加了系統的複雜性和運維難度,特別是在高併發、大壓力、高可靠性的專案中。執行緒資源的同步、搶佔、互斥都需要慎重考慮,以避免產生效能損耗和執行緒死鎖。

建議118:不推薦覆寫start方法

  多執行緒比較簡單的實現方式是繼承Thread類,然後覆寫run方法,在客戶端程式中通過呼叫物件的start方法即可啟動一個執行緒,這是多執行緒程式的標準寫法。不知道大家能夠還能回想起自己寫的第一個多執行緒的demo呢?估計一般是這樣寫的:

class MultiThread extends Thread{
    @Override
    public synchronized void start() {
        //呼叫執行緒體
run();
} @Override public void run() { //MultiThread do someThing } }

覆寫run方法,這好辦,寫上自己的業務邏輯即可,但為什麼要覆寫start方法呢?最常見的理由是:要在客戶端呼叫start方法啟動執行緒,不覆寫start方法怎麼啟動run方法呢?於是乎就覆寫了start方法,在方法內呼叫run方法。客戶端程式碼是一個標準程式,程式碼如下 

public static void main(String[] args) {
        //多執行緒物件
        MultiThread m = new MultiThread();
        //啟動多執行緒
        m.start();
    }

  相信大家都能看出,這是一個錯誤的多執行緒應用,main方法根本就沒有啟動一個子執行緒,整個應用程式中只有一個主執行緒在執行,並不會建立任何其它的執行緒。對此,有很簡單的解決辦法。只要刪除MultiThread類的start方法即可。

  然後呢?就結束了嗎?是的,很多時候確實到此結束了。那為什麼不必而且不能覆寫start方法,僅僅就是因為" 多執行緒應用就是這樣寫的 " 這個原因嗎?

  要說明這個問題,就需要看一下Thread類的原始碼了。Thread類的start方法的程式碼(這個是JDK7版本的)如下: 

public synchronized void start() {
        // 判斷執行緒狀態,必須是為啟動狀態
        /**
         * This method is not invoked for the main method thread or "system"
         * group threads created/set up by the VM. Any new functionality added
         * to this method in the future may have to also be added to the VM.
         *
         * A zero status value corresponds to state "NEW".
         */
        if (threadStatus != 0)
            throw new IllegalThreadStateException();
        // 加入執行緒組中
        /*
         * Notify the group that this thread is about to be started so that it
         * can be added to the group's list of threads and the group's unstarted
         * count can be decremented.
         */
        group.add(this);
        boolean started = false;
        try {
            // 分配棧記憶體,啟動執行緒,執行run方法
            start0();
            started = true;
        } finally {
            try {
                if (!started) {
                    group.threadStartFailed(this);
                }
            } catch (Throwable ignore) {
                /*
                 * do nothing. If start0 threw a Throwable then it will be
                 * passed up the call stack
                 */
            }
        }
    }
   // 本地方法
private native void start0();

  這裡的關鍵是本地方法start0,它實現了啟動執行緒、申請棧記憶體、執行run方法、修改執行緒狀態等職責,執行緒管理和棧記憶體管理都是由JVM負責的,如果覆蓋了start方法,也就是撤銷了執行緒管理和棧記憶體管理的能力,這樣如何啟動一個執行緒呢?事實上,不需要關注執行緒和棧記憶體的管理,主需要編碼者實現多執行緒的邏輯即可(即run方法體),這也是JVM比較聰明的地方,簡化多執行緒應用。

  那可能有人要問了:如果確實有必要覆寫start方法,那該如何處理呢?這確實是一個罕見的要求,不過覆寫也容易,只要在start方法中加上super.start()即可,程式碼如下:

class MultiThread extends Thread {
    @Override
    public synchronized void start() {
        /* 執行緒啟動前的業務處理 */
        super.start();
        /* 執行緒啟動後的業務處理 */
    }

    @Override
    public void run() {
        // MultiThread do someThing
    }

}

  注意看start方法,呼叫了父類的start方法,沒有主動呼叫run方法,這是由JVM自行呼叫的,不用我們顯示實現,而且是一定不能實現。此方式雖然解決了" 覆寫start方法 "的問題,但是基本上無用武之地,到目前為止還沒有發現一定要覆寫start方法的多執行緒應用,所以要求覆寫start的場景。都可以使用其他的方式實現,例如類變數、事件機制、監聽等方式。

注意:繼承自Thread類的多執行緒類不必覆寫start方法。

建議119:啟動執行緒前stop方法是不可靠的

  有這樣一個案例,我們需要一個高效率的垃圾郵件製造機,也就是有儘可能多的執行緒來儘可能多的製造垃圾郵件,垃圾郵件重要的資訊儲存在資料庫中,如收件地址、混淆後的標題、反應垃圾處理後的內容等,垃圾製造機的作用就是從資料庫中讀取這些資訊,判斷是否符合條件(如收件地址必須包含@符號、標題不能為空等),然後轉換成一份真實的郵件發出去。

  整個應用邏輯很簡單,這必然是一個多執行緒應用,垃圾郵件製造機需要繼承Thread類,程式碼如下:

//垃圾郵件製造機
class SpamMachine extends Thread{
    @Override
    public void run() {
        //製造垃圾郵件
        System.out.println("製造大量垃圾郵件......");
    }
}

  在客戶端程式碼中需要發揮計算機的最大潛能來製造郵件,也就是說開儘可能多的執行緒,這裡我們使用一個while迴圈來處理,程式碼如下:

public static void main(String[] args) {
        //不分晝夜的製造垃圾郵件
        while(true){
            //多執行緒多個垃圾郵件製造機
            SpamMachine sm = new SpamMachine();
            //xx條件判斷,不符合提交就設定該執行緒不可執行
            if(!false){
                sm.stop();
            }
            //如果執行緒是stop狀態,則不會啟動
            sm.start();
        }
    }

  在此段程式碼中,設定了一個極端條件:所有的執行緒在啟動前都執行stop方法,雖然它是一個過時的方法,但它的執行邏輯還是正常的,況且stop方法在此處的目的並不是停止一個執行緒,而是設定執行緒為不可啟用狀態。想來這應該是沒有問題的,但是執行結果卻出現了奇怪的現象:部分執行緒還是啟動了,也就是在某些執行緒(沒有規律)中的start方法正常執行了。在不符合判斷規則的情況下,不可啟用狀態的執行緒還是啟用了。這是為什麼呢?

  這是執行緒啟動start方法的一個缺陷。Thread類的stop方法會根據執行緒狀態來判斷是終結執行緒還是設定執行緒為不可執行狀態,對於未啟動的執行緒(執行緒狀態為NEW)來說,會設定其標誌位為不可啟動,而其他的狀態則是直接停止。stop方法的JDK1.6原始碼(JDk1.6以上原始碼於此可能有變化,需要重新觀察原始碼)如下:  

   @Deprecated
    public final void stop() {
        // If the thread is already dead, return.
    // A zero status value corresponds to "NEW".
    if ((threadStatus != 0) && !isAlive()) {
        return;
    }
    stop1(new ThreadDeath());
    }
 private final synchronized void stop1(Throwable th) {
    SecurityManager security = System.getSecurityManager();
    if (security != null) {
        checkAccess();
        if ((this != Thread.currentThread()) ||
        (!(th instanceof ThreadDeath))) {
        security.checkPermission(SecurityConstants.STOP_THREAD_PERMISSION);
        }
    }
        // A zero status value corresponds to "NEW"
    if (threadStatus != 0) {
        resume(); // Wake up thread if it was suspended; no-op otherwise
        stop0(th);
    } else {

            // Must do the null arg check that the VM would do with stop0
        if (th == null) {
         throw new NullPointerException();
        }

            // Remember this stop attempt for if/when start is used
        stopBeforeStart = true;
        throwableFromStop = th;
        }
    }

  這裡設定了stopBeforeStart變數,標誌著是在啟動前設定了停止標誌,在start方法中(JDK6原始碼)是這樣校驗的:  

public synchronized void start() {
        /**
     * This method is not invoked for the main method thread or "system"
     * group threads created/set up by the VM. Any new functionality added 
     * to this method in the future may have to also be added to the VM.
     *
     * A zero status value corresponds to state "NEW".
         */
        if (threadStatus != 0)
            throw new IllegalThreadStateException();
        group.add(this);
        start0();
// 在啟動前設定了停止狀態
if (stopBeforeStart) { stop0(throwableFromStop); } } private native void start0();

  注意看start0方法和stop0方法的順序,start0方法在前,也就說既是stopBeforeStart為true(不可啟動),也會啟動一個執行緒,然後再stop0結束這個執行緒,而罪魁禍首就在這裡!

  明白了原因,我們的情景程式碼就很容易修改了,程式碼如下:

public static void main(String[] args) {
        // 不分晝夜的製造垃圾郵件
        while (true) {
            // 多執行緒多個垃圾郵件製造機
            SpamMachine sm = new SpamMachine();
            // xx條件判斷,不符合提交就設定該執行緒不可執行
            if (!false) {
                new SpamMachine().start();
            }
        }
    }

  不再使用stop方法進行狀態的設定,直接通過判斷條件來決定執行緒是否可啟用。對於start方法的缺陷,一般不會引起太大的問題,只是增加了執行緒啟動和停止的精度而已。

建議120:不使用stop方法停止執行緒

  執行緒啟動完畢後,在執行時可能需要中止,Java提供的終止方法只有一個stop,但是我不建議使用這個方法,因為它有以下三個問題:

(1)、stop方法是過時的:從Java編碼規則來說,已經過時的方法不建議採用。

(2)、stop方法會導致程式碼邏輯不完整:stop方法是一種" 惡意 " 的中斷,一旦執行stop方法,即終止當前正在執行的執行緒,不管執行緒邏輯是否完整,這是非常危險的。看如下的程式碼:

public static void main(String[] args) {
        Thread thread = new Thread() {
            @Override
            public void run() {
                try {
                    // 子執行緒休眠1秒
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    // 異常處理
                }
                System.out.println("此處是業務邏輯,永遠不會執行");
            }
        };
        // 啟動執行緒
        thread.start();
        // 主執行緒休眠0.1秒
        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        // 子執行緒停止
        thread.stop();
    }

  這段程式碼的邏輯是這樣的:子執行緒是一個匿名內部類,它的run方法在執行時會休眠一秒,然後執行後續的邏輯,而主執行緒則是休眠0.1秒後終止子執行緒的執行,也就說JVM在執行tread.stop()時,子執行緒還在執行sleep(1000),此時stop方法會清除棧內資訊,結束該執行緒,這也就導致了run方法的邏輯不完整,輸出語句println代表的是一段邏輯,可能非常重要,比如子執行緒的主邏輯、資源回收、情景初始化等,但是因為stop執行緒了,這些都不再執行,於是就產生了業務邏輯不完整的情況。

  這是極度危險的,因為我們不知道子執行緒會在什麼時候被終止,stop連基本的邏輯完整性都無法保證。而且此種操作也是非常隱蔽的,子執行緒執行到何處會被關閉很難定位,這位以後的維護帶來了很多麻煩。

(3)、stop方法會破壞原子邏輯

  多執行緒為了解決共享資源搶佔的問題,使用了鎖概念,避免資源不同步,但是正因為此,stop方法卻會帶來更大的麻煩,它會丟棄所有的鎖,導致原子邏輯受損。例如有這樣一段程式:

class MultiThread implements Runnable {
    int a = 0;
    @Override
    public void run() {
        // 同步程式碼塊,保證原子操作
        synchronized ("") {
            // 自增
            a++;
            try {
                //執行緒休眠0.1秒
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            // 自減
            a--;
            String tn = Thread.currentThread().getName();
            System.out.println(tn + ":a = " + a);
        }
    }

}

  MultiThread實現了Runnable介面,具備多執行緒能力,其中run方法中加上了synchronized程式碼塊,表示內部是原子邏輯,它會先自增然後自減,按照synchronized同步程式碼塊的規則來處理,此時無論啟動多少執行緒,列印出來的結果應該是a=0,但是如果有一個正在執行的執行緒被stop,就會破壞這種原子邏輯,程式碼如下:  

    public static void main(String[] args) {
        MultiThread t = new MultiThread();
        Thread t1 = new Thread(t);
        // 啟動t1執行緒
        t1.start();
        for (int i = 0; i < 5; i++) {
            new Thread(t).start();
        }
        //停止t1執行緒
        t1.stop();
    }

  首先說明的是所有執行緒共享了一個MultiThread的例項變數t,其次由於在run方法中加入了同步程式碼塊,所以只能有一個執行緒進入到synchronized塊中。這段程式碼的執行順序如下:

  1. 執行緒t1啟動,並執行run方法,由於沒有其它執行緒同步程式碼塊的鎖,所以t1執行緒執行後自加後執行到sleep方法即開始休眠,此時a=1
  2. JVM又啟動了5個執行緒,也同時執行run方法,由於synchronized關鍵字的阻塞作用,這5個執行緒不能執行自增和自減操作,等待t1執行緒鎖釋放。
  3. 主執行緒執行了t1.stop方法,終止了t1執行緒,注意,由於a變數是所有執行緒共享的,所以其它5個執行緒獲得的a變數也是1
  4. 其它5個執行緒依次獲得CPU執行機會,列印出a值

  分析了這麼多,相信大家也明白了輸出結果,結果如下:

    Thread-5:a = 1
    Thread-4:a = 1
    Thread-3:a = 1
    Thread-2:a = 1
    Thread-1:a = 1

  原本期望synchronized同步程式碼塊中的邏輯都是原子邏輯,不受外界執行緒的干擾,但是結果卻出現原子邏輯被破壞的情況,這也是stop方法被廢棄的一個重要原因:破壞了原子邏輯。

  既然終止一個執行緒不能使用stop方法,那怎樣才能終止一個正在執行的執行緒呢?答案也簡單,使用自定義的標誌位決定執行緒的執行情況,程式碼如下:

class SafeStopThread extends Thread {
    // 此變數必須加上volatile
    /*
     * volatile: 1.作為指令關鍵字,確保本條指令不會因編譯器的優化而省略,且要求每次直接讀值.
     * 2.被設計用來修飾被不同執行緒訪問和修改的變數。如果不加入volatile
     * ,基本上會導致這樣的結果:要麼無法編寫多執行緒程式,要麼編譯器失去大量優化的機會。
     */
    private volatile boolean stop = false;

    @Override
    public void run() {
        // 判斷執行緒體是否執行
        while (stop) {
            // doSomething
        }
    }

    public void terminate() {
        stop = true;
    }
}

  這是很簡單的辦法,線上程體中判斷是否需要停止執行,即可保證執行緒體的邏輯完整性,而且也不會破壞原子邏輯。可能大家對JavaAPI比較熟悉,於是提出疑問:Thread不是還提供了interrupt中斷執行緒的方法嗎?這個方法可不是過時方法,那可以使用嗎?它可以終止一個執行緒嗎?

  interrupt,名字看上去很像是終止一個執行緒的方法,但它不能終止一個正在執行著的執行緒,它只是修改中斷標誌而已,例如下面一段程式碼:

    public static void main(String[] args) {
        Thread thread = new Thread() {
            @Override
            public void run() {
                // 執行緒一直執行
                while (true) {
                    System.out.println("Running......");
                }
            }
        };
        // 啟動執行緒
        thread.start();
        // 中斷執行緒
        thread.interrupt();
    }

  執行這段程式碼,你會發現一直有Running在輸出,永遠不會停止,似乎執行了interrupt沒有任何變化,那是因為interrupt方法不能終止一個執行緒狀態,它只會改變中斷標誌位(如果在thread.interrupt()前後輸出thread.isInterrupted()則會發現分別輸出了false和true),如果需要終止該執行緒,還需要自己進行判斷,例如我們可以使用interrupt編寫出更簡潔、安全的終止執行緒程式碼:

class SafeStopThread extends Thread {
    @Override
    public void run() {
        //判斷執行緒體是否執行
        while (!isInterrupted()) {
            // do SomeThing
        }
    }
}

   總之,如果期望終止一個正在執行的執行緒,則不能使用已過時的stop方法。需要自行編碼實現,如此即可保證原子邏輯不被破壞,程式碼邏輯不會出現異常。當然,如果我們使用的是執行緒池(比如ThreadPoolExecutor類),那麼可以通過shutdown方法逐步關閉池中的執行緒,它採用的是比較溫和、安全的關閉執行緒方法,完全不會產生類似stop方法的弊端。

建議121:執行緒優先順序只使用三個等級

  執行緒的優先順序(Priority)決定了執行緒獲得CPU執行的機會,優先順序越高獲得的執行機會越大,優先順序越低獲得的機會越小。Java的執行緒有10個級別(準確的說是11個級別,級別為0的執行緒是JVM的,應用程式不能設定該級別),那是不是說級別是10的執行緒肯定比級別是9的執行緒先執行呢?我們來看如下一個多執行緒類:

class TestThread implements Runnable {
    public void start(int _priority) {
        Thread t = new Thread(this);
        // 設定優先順序別
        t.setPriority(_priority);
        t.start();
    }
    @Override
    public void run() {
        // 消耗CPU的計算
        for (int i = 0; i < 100000; i++) {
            Math.hypot(924526789, Math.cos(i));
        }
        // 輸出執行緒優先順序
        System.out.println("Priority:" + Thread.currentThread().getPriority());
    }
}

  該多執行緒實現了Runnable介面,實現了run方法,注意在run方法中有一個比較佔用CPU的計算,該計算毫無意義,

public static void main(String[] args) {
        //啟動20個不同優先順序的執行緒
        for (int i = 0; i < 20; i++) {
            new TestThread().start(i % 10 + 1);
        }
    }

 這裡建立了20個執行緒,每個執行緒在執行時都耗盡了CPU的資源,因為優先順序不同,執行緒排程應該是先處理優先順序高的,然後處理優先順序低的,也就是先執行2個優先順序為10的執行緒,然後執行2個優先順序為9的執行緒,2個優先順序為8的執行緒......但是結果卻並不是這樣的。

  Priority:5
  Priority:7
  Priority:10
  Priority:6
  Priority:9
  Priority:6
  Priority:5
  Priority:7
  Priority:10
  Priority:3
  Priority:4
  Priority:8
  Priority:8
  Priority:9
  Priority:4
  Priority:1
  Priority:3
  Priority:1
  Priority:2
  Priority:2

  println方法雖然有輸出損耗,可能會影響到輸出結果,但是不管執行多少次,你都會發現兩個不爭的事實:

(1)、並不是嚴格按照執行緒優先順序來執行的

  比如執行緒優先順序為5的執行緒比優先順序為7的執行緒先執行,優先順序為1的執行緒比優先順序為2的執行緒先執行,很少出現優先順序為2的執行緒比優先順序為10的執行緒先執行(注意,這裡是" 很少 ",是說確實有可能出現,只是機率低,因為優先順序只是表示執行緒獲得CPU執行的機會,並不代表強制的排序號)。

(2)、優先順序差別越大,執行機會差別越明顯

  比如優先順序為10的執行緒通常會比優先順序為2的執行緒先執行,但是優先順序為6的執行緒和優先順序為5的執行緒差別就不太明顯了,執行多次,你會發現有不同的順序。

  這兩個現象是執行緒優先順序的一個重要表現,之所以會出現這種情況,是因為執行緒執行是需要獲得CPU資源的,那誰能決定哪個執行緒先獲得哪個執行緒後獲得呢?這是依照作業系統設定的執行緒優先順序來分配的,也就是說,每個執行緒要執行,需要作業系統分配優先順序和CPU資源,對於JAVA來說,JVM呼叫作業系統的介面設定優先順序,比如windows作業系統優先順序都相同嗎?

  事實上,不同的作業系統執行緒優先順序是不同的,Windows有7個優先順序,Linux有140個優先順序,Freebsd則由255個(此處指的優先順序個數,不同作業系統有不同的分類,如中斷級執行緒,作業系統級等,各個作業系統具體使用者可用的執行緒數量也不相同)。Java是跨平臺的系統,需要把這10個優先順序對映成不同的作業系統的優先順序,於是界定了Java的優先順序只是代表搶佔CPU的機會大小,優先順序越高,搶佔CPU的機會越大,被優先執行的可能性越高,優先順序相差不大,則搶佔CPU的機會差別也不大,這就是導致了優先順序為9的執行緒可能比優先順序為10的執行緒先執行。

  Java的締造者們也覺察到了執行緒優先問題,於是Thread類中設定了三個優先順序,此意就是告訴開發者,建議使用優先順序常量,而不是1到10的隨機數字。常量程式碼如下: 

public class Thread implements Runnable {
    /**
     * The minimum priority that a thread can have. 
     */
    public final static int MIN_PRIORITY = 1;
    /**
     * The default priority that is assigned to a thread. 
     */
    public final static int NORM_PRIORITY = 5;
    /**
     * The maximum priority that a thread can have. 
     */
    public final static int MAX_PRIORITY = 10;


}

  在編碼時直接使用這些優先順序常量,可以說在大部分情況下MAX_PRIORITY的執行緒回比MIN_PRIORITY的執行緒優先執行,但是不能認為是必然會先執行,不能把這個優先順序做為核心業務的必然條件,Java無法保證優先順序高肯定會先執行,只能保證高優先順序有更多的執行機會。因此,建議在開發時只使用此三類優先順序,沒有必要使用其他7個數字,這樣也可以保證在不同的作業系統上優先順序的表現基本相同。

  大家也許會問,如果優先順序相同呢?這很好辦,也是由作業系統決定的。基本上是按照FIFO原則(先入先出,First Input First Output),但也是不能完全保證。

相關文章