歡迎光臨
每天分享高質量文章

使用 Accessor Service 共享可變物件

(給ImportNew加星標,提高Java技能)

 

編譯:ImportNew/唐尤華

 

Brian Goetz 在他的 “Java Concurrency In Practice” 中,第54頁介紹瞭如何在執行緒間安全地共享物件。需要註意下麵4點:

 

  1. 物件要保持執行緒限定(僅限執行緒內部更新),即只由擁有該物件的執行緒更新
  2. 共享物件時保持只讀,只做一次性釋出
  3. 物件內部是執行緒安全的,物件內部實現同步
  4. 物件由鎖機制保護

 

本文介紹了方案4的一個變種,共享物件既不保持執行緒限定、也不只讀或者在物件內部實現同步,而是採用讀寫鎖確保物件狀態正確。下麵展示的程式碼可高度併發,無需使用 `synchronized` 且不會發生執行緒競爭造成應用效能下降。雖然沒有使用 `synchronized`,透過應用特定規則仍然可以確保共享物件的修改在所有執行緒中可見。

 

1. 建立共享物件

 

共享的物件應遵守一些規則。這樣不但能避免發生執行緒可見性問題,還能確保執行緒安全。示例如下:

 

```java
/**
 * 安全共享物件實體
 */
public final class SharedObject {
    /**
     * 互斥讀寫鎖
     */
    private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
    
    /**
     * 可變狀態列位示例
     */
    private volatile String data;
    
    /**
     * 按照加鎖規則宣告的其他可變狀態
     */
    
    /**
     * 預設包級私有建構式
     */
    SharedObject() {
    }

    /**
     * 包級私有複製函式
     */
    SharedObject(SharedObject template) {
        this.data = template.data;
    }

    boolean readLock() {
        return lock.readLock().tryLock();
    }

    boolean writeLock() {
        return lock.writeLock().tryLock();
    }

    void readUnlock() {
        lock.readLock().unlock();
    }

    void writeUnlock() {
        lock.writeLock().unlock();
    }

    public String getData() {
        return data;
    }

    /**
     * 包級私有 setter 方法
     */
    void setData(String data) {
        this.data = data;
    }
}
```

 

物件本身包含一個 `ReentrantReadWriteLock` 用來鎖定。只要沒有未釋放的 write-lock,就能支援多執行緒併發讀取。我們不希望共享物件實體被不必要的鎖降低讀取效能。如果執行緒修改了物件狀態,就需要確保併發讀取不會因為其他執行緒的影響讀到無效狀態。當物件成功獲得了 write-lock 則不允許進行讀取。這就是 `ReadWriteLock` 的設計思想。

 

其他執行緒可以透過另一個 Accessor Service 服務訪問 `SharedObject` 實體,接下來會講解這個過程。但是首先,讓我們站在可見性角度應用規則,把 `SharedObject` 改造成執行緒安全類,透過 Accessor Service 提供服務。規則如下:

 

  1. 只允許相同 package 的類建立物件和修改狀態,這裡假定類與它的 Accessor Service 位於同一包下;
  2. 不要從 Accessor Service 中丟掉原始物件,因此需要在建構式中複製物件;
  3. 宣告 `ReentrantReadWriteLock` 進行狀態鎖定;
  4. 所有修改狀態的方法應只對同一個包中的類開放,比如 Accessor Service;
  5. 所有可變狀態都要宣告為 `volatile`;
  6. 如果 `volatile` 欄位碰巧是物件取用,必須遵守以下規則:

             1.物件必須是不可變的;

             2.該欄位取用的物件必須遵守規則4、5、6。

 

規則4、5、6能夠保證應用執行過程中 `SharedObject` 物件的狀態變化對所有執行緒可見。這樣,無論對應的記憶體採用何種同步機制,都能夠確保結論成立。遵守以上規則,可以看到物件當前最新狀態,但這裡並不保證狀態有效。物件狀態的有效性僅取決於修改操作是否執行緒安全,關於這部分會在接下來 Accessor Service 的使用中介紹。

 

2. 共享物件 Accessor Service

 

現在,讓我們看看上面提到的 service 類,它負責為共享物件提供執行緒安全的更新操作:

 

```java
package org.projectbarbel.playground.safeaccessor;

import java.util.ConcurrentModificationException;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Consumer;
import java.util.function.Function;

/**
 * 執行緒安全地訪問共享物件
 */
public final class SafeAccessorService {

    /**
     * {@link ConcurrentHashMap} 對所有執行緒安全地釋出物件,物件無一遺漏
     *
     */
    private final Map sharedObjects = new ConcurrentHashMap();
    
    public SafeAccessorService() {
    }

    /**
     * 執行緒安全地訪問共享實體
     * 
     * @param objectId         object id
     * @param compoundAction   共享物件上執行的原子操作
     * @param lock             lock 函式, 為物件加鎖
     * @param unlock           unlock 函式, 為物件解鎖
     * @return 更新後的實體
     */
    private SharedObject access(String objectId, Function compoundAction,
            Function lock, Consumer unlock) {
        // 安全地建立新實體, 由 ConcurrentHashmap 一次性釋出
        SharedObject sharedObject = sharedObjects.computeIfAbsent(objectId, this::createSharedInstance);
        if (lock.apply(sharedObject)) {
            try {
                // 執行緒安全地修改物件, sharedObject 需要遵守加鎖規則
                return compoundAction.apply(sharedObject);
            } finally {
                unlock.accept(sharedObject);
            }
        } else {
            // 交由客戶端處理
            throw new ConcurrentModificationException(
                    "the shared object with id=" + objectId + " is locked - try again later");
        }
    }

    /**
     * Accessor Service 更新操作示例,可自行定義
     * @param objectId 待更新物件
     * @param data 設定給物件的資料
     */
    public void updateData(String objectId, String data) {
        access(objectId, so -> {
            so.setData(data);
            return so;
        }, so -> so.writeLock(), so -> so.writeUnlock());
    }

    /**
     * Get access 方法傳回共享物件快照, 不修改原始物件,
     * 確保客戶端始終工作在有效狀態。
     * 傳入物件 id 無效時,方法會建立一個新實體。
     * 
     * @param objectId {@link SharedObject} id
     * @return 共享物件複製
     */
    public SharedObject get(String objectId) {
        return access(objectId, so -> new SharedObject(so), so -> so.readLock(), so -> so.readUnlock());
    }

    /**
     * 從 map 中移除物件
     * 
     * @param objectId 待移除的物件 id
     */
    public void remove(String objectId) {
        sharedObjects.remove(objectId);
    }

    /**
     * 建立新的共享實體
     *
     * @param id 共享物件 id
     * @return 新建立的物件實體
     */
    private SharedObject createSharedInstance(String id) {
        return new SharedObject();
    }
}
```

 

共享物件儲存在 `ConcurrentHashmap` 中(18行)。雖然能夠保證執行緒建立物件時實現一次性安全釋出,但不能承諾修改可變物件對所有執行緒可見。要實現可見性,必須在遵守上述規則的前提下,對修改狀態操作使用同步技術。

 

`access()` 是 `SaveAccessorService` 類的核心方法,能夠根據需要安全地建立新實體(36行);根據傳給 `access()` 的鎖定和解鎖函式,有 read-lock 或 write-lock 兩種型別;如果加鎖成功,會在物件上呼叫 `compoundAction`(40行);程式的最後會釋放鎖(46行)。這種激進的非等待策略會被修改傳入的鎖定和解鎖函式或者 `SharedObject` 中定義加鎖方法削弱。

 

讓我們來看 `updateData()` 方法(56行),它呼叫了上面提到的 `access()` 方法執行更新操作。`update` 方法再呼叫 `access()`,這裡 `compoundAction` 會在共享物件上呼叫 `setData()` 方法。整個呼叫過程在 write-lock(60行)控制下進行。`update()` 函式只對 `data` 變數進行了一個非常簡單的更新。使用者可根據需要為共享物件定義更複雜的操作。所有操作都是“自動”執行,也就是說只要向 `access()` 方法傳遞 `compoundAction`,就會在 write-lock 的控制下執行。

 

`get()`(71行)也呼叫了 `access()` 方法,但這裡只是在 read-lock 的控制下建立了物件快照。這樣能確保快照中的值一直有效,因為只要 write-lock 未釋放,獲取共享物件的 read-lock 就會失敗。註意:`get()` 方法不會把原始物件的取用傳回給客戶端。這種技術有時被稱作實體約束,確保不會在 Accessor Service 以外的地方對原始實體執行非執行緒安全的操作。

 

3. 優點與不足

 

這種樣式存在優點與不足。每個物件都需要根據讀寫型別分配對應的鎖,樣式的優點在於能夠把鎖的控制範圍減到最小。上面的示例中,加鎖失敗後不會等待,直接向客戶端傳回 `ConcurrentModificationException` 異常,交由客戶端處理。客戶端捕獲異常後可繼續處理其他任務,完成後傳回。

 

客戶端也可以不選擇這種激進的加鎖策略,比如讓執行緒等待直到加鎖成功。`ReentrantReadWriteLock` 提供了 `tryLock(long timeout, TimeUnit unit)` 方法可以做到這一點。可以透過 `access()` 呼叫時傳入的 `lock` 和 `unlock` 函式進行加鎖,也可以修改 `SharedObject` 中的 `lock` 函式。使用者可以決定使用其他型別的鎖或者採取不同的加鎖策略。因此,完全可以根據自己的加鎖需求進行調整。我提出了“可擴充套件性選項”,出現執行緒爭用的情況極低。

 

樣式的另一個優點,客戶端可以定義類似 `required` 的原子操作。`updateData()` 方法只是更新操作一個簡單的示例,實際會用到更加複雜的操作,只需向 `SaveAccessorService` 新增類似 `updateData()` 的方法即可。與基於 Spring 的應用類似,底層是資料庫可以為 service 新增多個 update 方法;同樣的,這些方法會執行其他 compound action,比如在共享物件上執行多個 setter 方法。

 

樣式還有一個優點:物件本身無需關心加鎖過程,只有 getter、setter 和排他鎖物件。Service 中定義了 compound action,這些 action 受物件鎖保護。這種方法可以方便地新增複雜 action,甚至可以為多種不同的共享物件定義 action。Compound action 不限於某個共享物件狀態,還可以是針對多個物件的原子操作。這種情況下可能引入新的多執行緒問題,比如死鎖。

 

樣式的缺點在於,使用者要能處理好共享物件組合。共享物件必須遵守設定的規則,否則可能出現 Accessor Service 暴露不必要的取用,結果執行緒讀到過期資料。在我看來,另一個缺點是物件儲存在 map 中,對客戶端透明。如果客戶端無法很好地管理 map,可能會帶來記憶體洩漏。例如,只新增物件不移出物件,結果會造成老年代記憶體使用增加。可以透過 `remove()` 方法移除物件,或者呼叫其他方法清空整個 map。

 

4. 效能

 

這個方案是否比 `synchronized` 效能更好?要回答這個問題,首先需要知道讀操作的比例是否大大超過寫操作。雖然與具體的應用緊密相關,但是可以確認讀寫鎖針對併發效能進行了設計最佳化。Brian Goetz 在書中提到:“多處理器系統中,訪問以讀為主的資料結構讀寫鎖會進行最佳化;而其他場合下,由於自身實現的複雜性,效能上會比排它鎖略差“(Java Concurrency In Practice, 2006, 286頁)。因此,武斷地評價這種方案比其他實現更好是不合適的。得出正式的結論前,需要對你的應用進行效能分析。也可以在每次更新前獲得共享物件監視器,執行讀操作,對比本文的加鎖策略進行評估。

 

5. 可見性

 

嚴格來說,Accessor Service 中的 `SharedObject` 並不需要宣告 `volatile`,因為顯示加鎖已經能夠保證類似 `synchronized` 的記憶體可見性。然而,我們認為 `SharedObject` 可以安全地用到許多場合。例如,即使 `SafeAccessorService` 暴露了物件取用,`SharedObject` 並不會馬上失敗,因為它的實現遵守了前文的規則。此外,在我設計的一個 “ultra-fast” 應用中,使用 `AtomicBoolean` 作為共享物件的 psuedo-lock,這些物件由 Accessor Service 實體管理。這意味著,為了達到最高的效能和最少的執行緒爭用,拋棄了所有 JDK 提供的複雜同步機制。這種情況下,在 `SharedObject` 上應用的規則就顯得極為重要,因為我放棄了所有執行緒資料可見性保證,比如 `ReentrantReadWriteLock` 顯示鎖和 `synchronized`。總結一下,`volatile` 能以較低的成本減小整個程式脆弱性。

 

當然,還會有更多問題有待討論。歡迎在下麵評論,丟擲你的想法。本文原始碼可以在[這裡][1]找到,還有一個[測試用例][2]。

 

希望你喜歡這篇文章。

 

[1]:https://github.com/nschlimm/playground-java8/tree/master/src/main/java/org/projectbarbel/playground/safeaccessor

[2]:https://github.com/nschlimm/playground-java8/blob/master/src/main/java/org/projectbarbel/playground/safeaccessor/SafeAccessorServiceTest.java

    已同步到看一看
    贊(0)

    分享創造快樂