Java併發編程實戰讀書筆記——第二章線程安全性

提示(點擊跳轉)

2.1 什麼是線程安全?

2.2 原子性

2.2.1 競態條件
2.2.2 延遲初始化中的竟態條件
2.2.3 複合操作

2.3 加鎖機制

2.3.1 內置鎖
2.3.2 重入

2.4用鎖來保護狀態

2.5 活躍性與性能


Java中主要的同步機制有關鍵字synchronized,volatile變量顯示鎖原子變量

2.1 什麼是線程安全?

  • 線程安全就是當多個線程訪問某個類時,這個類始終都能表現出正確的行爲。
  • 下面代碼展示了無狀態對象(類),應爲裏面沒有可變的變量或狀態。所以一定是線程安全的。
/**
 *因爲訪問此方法,不會影響另一個線程對其訪問計算的結果,所以線程安全。
 * 也就是多個線程去訪問此方法結果是不互相干擾的,沒有共享的變量。
 * 它不包含共享的變量和其他域中的引用,計算過程中臨時狀態僅存在線程棧上的局部變量中。
 */
@ThreadSafe
public class StatelessFactorizer implements Servlet {
    public void service(ServletRequest req, ServletResponse resp){
        BigInteger i = extractFromRequest(req);//獲取數
        BigInteger[] factors = factor(i);//對servlet中數進行因數分解
        encodeIntResponse(resp,factors);//將因數進行返回
    }
}

2.2 原子性

所謂的原子性,要不都一次執行,別的線程不能干擾,要不都不執行。

/**
 * 統計已處理請求的個數
 * 這個爲線程不安全。假設count的初始值爲5,當來多個線程去執行service的時候,都將其值改爲了6,這就造成了
 * 嚴重錯誤。
 */
@NotThreadSafe
public class UnsafeCountingFactorizer implements Servlet {
    private long count = 0;
    public long getCount(){
        return count;
    }
    public void service(ServletRequest req, ServletResponse resp){
        BigInteger i = extractRequest(req);
        BigInteger[] factors = factors(i);
        ++count;
        encodeIntResponse(resp,factors);
    }
}

上面代碼分析原因:
在這個類中有了共享變量count,同時++count的操作不是原子性的,有三個獨立的操作在進行“讀入-修改-寫入”的操作序列,並且最終結果依賴於之前count的狀態。n(n>2)個線程到來都進行讀入count之前狀態,然後進行++操作,最終寫入只是將count+1了,實際應該是count+n。

2.2.1 競態條件
  • 當某個計算的正確性取決於多個線程的交替執行時序時,就會發現競態條件。
  • 常見的競態條件類型有:“先檢查後執行”,“讀入-修改-寫入”。本質是基於可能失效的觀察結果來做出後序的判斷或計算,因爲此時在檢查到執行的過程中可能會有別的線程來改變你原來的觀察結果。
2.2.2 延遲初始化中的竟態條件

是對“先檢查後執行給了一個示例” 。拿未加鎖的懶漢式單例做例子。

/**
 * 因爲if-else的存在有了“先檢查後執行”,存在竟態條件。會造成線程不安全。
 */
@NotThreadSafe
public class LazyInitRace {
    //私有屬性
    private LazyInitRace instance =null;
    //私有構造器
    private LazyInitRace(){ };
    //暴露方法
    public LazyInitRace getInstance(){
        if(instance == null){
            instance = new LazyInitRace();
        }
        return instance;
    }
}
2.2.3 複合操作
  • 前面的“先檢查後執行”,“讀入-修改-寫入”被稱爲複合操作。
  • 在2.2UnsafeCountingFactorizer和2.2.2LazyInitRace對其“讀入-修改-寫入”和“先檢查後執行”的操作需要包含一組以原子方式執行(或者說不可分割)的操作。要避免競態條件問題,就必須在某個線程修改該變量時,通過某種方式防止其他線程使用這個變量,從而確保其他線程只能在修改報作完成之前或之後達取和修改狀態,而不是在修改狀態的過程中。
  • 在這裏使用原子變量類使得複合操作變爲原子操作。
/**
 *使用了java中java.util.concurrent.atomic包中的一些原子變量類
 */
@ThreadSafe
public class CountingFactorizer implements Servlet {
    private final AtomicLong count = new AtomicLong(0);//使用原子變量類,實現數值上的原子狀態裝換
    public long getCount(){
        return count.get();
    }

    @Override
    public void service(ServletRequest req, ServletResponse resp) {
        BigInteger i = extractRequest(req);
        BigInteger[] factors = factors(i);
        count.incrementAndGet();//原子遞增當前值
        encodeIntResponse(resp,factors);
    }
}

對原子變量類的解釋:
 在 javautil. concurrentatomic包中包含了一些原子變量類,用於實現在數值和對象引用上的原子狀態轉換。通過用AtomicLong來代替long類型的計數器,能夠確保所有對計數器狀態的訪問操作都是原子的。由於Servlet的狀態就是計數器的狀態,並且計數器是線程安全的,因此這裏的Servlet也是線程安全的。

 AtomicLong的底層實際使用了關鍵字volatile.

public class AtomicLong extends Number implements java.io.Serializable {
    private static final jdk.internal.misc.Unsafe U = jdk.internal.misc.Unsafe.getUnsafe();
    private static final long VALUE = U.objectFieldOffset(java.util.concurrent.atomic.AtomicLong.class, "value");
    private volatile long value;

    public final long get() {
        return value;
    }
    //原子遞增當前值
    public final long incrementAndGet() {
        return U.getAndAddLong(this, VALUE, 1L) + 1L;
    }
}

2.3 加鎖機制

在無狀態中添加一個狀態由線程安全的對象來管理,這個類是線程安全的(2.2.3中的CountingFactorizer),但是存在多個狀態變量就算每個都由變爲原子的,該類依然不安全。

/**
 * 緩存上一次的數和結果,如果下一次來的數相同,則直接返回。
 * 下面將變量都包裝成爲了線程安全,但是其整體類卻還是線程不安全的,因爲變量和變量之間有依賴順序
 * 其實還存在竟態條件。需要將方法的操作也變爲原子的(或加鎖)
 */
@NotThreadSafe
public class UnsafeCachingFactorizer implements Servlet {
    //AtomicReference是替代對象引用的線程安全類
    private final AtomicReference<BigInteger> lastNumber = new AtomicReference<>();//@1
    private final AtomicReference<BigInteger[]> lastFactors = new AtomicReference<>();//@2
    public void service(ServletRequest req, ServletResponse resp){//@3
        BigInteger i = extractFromRequest(req);
        if(i.equals(lastNumber.get())){//@3.1
            encodeIntoResponse(resp,lastFactors.get());
        }else {//@3.2
            BigInteger[] factors = factor(i);
            lastNumber.set(i);
            lastFactors.set(factors);
            encodeIntoResponse(resp,lastFactors.get());
        }
    }
}

代碼思考

  • @1和@2行變量爲了防止出現“讀入-修改-寫入”的問題,將其用原子變量類包裝。@3行的方法依然會造成線程不安全。假如一個線程進入@3.2行執行將lastNumber修改,此時另一個線程進入@3行,執行@3.1那麼獲取到的lastFactirs就是上一次未改變的。因此此方法必須是原子操作或是加鎖。
  • 如果要保證狀態的一致性,必須將多個變量的所有狀態更改變成原子性,一次性修改完成。
2.3.1 內置鎖
  • Java提供了一種內置的鎖機制來支持原子性:同步代碼塊。
    同步代碼塊鎖的是方法調用所在的對象,靜態的synchronized方法以Class對象作爲鎖。
  • 每個Java對象都可以用做一個實現同步的鎖,這些鎖被稱爲內置鎖( Intrinsic Lock)或監視器鎖( Monitor Lock)。線程在進入同步代碼塊之前會自動獲得鎖,並且在退出同步代碼塊時自動釋放鎖。獲得內鎖的唯一途徑就是進入由這個鎖保護的同步代碼塊或方法。
  • Java的內置鎖相當於一種互斥體(或互斥鎖,由於每次只能有一個線程執行內置鎖保護的代碼塊,因此,由這個鎖保護的同步代碼塊會以原子方式執行,多個線程在執行該代碼塊時也不會相互干擾。任何一個執行同步代碼塊的線程,都不可能看到有其他線程正在執行由同一個鎖保護的同步代碼塊。
/**
 * 使用synchronized來給方法加鎖
 */
@ThreadSafe
public class SynchronizedFactorizer implements Servlet {
    //AtomicReference是替代對象引用的線程安全類
    private final AtomicReference<BigInteger> lastNumber = new AtomicReference<>();
    private final AtomicReference<BigInteger[]> lastFactors = new AtomicReference<>();
    public synchronized void service(ServletRequest req, ServletResponse resp){
        BigInteger i = extractFromRequest(req);
        if(i.equals(lastNumber.get())){
            encodeIntoResponse(resp,lastFactors.get());
        }else {
            BigInteger[] factors = factor(i);
            lastNumber.set(i);
            lastFactors.set(factors);
            encodeIntoResponse(resp,lastFactors.get());
        }
    }
}

代碼思考

  • 當一個線程來執行service(),只有獲得當前對象的內置鎖,才能去執行此service()方法,並且去調用當前對象的兩個屬性lastNumber和lastFactors,改變其狀態。
  • 在程序中使用了關鍵字synchronized來修飾service方法,因此在同一時刻只有一個線程可以執行service方法。(因爲一個線程獲得當前對象的內置鎖,其他的線程要去等待)現在的SynchronizedFactorizer是線程安全的。然而,這種方法卻過於極端,因爲多個客戶端無法同時使用因數分解Servlet,服務的響應性非常低。
2.3.2 重入
  • 當某個線程請求一個由其他線程持有的鎖時,發出請求的線程就會阻塞。內置鎖是可重入的,也就是某個線程試圖獲取一個已經由它自己持有的鎖,那麼這個請求就會成功。“重人”意味節獲取鎖的操作的粒度是線程而不是“調用”。
  • 重入的原理:
    爲每個鎖關聯一個獲取計數值和一個所有者線程。當計數值爲0時,這個鎖就被認爲沒有被任何線程持有。當線程請求一個未被持有的鎖時,JVM將記下鎖的持有者,並且將獲取計數值置爲1.如果同一個線程再次獲取這個鎖,計數值將遞增,而當線程退出同步代碼塊時,計數器會相應地遞減。當計數值爲0時,這個鎖將被釋放。
/**
 *如果內置鎖不是可重入的話,這段代碼就會發生死鎖。
 */
public class Widget {
    public synchronized void doSomething(){}
}
class LoggingWidget extends Widget{
    public synchronized void dosomething(){
        System.out.println(toString()+"calling doSomehing");
        super.doSomething();
    }
}

代碼思考:
  子類重寫了父類的 synchronized方法,然後調用父類中的方法,此時如果沒有可重入的鎖,那麼這段代碼將產生死鎖。由於 Widget和 LoggingWidget中 doSomething方法都是 synchronized方法,因此每個 dosomething方法在執行前都會獲取Widget的鎖,然而,如果內置鎖不是可重入的,那麼在調用 super doSomething時將無法獲得 widget上的鎖,因爲這個鎖已經被持有,從而線程將永遠停頓下去,等待一個永遠也無法獲得的鎖。重入則避免了這種死鎖情況的發生。
問題:
  1.線程獲取的鎖是誰的鎖?假設new了一個LoggingWidget類型的對象A,是獲取了當前對象A的鎖嗎?(自己理解Java中鎖基於對象的,每個對象都有自己的鎖。)
  2.爲神魔書中說是因此每個dosomething方法在執行前都會獲取Widget的鎖。其子類不應該獲取LoggingWidget的鎖嗎?

2.4 用鎖來保護狀態

  • 在2.3.1的程序中lastNumber和lastFactors這兩個變量都是由Servlet的當前對象的內置鎖 來保護的。
  • 之所以每個對象都有一個內置鎖,只是爲了免去顯式地創建鎖對象。
  • 並非所有數據都需要鎖的保護,只有被多個線程同時訪問的可變數據才需要通過鎖來保護。

2.5 活躍性與性能

  • 在2.3UnsafeCachingFactorizer代碼中,我們通過在因數分解Servlet中引入了緩存機制來提升性能。在緩存中需要使用共享狀態,因此需要通過同步來維護狀態的完整性。然而,如果使用SynchronizedFactorizer中的同步方式,那麼代碼的執行性能將非常糟糕。2.3.1中SynchronizedFactorizer中直接對整個方法進行synchronized,這樣每次只能有一個線程可以執行,背離了Servlet的設計初衷,其實需要同時處理多個請求的。
@ThreadSafe
public class CachedFactorizer implements Servlet {
    private BigInteger lastNumber;//緩存上一次的數
    private BigInteger[] lastFactors;//緩存上一次數lastNumber的因數
    private long hits;//執行請求的個數
    private long cachehits;//所有請求中直接和lastNumber相同,使用lastFactors的請求的個數
    //獲取hits的個數
    public synchronized long getHits(){
        return hits;
    }
    //獲取命中緩存的請求個數
    public synchronized double getCachehits(){
        return (double)cachehits/(double)hits;
    }
    //Servlet進行服務
    public void service(ServletRequest req, ServletResponse resp){
        BigInteger i = extractFromRequest(req);//獲取到計算的數
        BigInteger[] factors = null;//存儲i的因數,每一個請求到來都會刷新爲null
        //對代碼塊進行加鎖
        /*1.++hits是“讀入-修改-寫入”的符合操作,使得其變爲原子操作
        * 2.這裏的if語句存在了一個“先檢查後執行”的符合操作也是線程不安全的,所以加鎖,也是其變爲了原子操作
        * 提示:this標識只有獲得當前對象的鎖纔可以進入執行
        */
        synchronized (this){
            ++hits;
            if (i.equals(lastNumber)) {
                ++cachehits;
                factors = lastFactors.clone();
            }
        }
        if(factors==null){
            factors = factor(i);//求i的因數
            /**
             * 如果本次沒有命中緩存,則將lastNumber和lastFactors刷新
             * 對其進行加鎖,使其操作原子化
             */
            synchronized (this){
                lastNumber = i;
                lastFactors = factors.clone();
            }
        }
        encodeIntResponse(req,resp);
    }
}
  • 在CachedFactorizer中不再使用AtomicLong類型的命中計數器,而是使用了一個 long類型的變量。當然也可以使用AtomicLong類型,對在單個變量上實現原子操作來說,=原子變量是很有用的,但由於我們已經使用了同步代碼塊來、構造原子操作,而使用兩種不同的同步機制不僅會帶來混亂,也不會在性能或安全性上帶來任何好處,因此在這裏不使用原子變量。

使用鎖時應該考慮的:

  • 重新構造後的CachedFactorizer實現了在簡單性(對整個方法進行同步)與併發性(對儘可能短的代碼路徑進行同步)之間的平衡。在獲取與釋放鎖等操作上都需要一定的開銷,因此如果將同步代碼塊分解得過細(例如將++hits分解到它自己的同步代碼塊中),那麼通常並不好,儘管這樣做不會破壞原子性。當訪問狀態變量或者在複合操作的執行期間,CachedFactorizer 需要持有鎖。但在執行時間較長的因數分解運算之前要釋放鎖。這樣既確保了線程安全性,也不會過多地影響併發性,而且在每個同步代碼塊中的代碼路徑都“足夠短”。要判斷同步代碼塊的合理大小,需要在各種設計需求之間進行權衡。

  • 通常,在簡單性與性能之間存在着相互制約因素。當實現某個同步策略時,一定不要盲目地爲了性能而犧牲簡單性(這可能會破壞安全性)。

  • 當執行時間較長的計算或者可能無法快速完成的操作時(例如,網絡I/O或控制檯I/O),一定不要持有鎖。


點擊回到頂部

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章