JCIP閱讀筆記之執行緒安全性

shelbylee發表於2019-01-19

本文是作者在閱讀JCIP過程中的部分筆記和思考,純手敲,如有誤處,請指正,非常感謝~

可能會有人對書中程式碼示例中的註解有疑問,這裡說一下,JCIP中示例程式碼的註解都是自定義的,並非官方JDK的註解,因此如果想要在自己的程式碼中使用,需要新增依賴。移步:jcip.net

一、什麼是執行緒安全性?

當多個執行緒訪問某個類時,這個類始終都能表現出正確的行為,那麼這個類就是執行緒安全的。

示例:一個無狀態的Servlet

從request中獲取數值,然後因數分解,最後將結果封裝到response中

    @ThreadSafe
    public class StatelessFactorizer implements Servlet {
        public void service(ServletRequest req, ServletResponse resp) {
            BigInteger i = extractFromRequest(req);
            BigInteger[] factors = factor(i);
            encodeIntoResponse(resp, factors);
        }
    }

這是一個無狀態的Servlet,什麼是無狀態的?不包含任何域或者對其他類的域的引用。service裡僅僅是用到了存線上程棧上的區域性變數的臨時狀態,並且只能由正在執行的執行緒訪問。

所以,如果有一個執行緒A正在訪問StatelessFactorizer類,執行緒B也在訪問StatelessFactorizer類,但是二者不會相互影響,最後的計算結果仍然是正確的,為什麼呢?因為這兩個執行緒並沒有共享狀態,他們各自訪問的都是自己的區域性變數,所以像這樣 無狀態的物件都是執行緒安全的

大多數Servlet都是執行緒安全的,所以極大降低了在實現Servlet執行緒安全性的複雜性。只有在Servlet處理請求需要儲存一些資訊的情況下,執行緒安全性才會成為一個問題。

二、原子性

我理解的原子性就是指一個操作是最小範圍的操作,這個操作要麼完整的做要麼不做,是一個不可分割的操作。比如一個簡單的賦值語句 x = 1,就是一個原子操作,但是像複雜的運算子比如++, –這樣的不是原子操作,因為這涉及到“讀取-修改-寫入”的一個操作序列,並且結果依賴於之前的狀態。

示例:在沒有同步的情況下統計已處理請求數量的Servlet(非執行緒安全)

    @NotThreadSafe
    public class UnsafeCountingFactorizer implements Servlet {
        private long count = 0;

        public long getCount() {
            return count;
        }

        public void service(ServletRequest req, ServletResponse resp) {
            BigInteger i = extractFromRequest(req);
            BigInteger[] factors = factor(i);
            count++; // *1
            encodeIntoResponse(resp, factors);
        }
    }

在上面這段程式碼中,count是一個公共的資源,如果有多個執行緒,比如執行緒A, B同時進入到 *1 這行,那麼他們都讀取到count = 0,然後進行自增,那麼count就會變成1,很明顯這不是我們想要的結果,因為我們丟失了一次自增。

1. 競態條件

這裡有一個概念:競態條件(Race Condition),指的是,在併發程式設計中,由於不恰當的執行時序而出現不正確的結果。

在count自增的這個計算過程中,他的正確性取決於執行緒交替執行的時序,那麼就會發生競態條件。

大多數競態條件的本質是,基於一種可能失效的觀察結果來做出判斷 or 執行某個計算,即“先檢查後執行”。

還是拿這個count自增的計算過程舉例:

  • count++大致包含三步:

    • 取當前count值 *1
    • count加一 *2
    • 寫回count *3

那麼在這個過程中,執行緒A首先去獲取當前count,然後很不幸,執行緒A被掛起了,執行緒B此時進入到 1,他取得的count仍然為0,然後繼續 2,count = 1,現線上程B又被掛起了,執行緒A被喚醒繼續 2,此時執行緒A觀察到的仍然是自己被掛起之前count = 0的結果,實際上是已經失效的結果,執行緒A再繼續 2,count = 1,然後 *3,最後得到結果是count = 1,然後執行緒B被喚醒後繼續執行,得到的結果也是count = 1。

這就是一個典型的由於不恰當的執行時序而產生不正確的結果的例子,即發生競態條件。

2. 延遲初始化中的競態條件

這是一個典型的懶漢式的單例模式的實現(非執行緒安全)

    @NotThreadSafe
    public class Singleton {
        private static Singleton instance;

        private Singleton() {}

        public static Singleton getInstance() {
            if (instance == null) { // *1
                instance = new Singleton();
            }

            return instance;
        }
    }

在 *1 判空後,即實際需要使用時才初始化物件,也就是延遲初始化。這種方式首先判斷 instance 是否已經被初始化,如果已經初始化過則返回現有的instance,否則再建立新的instance,然後再返回,這樣就可以避免在後來的呼叫中執行這段高開銷的程式碼路徑。

在這段程式碼中包含一個競態條件,可能會破壞該類的正確性。假設有兩個執行緒A, B,同時進入到了getInstance()方法,執行緒A在 *1 判斷為true,然後開始建立Singleton例項,但是A會花費多久能建立完,以及執行緒的排程方式都是不確定的,所以有可能A還沒建立完例項,B已經判空返回true,最終結果就是建立了兩個例項物件,沒有達到單例模式想要達到的效果。

當然,單例模式有很多其他經典的執行緒安全的實現方式,像DCL、靜態內部類、列舉都可以保證執行緒安全,在這裡就不贅述了。

三、加鎖機制

還是回到因數分解那個例子,如果希望提升Servlet的效能,將剛計算的結果快取起來,當兩個連續的請求對相同的值進行因數分解時,可以直接用上一次的結果,無需重新計算。

具體實現如下:

該Servlet在沒有足夠原子性保證的情況下對其最近計算結果進行快取(非執行緒安全)

@NotThreadSafe
public class UnsafeCachingFactorizer implements Servlet {
    private final AtomicReference<BigInteger> lastNumber
            = new AtomicReference<>();
    private final AtomicReference<BigInteger[]> lastFactors
            = new AtomicReference<>();

    public void service (ServletRequest req, ServletResponse resp) {
        BigInteger i = extractFromRequest(req);
        if (i.equals(lastNumber.get())) // *2
            encodeIntoResponse(resp, lastFactors.get()); // *3
        else {
            BigInteger[] factors = factor(i);
            lastNumber.set(i); // *1
            lastFactors.set(factors);
            encodeIntoResponse(resp, factors);
        }
    }
}

很明顯這個Servlet不是執行緒安全的,儘管使用了AtomicReference(替代物件引用的執行緒安全類)來保證每個操作的原子性,但是整個過程仍然存在競態條件,我們無法同時更新lastNumber和lastFactors,比如執行緒A執行到 1之後set了新的lastNumber,但此時還沒有更新lastFactors,然後執行緒B進入到了 2,發現已經該數字已經有快取,便進入 *3,但此時執行緒A並沒有同時更新lastFactors,所以執行緒B現在get的i的因數分解結果是錯誤的。

Java提供了一些鎖的機制來解決這樣的問題。

1. 內建鎖

synchronized (lock) {
    // 訪問或修改由鎖保護的共享狀態
}

在Java中,最基本的互斥同步手段就是synchronized關鍵字了

比如,我們對一個計數操作進行同步

public class Test implements Runnable {
    private static int count;

    public Test() {
        count = 0;
    }

    @Override
    public void run() {
        synchronized (this) {
            for (int i = 0; i < 5; i++) {
                try {
                    System.out.println(Thread.currentThread().getName() + ":" + (count++));
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }

    public int getCount() {
        return count;
    }

    public static void main(String[] args) {
        Test test = new Test();
        Thread thread1 = new Thread(test, "thread1");
        Thread thread2 = new Thread(test, "thread2");
        thread1.start();
        thread2.start();
    }

}

最後輸出的結果是:

thread1:0
thread1:1
thread1:2
thread1:3
thread1:4
thread2:5
thread2:6
thread2:7
thread2:8
thread2:9

synchronized關鍵字編譯後會在同步塊前後形成 monitorenter 和 monitorexit 這兩個位元組碼指令

  public void run();
    Code:
       0: aload_0
       1: dup
       2: astore_1
       3: monitorenter
       4: iconst_0
       5: istore_2
       6: iload_2
        // ......
      67: iinc          2, 1
      70: goto          6
      73: aload_1
      74: monitorexit
      75: goto          85
      78: astore        4
      80: aload_1
      81: monitorexit
      82: aload         4
      84: athrow
      85: return

在執行monitorenter時會嘗試去獲取物件的鎖,如果這個物件沒被鎖定 or 當前執行緒已擁有了這個物件的鎖,則計數器 +1 ,相應地,執行monitorexit時計數器 -1 ,計數器為0,則釋放鎖。如果獲取物件失敗,需要阻塞等待。

雖然這種方式可以保證執行緒安全,但是效能方面會有些問題。

因為Java的執行緒是對映到作業系統的原聲執行緒上的,所以如果要阻塞 or 喚醒一個執行緒,需要作業系統在系統態和使用者態之間轉換,而這種轉換會耗費很多處理器時間。

除此之外,這種同步機制在某些情況下有些極端,如果我們用synchronized關鍵字修飾前面提到的因式分解的service方法,那麼在同一時刻就只有一個執行緒能執行該方法,也就意味著多個客戶端無法同時使用因式分解Servlet,服務的響應性非常低。

不過,虛擬機器本身也在對其不斷地進行一些優化。

2. 重入

什麼是重入?

舉個例子,一個加了X鎖的方法A,這個方法內呼叫了方法B,方法B也加了X鎖,那麼,如果一個執行緒拿到了方法A的X鎖,再呼叫方法B時,就會嘗試獲取一個自己已經擁有的X鎖,這就是重入。

重入的一種實現方法是:每個鎖有一個計數值,若計數值為0,則該鎖沒被任何執行緒擁有。當一個執行緒想拿這個鎖時,計數值加1;當一個執行緒退出同步塊時,計數值減1。計數值為0時鎖被釋放。

synchronized就是一個可重入的鎖,我們可以用以下程式碼證明一下看看:

Parent.java

public class Parent {
    public synchronized void doSomething() {
        System.out.println("Parent: calling doSomething");
    }
}

Child.java

public class Child extends Parent {
    public synchronized void doSomething() {
        System.out.println("Child: calling doSomething");
        super.doSomething(); // 獲取父類的鎖
    }

    public static void main(String[] args) {
        Child child = new Child();
        child.doSomething();
    }
}

輸出:

Child: calling doSomething
Parent: calling doSomething

如果synchronized不是一個可重入鎖,那麼上面程式碼必將產生死鎖。Child和Parent類中doSomething方法都被synchronized修飾,我們在呼叫子類的過載的方法時,已經獲取到了synchronized鎖,而該方法內又呼叫了父類的doSomething,會再次嘗試獲取該synchronized鎖,如果synchronized不是可重入的鎖,那麼在呼叫super.doSomething()時將無法獲取父類的鎖,執行緒會永遠停頓,等待一個永遠也無法獲得的鎖,即發生了死鎖。

四、活躍性與效能

前面在內建鎖部分提到過,如果用synchronized關鍵字修飾因式分解的service方法,那麼每次只有一個執行緒可以執行,程式的效能將會非常低下,當多個請求同時到達因式分解Servlet時,這個應用便會成為 Poor Concurrency。

那麼,難道我們就不能使用synchronized了嗎?

當然不是的,只是我們需要恰當且小心地使用。

我們可以通過縮小同步塊,來做到既能確保Servlet的併發性,又能保證執行緒安全性。我們應該儘量將不影響共享狀態且執行時間較長的操作從同步塊中分離,從而縮小同步塊的範圍。

下面來看在JCIP中,作者是怎麼實現在簡單性和併發性之間的平衡的:

快取最近執行因數分解的數值及其計算結果的Servlet(執行緒安全且高效的)

    @ThreadSafe
    public class CachedFactorizer implements Servlet {
        @GuardedBy("this") private BigInteger lastNumber;
        @GuardedBy("this") private BigInteger[] lastFactors;
        @GuardedBy("this") private long hits;
        @GuardedBy("this") private long cacheHits;

        // 因為hits和cacheHits也是共享變數,所以需要使用同步 *3
        public synchronized long gethits() {
            return hits;
        }
        public synchronized double getCacheHitRatio() {
            return (double) cacheHits / (double) hits;
        }

        public void service(ServletRequest req, ServletResponse resp) {
            // 區域性變數,不會共享,無需同步
            BigInteger i = extractFromRequest(req);
            BigInteger[] factors = null;

            synchronized (this) { // *2
                ++hits;
                // 命中快取
                if (i.equals(lastNumber)) {
                    ++cacheHits;
                    factors = lastFactors.clone();
                }
            }

            // 沒命中,則進行計算
            if (factors == null) {
                factors = factor(i); // *3
                // 同步更新兩個共享變數
                synchronized (this) { // *1
                    lastNumber = i;
                    lastFactors = factors.clone();
                }
            }

            encodeIntoResponse(resp, factors);
        }

    }

首先,lastNumber和lastFactors作為兩個共享變數是肯定需要同步更新的,因此在 1 處進行了同步。然後,在 2 處,判斷是否命中快取的操作序列也必須同步。此外,在 *3 處,快取命中計數器的實現也需要實現同步,因為計數器是共享的。

安全性是實現了,那麼效能上呢?

前面我們說過,應該儘量將 不影響共享狀態執行時間較長 的操作從同步塊中分離,從而縮小同步塊的範圍。那麼這個Servlet裡不影響共享狀態的就是i和factos這兩個區域性變數,可以看到作者已經將其分離出;執行時間較長的操作就是因式分解了,在 *3 處,CachedFactorizer已經釋放了前面獲得的鎖,在執行因式分解時不需要持有鎖。

因此,這樣既確保了執行緒安全,又不會過多影響併發性,並且在每個同步塊內的程式碼都“足夠短”。

總之,在併發程式碼的設計中,我們要儘量設計好每個同步塊的大小,在併發性和安全性上做好平衡。

參考自:
《Java Concurrency in Practice》
以及其他網路資源

相關文章