JCIP閱讀筆記之線程安全性

本文是做者在閱讀JCIP過程當中的部分筆記和思考,純手敲,若有誤處,請指正,很是感謝~java

可能會有人對書中代碼示例中的註解有疑問,這裏說一下,JCIP中示例代碼的註解都是自定義的,並不是官方JDK的註解,所以若是想要在本身的代碼中使用,須要添加依賴。移步:jcip.net編程

1、什麼是線程安全性?

當多個線程訪問某個類時,這個類始終都能表現出正確的行爲,那麼這個類就是線程安全的。緩存

示例:一個無狀態的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類,可是兩者不會相互影響,最後的計算結果仍然是正確的,爲何呢?由於這兩個線程並無共享狀態,他們各自訪問的都是本身的局部變量,因此像這樣 無狀態的對象都是線程安全的less

大多數Servlet都是線程安全的,因此極大下降了在實現Servlet線程安全性的複雜性。只有在Servlet處理請求須要保存一些信息的狀況下,線程安全性纔會成爲一個問題。ide

2、原子性

我理解的原子性就是指一個操做是最小範圍的操做,這個操做要麼完整的作要麼不作,是一個不可分割的操做。好比一個簡單的賦值語句 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、靜態內部類、枚舉均可以保證線程安全,在這裏就不贅述了。

3、加鎖機制

仍是回到因數分解那個例子,若是但願提高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()時將沒法獲取父類的鎖,線程會永遠停頓,等待一個永遠也沒法得到的鎖,即發生了死鎖。

4、活躍性與性能

前面在內置鎖部分提到過,若是用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》以及其餘網絡資源

相關文章
相關標籤/搜索