1. 程式人生 > >《Effective Java》——學習筆記(異常&併發)

《Effective Java》——學習筆記(異常&併發)

異常

第57條:只針對異常的情況才使用異常

異常應該只用於異常的情況下:它們永遠不應該用於正常的控制流

設計良好的API不應該強迫它的客戶端為了正常的控制流而使用異常

第58條:對可恢復的情況使用受檢異常,對程式設計錯誤使用執行時異常

Java程式設計語言提供了三種可丟擲結構:受檢的異常、執行時異常和錯誤

如果期望呼叫者能夠適當地恢復,對於這種情況就應該使用受檢的異常,通過丟擲受檢的異常,強迫呼叫者在一個catch子句中處理該異常,或者將它傳播出去。因此,方法中宣告要丟擲的每個受檢的異常,都是對API使用者的一種潛在指示:與異常相關聯的條件是呼叫這個方法的一種可能的結果

如果程式丟擲未受檢的異常或者錯誤,往往就屬於不可恢復的情形,繼續執行下去有害無益

錯誤往往被JVM保留用於表示資源不足、約束失敗或者其他使程式無法繼續執行的條件,由於這已經是個幾乎被普遍接受的慣例,因此最好不要再實現任何新的Error子類,因此,所有未受檢的丟擲結構都應該是RuntimeException的子類(直接的或者間接的)

第59條:避免不必要地使用受檢的異常

過分使用受檢的異常會使API使用起來非常不便,如果正確地使用API並不能阻止這種異常條件的產生,並且一旦產生異常,使用API的程式設計師可以立即採取有用的動作,這時受檢異常才應該被使用

第60條:優先使用標準的異常

Java平臺類庫提供了一組基本的未受檢的異常,它們滿足了絕大多數API的異常丟擲需要,重用現有的異常有很多方面的好處,如更加易於學習和使用,可讀性會更好等

常用的異常:

  • illegalArgumentException 當呼叫者傳遞的引數值不合適的時候,往往就會丟擲這個異常
  • illegalStateException 如果因為接受物件的狀態而使呼叫非法,通常就會丟擲這個異常
  • NullPointerException 引數中傳遞了null
  • IndexOutOfBoundsException 下標越界
  • ConcurrentModificationException 如果一個物件被設計為專用於單執行緒或者與外部同步機制配合使用,一旦發現它正在(或已經)被併發地修改,就應該丟擲這個異常
  • UnsupportedOperationException 如果物件不支援所請求的操作,就會丟擲這個異常

第61條:丟擲與抽象相對應的異常

更高層的實現應該捕獲低層的異常,同時丟擲可以按照高層抽象進行解釋的異常,這種做法被稱為異常轉譯

public E get(int index){
    ListIterator<E> i = listIterator(index);
    try {
        return i.next();
    } catch(NoSuchElementException e){
        throw new IndexOutOfBoundsException("Index: " + index);
    }
}

一種特殊的異常轉譯形式稱為異常鏈,如果底層的異常對於除錯導致高層異常的問題非常有幫助,使用異常鏈就很合適

try{
    ...
} catch(LowerLevelException cause){
    throw new HigherLevelException(cause);
}

class HigherLevelException extends Exception{
    HigherLevelException(Throwable cause){
        super(cause);
    }
}

如果不能阻止或者處理來自更低層的異常,一般的做法是使用異常轉譯,除非低層方法碰巧可以保證它丟擲的所有異常對高層也合適才可以將異常從低層傳播到高層。異常鏈對高層和低層異常都提供了最佳的功能:它允許丟擲適當的高層異常,同時又能捕獲底層的原因進行失敗分析

第62條:每個方法丟擲的異常都要有文件

始終要單獨地宣告受檢的異常,並且利用Javadoc的@throws標記,準確地記錄下丟擲每個異常的條件,但是不要使用throws關鍵字將未受檢的異常包含在方法的宣告中

第63條:在細節訊息中包含能捕獲失敗的資訊

為了捕獲失敗,異常的細節資訊應該包含所有“對該異常有貢獻”的引數和域的值。例如,IndexOutOfBoundsException異常的細節資訊應該包含下界、上界以及沒有落在界內的下標值

為了確保在異常的細節訊息中包含足夠的能捕獲失敗的資訊,一種辦法是在異常的構造器而不是字串細節訊息中引入這些資訊,然後,有了這些資訊,只要把它們放到訊息描述中,就可以自動產生細節訊息。例如,IndexOutOfBoundsException可以有個這樣的構造器:

public IndexOutOfBoundsException(int lowerBound, int upperBound, int index){
    super("Lower bound: " + lowerBound + 
          ", Upper bound: " + upperBound + 
          ", Index: " + index);

    // Save failure information for programmatic access
    this.lowerBound = lowerBound;
    this.upperBound = upperBound;
    this.index = index;
}

第64條:努力使失敗保持原子性

失敗的方法呼叫應該使物件保持在被呼叫之前的狀態,具有這種屬性的方法被稱為具有失敗原子性

對於可變物件,可以通過在執行操作之前檢查引數的有效性

public Object pop(){
    if(size == 0)
        throw new EmptyStackException();

    Object result = elements[--size];
    elements[size] = null;
    return result;
}

一種類似的獲得失敗原子性的辦法是,調整計算處理過程的順序,使得任何可能會失敗的計算部分都在物件狀態被修改之前發生

另一種獲得原子性的辦法是,在物件的一份臨時拷貝上執行操作,當操作完成之後再用臨時拷貝中的結果代替物件的內容

第65條:不要忽略異常

當API的設計者宣告一個方法將丟擲某個異常的時候,不應該忽略(空的catch塊)它

try{
    ...
}catch(SomeException e){

}

併發

第66條:同步訪問共享的可變資料

為了線上程之間進行可靠的通訊,也為了互斥訪問,同步是必要的

如果對共享的可變資料的訪問不能同步,其後果將非常可怕,即使這個變數是原子可讀寫的(long和double型別不是原子的)基本型別。要阻止一個執行緒妨礙另一個執行緒,建議做法是讓第一個執行緒輪詢一個boolean域,這個域一開始為false,但是可以通過第二個執行緒設定為true,以表示第一個執行緒將終止自己。由於boolean域的讀和寫操作都是原子的,程式再訪問這個域的時候不再使用同步:

public class StopThread {

    private static boolean stopRequested;

    public static void main(String[] args) throws InterruptedException {
        Thread backgroundThread = new Thread(new Runnable() {
            @Override
            public void run() {
                int i = 0;
                while (!stopRequested) {
                    i++;
                }
            }
        });

        backgroundThread.start();

        TimeUnit.SECONDS.sleep(1);
        stopRequested = true;
    }
}

由於上述程式碼沒有同步,虛擬機器會將這個程式碼:

while(!done)
    i++;

轉變成這樣:

if(!done)
    while(true)
        i++;

這種JVM的優化會導致活性失敗:這個程式無法前進。修正這個問題的一種方式是同步訪問stopRequest域,如下:

public class StopThread {

    private static boolean stopRequested;

    private static synchronized void requestStop(){
        stopRequested = true;
    }

    private static synchronized boolean stopRequested(){
        return stopRequested;
    }

    public static void main(String[] args) throws InterruptedException {
        Thread backgroundThread = new Thread(new Runnable() {
            @Override
            public void run() {
                int i = 0;
                while (!stopRequested()) {
                    i++;
                }
            }
        });

        backgroundThread.start();

        TimeUnit.SECONDS.sleep(1);
        requestStop();
    }
}

StopThread中被同步方法的動作即使沒有同步也是原子的,這些方法的同步只是為了它的通訊效果,而不是為了互斥訪問

也可以使用volatile修飾符不執行互斥訪問,但它可以保證任何一個執行緒在讀取該域的時候都將看到最近剛剛被寫入的值:

public class StopThread {

    private static volatile boolean stopRequested;

    public static void main(String[] args) throws InterruptedException {
        Thread backgroundThread = new Thread(new Runnable() {
            @Override
            public void run() {
                int i = 0;
                while (!stopRequested) {
                    i++;
                }
            }
        });

        backgroundThread.start();

        TimeUnit.SECONDS.sleep(1);
        stopRequested = true;
    }
}

在使用volatile的時候務必要小心,考慮下面的方法,假設它要產生序列號:

private static volatile int nextSerialNumber = 0;

public static int generateSerialNumber(){
    return nextSerialNumber++;
}

因為增量操作符(++)不是原子的,它在nextSerialNumber域中執行兩項操作:首先它讀取值,然後寫回一個新值。如果第二個執行緒在第一個執行緒讀取舊值和寫回新值期間讀取這個域,第二個執行緒就會與第一個執行緒一起看到同一個值,並返回相同的序列號。這就是安全性失敗:這個程式會計算出錯誤的結果

修正如下:

private static final AtomicLong nextSerialNum = new AtomicLong();

public static long generateSerialNumber(){
    return nextSerialNum.getAndIncrement();
}

讓一個執行緒在短時間內修改一個數據物件,然後與其他執行緒共享,這是可以接受的,只同步共享物件引用的動作,然後其他執行緒沒有進一步的同步也可以讀取物件,只有它沒有再被修改。這種物件被稱作事實上不可變,將這種物件引用從一個執行緒傳遞到其他的執行緒被稱作安全釋出。安全釋出物件引用有許多方法:可以將它儲存在靜態域中,作為類初始化的一部分;可以將它儲存在volatile域、final域或者通過正常鎖定訪問的域中;或者可以將它放到併發的集合中

簡而言之,當多個執行緒共享可變資料的時候,每個讀或者寫資料的執行緒都必須執行同步。如果只需要執行緒之間的互動通訊,而不需要互斥,volatile修飾符就是一種可以接受的同步形式,但要正確地使用它可能需要一些技巧

第67條:避免過度同步

過度同步可能會導致效能降低、死鎖,甚至不確定的行為。在一個被同步的區域內部,不要呼叫設計成要被覆蓋的方法,或者是由客戶端以函式物件的形式提供的方法,這樣的方法是外來的,不知道該方法會做什麼事情,也無法控制它,如下例:

public class ObservableSet<E> extends ForwardingSet<E> {
    public ObservableSet(Set<E> set) { super(set); }

    private final List<SetObserver<E>> observers = 
        new ArrayList<SetObserver<E>>();

    public void addObserver(SetObserver<E> observer) {
        synchronized(observers) {
            observers.add(observer);
        }
    }

    public boolean removeObserver(SetObserver<E> observer) {
        synchronized(observers) {
            return observers.remove(observer);
        }
    }

    // This method is the culprit
    private void notifyElementAdded(E element) {
        synchronized(observers) {
            for (SetObserver<E> observer : observers)
                observer.added(this, element);
        }
    }

    @Override public boolean add(E element) {
        boolean added = super.add(element);
        if (added)
            notifyElementAdded(element);
        return added;
    }

    @Override public boolean addAll(Collection<? extends E> c) {
        boolean result = false;
        for (E element : c)
            result |= add(element);  // calls notifyElementAdded
        return result;
    }
}

Observer通過呼叫addObserver方法預訂通知,通過呼叫removeObserver方法取消預訂

public static void main(String[] args) {
    ObservableSet<Integer> set =
        new ObservableSet<Integer>(new HashSet<Integer>());

    set.addObserver(new SetObserver<Integer>() {
        public void added(ObservableSet<Integer> s, Integer e) {
            System.out.println(e);
            if (e == 23) s.removeObserver(this);
        }
    });

    for (int i = 0; i < 100; i++)
        set.add(i);
}

上述程式在打印出0~23的數字後,並沒有停止,而是丟擲ConcurrentModificationException。問題在於,當notifyElementAdded呼叫觀察者的added方法時,它正處於遍歷observers列表的過程中。added方法呼叫可觀察集合的removeObserver方法,從而呼叫observers.remove,企圖在遍歷列表的過程中,將一個元素從列表中刪除,這是非法的

可以通過將外來方法的呼叫移除同步的程式碼塊來解決這個問題

private void notifyElementAdded(E element) {
    List<SetObserver<E>> snapshot = null;
    synchronized(observers) {
        snapshot = new ArrayList<>(observers);
    }
    for (SetObserver<E> observer : snapshot){
        observer.added(this, element);
    }
}

通常,應該在同步區域內做盡可能少的工作,獲得鎖,檢查共享資料,根據需要轉換資料,然後放掉鎖。如果必須要執行某個耗時操作,則應該設法把這個操作移動同步區域的外面

永遠不要過度同步,在這個多核的時代,過度同步的實際成本並不是指獲取鎖所花費的CPU時間;而是指失去了並行的機會,以及因為需要確保每個核都有一個一致的記憶體檢視而導致的延遲,過度同步的另一項潛在開銷在於,它會限制VM優化程式碼執行的能力

如果一個可變的類要併發使用,應該使這個類變成是執行緒安全的,通過內部同步,可以獲得明顯比從外部鎖定整個物件更高的併發性,否則,就不要在內部同步

如果在內部同步了類,就可以使用不同的方法來實現高併發性,例如分拆鎖、分離鎖和非阻塞併發控制

如果方法修改了靜態域,也必須同步對這個域的訪問,即使它往往只用於單個執行緒

第68條:executor和task優先於執行緒

java平臺的java.util.concurrent.Executor是一個很靈活的基於介面的任務執行工具

如果編寫的是小程式,或者是輕載的伺服器,使用Executors.newCachedThreadPool通常是個不錯的選擇,因為它不需要配置,並且一般情況下能夠正確地完成工作。但是對於大負載的伺服器來說,快取的執行緒池就不是很好的選擇了,在快取的執行緒池中,被提交的任務沒有排成佇列,而是直接交給執行緒執行,如果沒有執行緒可用,就建立一個新的執行緒,如果伺服器負載得太重,以致它所有的CPU都安全被佔用了,當有更多的任務時,就會建立更多的執行緒,這樣只會使情況變得更糟。因此,在大負載的產品伺服器中,最好使用Executors.newFixedThreadPool,它提供了一個包含固定執行緒數目的執行緒池,或者為了最大限度地控制它,就直接使用ThreadPoolExecutor類

第69條:併發工具優先於wait和notify

正確地使用wait和notify比較困難,就應該使用更高階的併發工具來代替

併發集合為標準的集合介面(如List、Queue和Map)提供了高效能的併發實現,為了提供高併發性,這些實現在內部自己管理同步。這意味著客戶端無法原子地對併發集合進行方法呼叫,因此有些集合介面已經通過依賴狀態的修改操作進行了擴充套件,它將幾個基本操作合併到了單個原子操作中。例如,ConcurrentMap擴充套件了Map介面,並添加了幾個方法,包括putIfAbsent(key, value),當鍵沒有對映時會替它插入一個對映,並返回與鍵關聯的前一個值,如果沒有這樣的值,則返回null

ConcurrentHashMap除了提供卓越的併發性之外,速度也非常快,可以極大地提升併發應用程式的效能

有些集合介面已經通過阻塞操作進行了擴充套件,它們會一直等待(或阻塞)到可以成功執行為止。例如,BlockingQueue擴充套件了Queue介面,並添加了包括take在內的幾個方法,它從佇列中刪除並返回了頭元素,如果佇列為空,就等待。這樣就允許將阻塞佇列用於工作佇列,也稱作生產者—消費者佇列,一個或者多個生產者執行緒在工作佇列中新增工作專案,並且當工作專案可用時,一個或者多個消費者執行緒則從工作佇列中取出佇列並處理工作專案,大多數的ExecutorService實現都使用BlockingQueue

第70條:執行緒安全性的文件化

一個類為了可被多個執行緒安全地使用,必須在文件中清楚地說明它所支援的執行緒安全性級別

  • 不可變的——這個類的例項是不變的,所以,不需要外部的同步,這樣的例子包括String、Long和BigInteger
  • 無條件的執行緒安全——這個類的例項是可變的,但是這個類有著足夠的內部同步,所以,它的例項可以被併發使用,無需任何外部同步,其例子包括Random和ConcurrentHashMap
  • 有條件的執行緒安全——除了有些方法為進行安全的併發使用而需要外部同步之外,這種執行緒安全級別與無條件的執行緒安全相同,這樣的例子包括Collections.synchronized包裝返回的集合,它們的迭代器要求外部同步
  • 非執行緒安全——這個類例項是可變的,為了併發地使用它們,客戶必須利用自己選擇的外部同步包圍每個方法呼叫(或者呼叫序列),這樣的例子包括通用的集合實現,例如ArrayList和HashMap
  • 執行緒對立的——這個類不能安全地被多個執行緒併發使用,即使所有的方法呼叫都被外部同步包圍,執行緒對立的根源通常在於,沒有同步地修改靜態資料,這種類是因為沒有考慮到併發性而產生的後果

每個類都應該說明或者執行緒安全註解,清楚地在文件中說明它的執行緒安全屬性,synchronized修飾符與這個文件毫無關係,有條件的執行緒安全類必須在文件中指明“哪個方法呼叫序列需要外部同步,以及在執行這些序列的時候要獲得哪把鎖(通常情況下,指作用在例項自身上的那把鎖)”。如果編寫的是無條件的執行緒安全類,就應該考慮使用私有鎖物件來代替同步的方法,這樣可以防止客戶端程式和子類的不同步干擾,能夠在後續的版本中靈活地對併發控制採用更加複雜的方法

第71條:慎用延遲初始化

延遲初始化是延遲到需要域的值時才將它初始化的這種行為。這種方法既適用於靜態域,也適用於例項域,雖然延遲初始化主要是一種優化,但它也可以用來打破類和例項初始化中的有害迴圈

延遲初始化降低了初始化類或者建立例項的開銷,卻增加了訪問被延遲初始化的域的開銷。如果域只在類的例項部分被訪問,並且初始化這個域的開銷很高,可能就值得進行延遲初始化

當有多個執行緒時,延遲初始化是需要技巧的,如果兩個或者多個執行緒共享一個延遲初始化的域,採用某種形式的同步是很重要的。在大多數情況下,正常的初始化要優先於延遲初始化

如果出於效能的考慮而需要對靜態域使用延遲初始化,就使用lazy initialization holder class模式

private static class FieldHolder{
    static final FieldType field = computeFieldValue();
}

static FieldType getField(){
    return FieldHolder.field;
}

當getField方法第一次被呼叫時,它第一次讀取FieldHolder.field,導致FieldHolder類得到初始化

如果出於效能的考慮而需要對例項域使用延遲初始化,就使用雙重檢查模式,這種模式避免了在域被初始化之後訪問這個域時的鎖定開銷

private volatile FieldType field;
FieldType getField(){
    FieldType result = field;
    if(result == null) { // First check (no locking)
        synchronized(this){
            result = field;
            if(result == null){ // Second check (with locking)
                field = result = computeFieldValue();
            }
        }
    }
    return result;
}

如果需要延遲初始化一個可以接受重複初始化的例項域,可以使用單重檢查模式

private volatile FieldType field;

private FieldType getField(){
    FieldType result = field;
    if(result == null){
        field = result = computeFieldValue();
    }
    return result;
}

第72條:不要依賴於執行緒排程器

要編寫健壯的、響應良好的、可移植的多執行緒應用程式,最好的辦法是確保可執行執行緒的平均數量不明顯多於處理器的數量。這使得執行緒排程器沒有更多的選擇:它只需要執行這些可執行的執行緒,直到它們不再可執行為止。即使在根本不同的執行緒排程演算法下,這些程式的行為也不會有很大的變化

第73條:避免使用執行緒組

執行緒組並沒有提供太多有用的功能,而且它們提供的許多功能還都是有缺陷的,如果設計的一個類需要處理執行緒的邏輯組,或許可以使用執行緒池executor