Effective Java 第三版——79. 避免過分同步

Tips
書中的源代碼地址:https://github.com/jbloch/effective-java-3e-source-code
注意,書中的有些代碼裏方法是基於Java 9 API中的,因此JDK 最好下載 JDK 9以上的版本。java

Effective Java, Third Edition

79. 避免過分同步

條目 78警告咱們缺少同步的危險性。這一條目則涉及相反的問題。根據不一樣的狀況,過分的同步可能致使性能降低、死鎖甚至不肯定性行爲。git

爲了不活性失敗和安全性失敗,永遠不要在同步方法或代碼塊中將控制權交給客戶端。換句話說,在同步區域內,不要調用設計爲被重寫的方法,或者由客戶端以函數對象的形式提供的方法(條目 24)。從具備同步區域的類的角度來看,這種方法是外外來的(alien)。類不知道該方法作什麼,也沒法控制它。根據外來方法的做用,從同步區域調用它可能會致使異常、死鎖或數據損壞。github

要使其具體化說明這個問題,請考慮下面的類,它實現了一個可觀察集合包裝器(observable set wrapper)。當元素被添加到集合中時,它容許客戶端訂閱通知。這就是觀察者模式(Observer pattern)[Gamma95]。爲了簡單起見,當元素從集合中刪除時,該類不提供通知,可是提供通知也很簡單。這個類是在條目 18(第90頁)的ForwardingSet類實現的:編程

// Broken - invokes alien method from synchronized block!
public class ObservableSet<E> extends ForwardingSet<E> {
    public ObservableSet(Set<E> set) { super(set); }

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

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

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

    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;
    }
}

觀察者經過調用addObserver方法訂閱通知,並經過調用removeObserver方法取消訂閱。 在這兩種狀況下,都會將此回調接口的實例傳遞給該方法:小程序

@FunctionalInterface public interface SetObserver<E> {
    // Invoked when an element is added to the observable set
    void added(ObservableSet<E> set, E element);
}

該接口在結構上與BiConsumer <ObservableSet <E>,E>相同。 咱們選擇定義自定義函數式接口,由於接口和方法名稱使代碼更具可讀性,而且由於接口能夠演變爲包含多個回調。 也就是說,使用BiConsumer也能夠作出合理的論理由(條目 44)。數組

若是粗地略檢查一下,ObservableSet彷佛工做正常。 例如,如下程序打印0到99之間的數字:安全

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

    set.addObserver((s, e) -> System.out.println(e));

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

如今讓咱們嘗試一些更好玩的東西。假設咱們將addObserver調用替換爲一個傳遞觀察者的調用,該觀察者打印添加到集合中的整數值,若是該值爲23,則該調用將刪除自身:多線程

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

請注意,此調用使用匿名類實例代替上一次調用中使用的lambda表達式。 這是由於函數對象須要將自身傳遞給s.removeObserver,而lambdas表達式不能訪問本身(條目 42)。併發

你可能但願程序打印0到23的數字,以後觀察者將取消訂閱而且程序將以靜默方式終止。 實際上,它打印這些數字而後拋出ConcurrentModificationException異常。 問題是notifyElementAdded在調用觀察者的add方法時,正在迭代觀察者的列表。 add方法調用observable setremoveObserver方法,該方法又調用方法bservers.remove。 如今咱們遇到了麻煩。 咱們試圖在迭代它的過程當中從列表中刪除一個元素,這是非法的。 notifyElementAdded方法中的迭代在同步塊中,防止併發修改,但它不會阻止迭代線程自己回調到可觀察的集合並修改其觀察者列表。app

如今讓咱們嘗試一些奇怪的事情:讓咱們編寫一個嘗試取消訂閱的觀察者,但不是直接調用removeObserver,而是使用另外一個線程的服務來執行操做。 該觀察者使用執行者服務(executor service)(條目 80):

// Observer that uses a background thread needlessly
set.addObserver(new SetObserver<>() {
   public void added(ObservableSet<Integer> s, Integer e) {
      System.out.println(e);
      if (e == 23) {
         ExecutorService exec =
               Executors.newSingleThreadExecutor();
         try {
            exec.submit(() -> s.removeObserver(this)).get();
         } catch (ExecutionException | InterruptedException ex) {
            throw new AssertionError(ex);
         } finally {
            exec.shutdown();
         }
      }
   }
});

順便提一下,請注意,此程序在一個catch子句中捕獲兩種不一樣的異常類型。 Java 7中添加了這種稱爲multi-catch的工具。它能夠極大地提升清晰度並減少程序的大小,這些程序在響應多種異常類型時的行爲方式相同。

當運行這個程序時,沒有獲得異常:而是程序陷入僵局。 後臺線程調用s.removeObserver,它試圖鎖定觀察者,但它沒法獲取鎖,由於主線程已經有鎖。 一直以來,主線程都在等待後臺線程完成刪除觀察者,這解釋了發生死鎖的緣由。

這個例子是人爲設計的,由於觀察者沒有理由使用後臺線程來取消訂閱自己,可是問題是真實的。在實際系統中,從同步區域內調用外來方法會致使許多死鎖,好比GUI工具包。

在前面的異常和死鎖兩個例子中,咱們都很幸運。調用外來added方法時,由同步區域(觀察者)保護的資源處於一致狀態。假設要從同步區域調用一個外來方法,而同步區域保護的不變量暫時無效。由於Java編程語言中的鎖是可重入的,因此這樣的調用不會死鎖。與第一個致使異常的示例同樣,調用線程已經持有鎖,因此當它試圖從新得到鎖時,線程將成功,即便另外一個概念上不相關的操做正在對鎖保護的數據進行中。這種失敗的後果多是災難性的。從本質上說,這把鎖沒能發揮它的做用。可重入鎖簡化了多線程面向對象程序的構建,但它們能夠將活性失敗轉化爲安全性失敗。

幸運的是,經過將外來方法調用移出同步塊來解決這類問題一般並不難。對於notifyElementAdded方法,這涉及到獲取觀察者列表的「快照」,而後能夠在沒有鎖的狀況下安全地遍歷該列表。經過這樣修改,前面的兩個例子在運行時不會發生異常或死鎖了:

// Alien method moved outside of synchronized block - open calls
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);
}

實際上,有一種更好的方法能夠將外來方法調用移出同步代碼塊。Java類庫提供了一個名爲CopyOnWriteArrayList的併發集合(條目 81),該集合是爲此目的量身定製的。此列表實現是ArrayList的變體,其中全部修改操做都是經過複製整個底層數組來實現的。由於從不修改內部數組,因此迭代不須要鎖定,並且速度很是快。對於大多數使用,CopyOnWriteArrayList的性能會不好,可是對於不多修改和常常遍歷的觀察者列表來講,它是完美的。

若是修改列表使用CopyOnWriteArrayList,則無需更改ObservableSet的add和addAll方法。 如下是該類其他部分的代碼。 請注意,沒有任何顯示的同步:

// Thread-safe observable set with CopyOnWriteArrayList
private final List<SetObserver<E>> observers =
        new CopyOnWriteArrayList<>();

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

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

private void notifyElementAdded(E element) {
    for (SetObserver<E> observer : observers)
        observer.added(this, element);
}

在同步區域以外調用的外來方法稱爲開放調用[Goetz06,10.1.4]。 除了防止失敗,開放調用能夠大大增長併發性。 外來方法可能會持續任意長時間。 若是從同步區域調用外來方法,則將不容許其餘線程訪問受保護資源。

做爲一個規則,應該在同步區域內作儘量少的工做。獲取鎖,檢查共享數據,根據須要進行轉換,而後刪除鎖。若是必須執行一些耗時的活動,請設法將其移出同步區域,而不違反條目 78 中的指導原則。

這個條目的第一部分是關於正確性的。如今讓咱們簡要地看一下性能。雖然自Java早期以來,同步的成本已經大幅降低,但比以往任什麼時候候都更重要的是,不要過分同步。在多核世界中,過分同步的真正代價不是得到鎖花費的CPU時間:這是一種爭論,失去了並行化的機會,以及因爲須要確保每一個核心都有一致的內存視圖而形成的延遲。過分同步的另外一個隱藏成本是,它可能限制虛擬機優化代碼執行的能力。

若是正在編寫一個可變類,有兩個選項:能夠省略全部同步,並容許客戶端在須要併發使用時在外部進行同步,或者在內部進行同步,從而使類是線程安全的(條目 82)。 只有經過內部同步實現顯着更高的併發性時,才應選擇後一個選項,而不是讓客戶端在外部鎖定整個對象。 java.util中的集合(過期的Vector和Hashtable除外)採用前一種方法,而java.util.concurrent中的集合採用後者(條目 81)。

在Java的早期,許多類違反了這些準則。 例如,StringBuffer實例幾乎老是由單個線程使用,但它們執行內部同步。 正是因爲這個緣由,StringBuffer被StringBuilder取代,而StringBuilder只是一個不一樣步的StringBuffer。 一樣,java.util.Random中的線程安全僞隨機數生成器被java.util.concurrent.ThreadLocalRandom中的非同步實現取代,也是出於部分上述緣由。 若有疑問,請不要同步你的類,但要創建文檔,並記錄它不是線程安全的。

若是在內部同步類,可使用各類技術來實現高併發性,例如鎖分割( lock splitting)、鎖分段(lock striping)和非阻塞併發控制。這些技術超出了本書的範圍,可是在其餘地方也有討論[Goetz06, Herlihy12]。

若是一個方法修改了一個靜態屬性,而且有可能從多個線程調用該方法,則必須在內部同步對該屬性的訪問(除非該類可以容忍不肯定性行爲)。多線程客戶端不可能對這樣的方法執行外部同步,由於不相關的客戶端能夠在不一樣步的狀況下調用該方法。屬性本質上是一個全局變量,即便它是私有的,由於它能夠被不相關的客戶端讀取和修改。條目 78中的generateSerialNumber方法使用的nextSerialNumber屬性演示了這種狀況。

總之,爲了不死鎖和數據損壞,永遠不要從同步區域內調用外來方法。更通俗地說,在同步區域內所作的工做量保持在最低水平。在設計可變類時,請考慮它是否應該本身完成同步操做。在多核時代,比以往任什麼時候候都更重要的是不要過分同步。只有在有充分理由時,纔在內部同步類,並清楚地在文檔中記錄你的決定(條目 82)。

相關文章
相關標籤/搜索