Java核心技術Ι(2)——併發之同步

競爭條件

    在大多數實際的多線程應用中, 兩個或兩個以上的線程需要共享對同一數據的存取。如果兩個線程存取相同的對象,並且每個線程都調用了一個修改該對象狀態的方法,這樣線程之間就會相互影響。根據各線程訪問數據的次序,可能會產生訛誤的對象,這樣的情況通常稱爲競爭條件(race condition)。爲了避免多線程引起的對共享數據的訛誤,必須學習如何同步存取。下面我們舉個例子,模擬一個有若干賬戶的銀行。隨機地生成在這些賬戶之間轉移錢款的交易。每一個賬戶有一個線程。每一筆交易中, 會從線程所服務的賬戶中隨機轉移一定數目的錢款到另一個隨機賬戶。

public void transfer(int from, int to, double amount) {
    // CAUTION: unsafe when called from multiple threads
    System.out.println(Thread.currentThread());
    accounts[from] -= amount;
    System.out.printf(" %10.2f from %d to %d", amount, from, to);
    accounts[to] += amount;
    System.out.printf(" Total Balance: %10.2f%n", getTotalBalance());
}

當幾個線程更新銀行賬戶餘額,一段時間後,錯誤不知不覺地出現了,總額要麼增加要麼減少,Oh My God!我怎麼敢把我的錢存在這樣的銀行裏面?產生這個問題的原因主要是轉賬操作不是原子性的,假設兩個線程同時執行指令,該指令可能被處理如下:
(1) 將 accounts[to] 加載到寄存器;
(2) 增加 amount
(3) 將結果寫回 accounts[to]

現在,假定第 1 個線程執行步驟 1 和 2,然後它被剝奪了運行權。假定第 2 個線程被喚醒並修改了 accounts 數組中的同一項。然後第 1 個線程被喚醒並完成其第 3 步。這樣, 這一動作擦去了第二個線程所做的更新,於是,總金額不再正確,具體過程如下圖所示:
在這裏插入圖片描述
如果每個線程在再次睡眠之前所做的工作越少,那麼出現訛誤的風險會越低,因爲調度器在計算過程中剝奪線程的運行權可能性會更小(然並卵,會出錯就不得行啊)。

鎖對象

    有兩種機制防止代碼塊受併發訪問的干擾:

  • Java提供一個synchronized關鍵字達到這一目的,這一關鍵字自動提供一個鎖及相關“條件”,對於大多數需要顯式鎖的情況,這個關鍵字很便利;
  • Java SE 5.0引入了ReentrantLock類,確保任何時刻只有一個線程進入臨界區。一旦一個線程封鎖了鎖對象,其它任何線程都無法通過lock語句。當其它線程調用lock時,他們被阻塞,直到第一個線程釋放鎖對象。
    ReentrantLock保護代碼塊的基本結構如下:
mylock.lock(); // a ReentrantLock object
try {
    critical section
} finally {
    // make sure the lock is unlocked even if an exception is thrown
    myLock.unlock(); 
}

注意

  • 把解鎖操作放在 finally 子句之內是至關重要的。如果在臨界區的代碼拋出異常,鎖必須被釋放。否則其他線程將永遠阻塞。
  • 如果使用鎖, 就不能使用帶資源的 try 語句。首先, 解鎖方法名不是 close。不過,即使將它重命名, 帶資源的 try 語句也無法正常工作。
    假定一個線程調用 transfer, 在執行結束前被剝奪了運行權。假定第二個線程也調用 transfer,由於第二個線程不能獲得鎖, 將在調用 lock 方法時被阻塞。它必須等待第一個線程完成 transfer 方法的執行之後才能再度被激活。當第一個線程釋放鎖時, 那麼第二個線程才能開始運行,運行情況如下圖所示:
    非同步線程與同步線程比較
    鎖是可重入的,因爲線程可以重複地獲得已經持有的鎖。鎖保持一個持有計數(hold count)來跟蹤對 lock 方法的嵌套調用。線程在每一次調用 lock 都要調用 unlock 來釋放鎖。由於這一特性, 被一個鎖保護的代碼可以調用另一個使用相同的鎖的方法。

【java.util.concurrent.locks.Lock 5.0】

  1. void lock()
    獲取這個鎖;如果鎖同時被另一個線程擁有則發生阻塞。
  2. void unlock()
    釋放這個鎖

【java.util.concurrent.locks.ReentrantLock 5.0】

  1. ReentrantLock()
    構建一個可以被用來保護臨界區的可重入鎖。
  2. ReentrantLock(boolean fair)
    構建一個帶有公平策略的鎖。一個公平鎖偏愛等待時間最長的線程。但是,這一公平的保證將大大降低性能。所以,默認情況下,鎖沒有被強制爲公平的。

條件對象

    通常, 線程進人臨界區,卻發現在某一條件滿足之後它才能執行。要使用一個條件對象來管理那些已經獲得了一個鎖但是卻不能做有用工作的線程。下面我們介紹Java 庫中條件對象的實現。(由於歷史的原因, 條件對象經常被稱爲條件變量 conditional variable
    一個鎖對象可以有一個或多個相關的條件對象。你可以用 newCondition 方法獲得一個條件對象。習慣上給每一個條件對象命名爲可以反映它所表達的條件的名字。例如,在此設置一個條件對象來表達“餘額充足” 條件。

class Bank {
    private Condition sufficientFunds;
    ...
    public Bank() {
        ...
        sufficientFunds = bankLock.newCondition();
    }
}

如果 transfer 方法發現餘額不足,它調用 sufficientFunds.await()。當前線程現在被阻塞了,並放棄了鎖。我們希望這樣可以使得另一個線程可以進行增加賬戶餘額的操作。等待獲得鎖的線程和調用 await 方法的線程存在本質上的不同。一旦一個線程調用 await 方法, 它進人該條件的等待集。當鎖可用時,該線程不能馬上解除阻塞。相反,它處於阻塞狀態,直到另一個線程調用同一條件上的 signalAll 方法時爲止。當另一個線程轉賬時, 它應該調用 sufficientFunds.signalAll()。這一調用重新激活因爲這一條件而等待的所有線程,當這些線程從等待集當中移出時,它們再次成爲可運行的,調度器將再次激活它們。同時,它們將試圖重新進人該對象。一旦鎖成爲可用的,它們中的某個將從 await 調用返回,獲得該鎖並從被阻塞的地方繼續執行。此時, 線程應該再次測試該條件。 由於無法確保該條件被滿足— signalAll 方法僅僅是通知正在等待的線程:此時有可能已經滿足條件, 值得再次去檢測該條件。

通常對await()的調用應該在如下形式的循環體中:
while (!(ok to proceed)) {
    condition.await();
}

至關重要的是最終需要某個其他線程調用 signalAll 方法。當一個線程調用 await 時,它沒有辦法重新激活自身,它寄希望於其他線程。如果沒有其他線程來重新激活等待的線程,它就永遠不再運行了。這將導致令人不快的死鎖 deadlock 現象。如果所有其他線程被阻塞, 最後一個活動線程在解除其他線程的阻塞狀態之前就調用 await 方法, 那麼它也被阻塞。沒有任何線程可以解除其他線程的阻塞,那麼該程序就掛起了。調用 signalAll 的時機從經驗上講, 應該是在對象的狀態有利於等待線程的方向改變時。
    另一個方法 signal,則是隨機解除等待集中某個線程的阻塞狀態。這比解除所有線程的阻塞更加有效,但也存在危險。如果隨機選擇的線程發現自己仍然不能運行, 那麼它再次被阻塞。如果沒有其他線程再次調用 signal, 那麼系統就死鎖了。

注意: 調用 signalAll 不會立即激活一個等待線程。它僅僅解除等待線程的阻塞, 以便這些線程可以在當前線程退出同步方法之後,通過競爭實現對對象的訪問。

【java.util.concurrent.locks.Lock 5.0】

  1. Condition newCondition()
    返回一個與該鎖相關的條件對象。

【java.util.concurrent.locks.Condition 5.0】

  1. void await()
    將該線程放到條件的等待集中。
  2. void signalAll()
    解除該條件的等待集中的所有線程的阻塞狀態。
  3. void signal()
    從該條件的等待集中隨機地選擇一個線程,解除其阻塞狀態。

鎖和條件的關鍵之處:

  • 鎖用來保護代碼片段, 任何時刻只能有一個線程執行被保護的代碼。
  • 鎖可以管理試圖進入被保護代碼段的線程。
  • 鎖可以擁有一個或多個相關的條件對象。
  • 每個條件對象管理那些已經進入被保護的代碼段但還不能運行的線程。

synchronized關鍵字

    從 1.0 版開始,Java中的每一個對象都有一個內部鎖。如果一個方法用 synchronized 關鍵字聲明,那麼對象的鎖將保護整個方法。使用 synchronized 關鍵字來編寫代碼要簡潔得多。當然,要理解這一代碼,你必須瞭解每一個對象有一個內部鎖, 並且該鎖有一個內部條件,由鎖來管理那些試圖進入 synchronized 方法的線程,由條件來管理那些調用 wait 的線程。

public synchronized void method() {
    methond body
}

等價於

public void method() {
    this.intrinsicLock.lock();
    try {
        method body
    } finally {
        this.intrinsicLock.unlock();
    }
}

內部對象鎖只有一個相關條件。 wait 方法添加一個線程到等待集中, notifyAll/notify方法解除等待線程的阻塞狀態。換句話說,調用 waitnotifyAll等價於

intrinsicCondition.await();
intrinsicCondition.signalAll();

註釋:wait、notifyAll以及notify方法是Object類的final方法。Condition方法必須被命名爲await、signalAll和signal以便它們不會與那些方法發生衝突。

    將靜態方法聲明爲 synchronized 也是合法的。如果調用這種方法,該方法獲得相關的類對象的內部鎖。內部鎖和條件存在一些侷限。包括:

  • 不能中斷一個正在試圖獲得鎖的線程。
  • 試圖獲得鎖時不能設定超時。
  • 每個鎖僅有單一的條件,可能是不夠的。

在一般情況下,最好既不使用 Lock/Condition 也不使用 synchronized 關鍵字。在許多情況下你可以使用 java.util.concurrent 包中的一種機制,它會爲你處理所有的加鎖。如果 synchronized 關鍵字適合你的程序, 那麼請儘量使用它,這樣可以減少編寫的代碼數量,減少出錯的機率。除非特別需要 Lock/Condition提供的獨有特性,才使用它。

【java.lang.Object 1.0】

  1. void notifyAll()
    解除那些在該對象上調用wait方法的線程的阻塞狀態。該方法只能在同步方法或同步塊內部調用。如果當前線程不是對象鎖的持有者,該方法拋出一個IllegalMonitorStateException異常。
  2. void notify()
    隨機選擇一個在該對象上調用wait方法的線程,解除其阻塞狀態。該方法只能在一個同步方法或同步塊中調用。如果當前線程不是對象鎖的持有者,該方法拋出一個IllegalMonitorStateException異常。
  3. void wait(long millis)
  4. void wait(long millis, int nanos)
    導致線程進入等待狀態直到它被通知或經過指定的時間。這些方法只能在一個同步方法中調用。如果當前線程不是對象鎖的持有者該方法拋出一個IllegalMonitorStateException異常。

同步阻塞

    每一個 Java 對象有一個鎖,線程可以通過調用同步方法獲得鎖。還有另一種機制可以獲得鎖,通過進入一個同步阻塞。當線程進入如下形式的阻塞:

synchronized(obj) // this is the syntax for a synchronized block
{
    critical section
}

於是可以獲得 obj 的鎖。有時程序員使用一個對象的鎖來實現額外的原子操作, 稱爲客戶端鎖定 client-side locking,客戶端非常脆弱,通常不推薦使用。

監視器

    鎖和條件是線程同步的強大工具,但是,嚴格地講,它們不是面向對象的。多年來,研究人員努力尋找一種方法,可以在不需要程序員考慮如何加鎖的情況下,就可以保證多線程的安全性。最成功的解決方案之一是監視器 monitor,這一概念最早是由PerBrinchHansen 和 Tony Hoare 在 20 世紀 70 年代提出的。監視器的特性如下:

  • 監視器是隻包含私有域的類。
  • 每個監視器類的對象有一個相關的鎖。
  • 使用該鎖對所有的方法進行加鎖。換句話說,如果客戶端調用 obj.method(), 那麼 obj對象的鎖是在方法調用開始時自動獲得, 並且當方法返回時自動釋放該鎖。因爲所有的域是私有的,這樣的安排可以確保一個線程在對對象操作時,沒有其他線程能訪問該域。
  • 該鎖可以有任意多個相關條件。

    Java 設計者以不是很精確的方式採用了監視器概念, Java 中的每一個對象有一個內部的鎖和內部的條件。如果一個方法用 synchronized 關鍵字聲明,那麼,它表現的就像是一個監視器方法。通過調用 wait/notifyAll/notify 來訪問條件變量。然而, 在下述的 3 個方面 Java 對象不同於監視器, 從而使得線程的安全性下降:

  • 域不要求必須是 private
  • 方法不要求必須是 synchronized
  • 內部鎖對客戶是可用的。

volatile

    有時,僅僅爲了讀寫一個或兩個實例域就使用同步,顯得開銷過大了。畢竟,什麼地方能出錯呢? 遺憾的是,使用現代的處理器與編譯器,出錯的可能性很大:

  • 多處理器的計算機能夠暫時在寄存器或本地內存緩衝區中保存內存中的值。結果是,
    運行在不同處理器上的線程可能在同一個內存位置取到不同的值。
  • 編譯器可以改變指令執行的順序以使吞吐量最大化。這種順序上的變化不會改變代碼
    語義,但是編譯器假定內存的值僅僅在代碼中有顯式的修改指令時纔會改變。然而,
    內存的值可以被另一個線程改變(- -! 尷尬)。

當然,如果你使用鎖來保護可以被多個線程訪問的代碼,那麼可以不考慮這種問題。 volatile 關鍵字爲實例域的同步訪問提供了一種免鎖機制。如果聲明一個域爲 volatile ,那麼編譯器和虛擬機就知道該域是可能被另一個線程併發更新的。volatile 關鍵字有以下效果:

  • 使用 volatile 關鍵字會強制將修改的值立即寫入主存。
  • 使用 volatile 關鍵字的話,假設有兩個線程在修改同一個值,那麼當線程2進行修改時,會導致線程1的工作內存中緩存變量stop的緩存行無效。
  • 由於線程1的工作內存中緩存變量 stop 的緩存行無效,所以線程1再次讀取變量 stop 的值時會去主存讀取。

假設對共享變量除了賦值之外並不完成其他操作,那麼可以將這些共享變量聲明爲
volatile

final

    將域聲明爲final可以安全的訪問一個共享域,例如:

final Map<String, Double> accounts = new HashMap<>();

其它線程會在構造函數完成構造之後纔看到這個 accounts 變量,如果不使用 final,就不能保證其他線程看到的是 accounts 更新後的值,它們可能都只是看到 null, 而不是新構造的 HashMap。當然,對這個映射表的操作並不是線程安全的。如果多個線程在讀寫這個映射表,仍然需要進行同步。

原子性

    java.util.concurrent.atomic 包中有很多類使用了很高效的機器級指令(而不是使用鎖) 來保證其他操作的原子性。下面舉幾個例子:
(1) 方法 incrementAndGetdecrementAndGet 分別以原子方式將一個整數自增或自減。例如,可以安全地生成一個數值序列:

public static AtomicLong nextNumber = new AtomicLong();
// In some thread...
long id = nextNumber.incrementAndGet();

(2) 方法 compareAndSet 可以完成更復雜的更新,例如,假設希望跟蹤不同線程觀察的最大值:

do {
    oldValue = largest.get();
    newValue = Math.max(oldValue, observed);
} while (!largest.compareAndSet(oldValue, newValue));

如果另一個線程也在更新 largest,就可能阻止這個線程更新。這樣一來, compareAndSet 會返回 false,而不會設置新值。在這種情況下,循環會讀取更新後的值,並嘗試修改。最終,它會成功地用新值替換原來的值。
(3) 方法 updateAndGetaccumulateAndGet 也可以完成上述功能:

largest.updateAndGet(x -> Math.max(x, observed));

或者

largest.accumulateAndGet(observed, Math::max);

accumulateAndGet 方法利用一個二元操作符來合併原子值和所提供的參數。
(4) 如果有大量線程要訪問相同的原子值,性能會大幅下降,因爲樂觀更新需要太多次重
試。Java SE 8 提供了 LongAdderLongAccumulator 類來解決這個問題。LongAdder 包括多個變量(加數),其總和爲當前值。可以有多個線程更新不同的加數,線程個數增加時會自動提供新的加數。通常情況下, 只有當所有工作都完成之後才需要總和的值, 對於這種情況,這種方法會很高效,性能會有顯著的提升。

LongAccumulator adder = new LongAccumulator(Long::sum, 0);
// In some thread...
adder.accumulate(value);

在內部,這個累加器包含變量 a1,a2,...,ana_1,a_2,...,a_n,每個變量初始化爲零元素(這個例子中零元素爲0)。調用 accumulate 並提供值 v 時,其中一個變量會以原子方式更新爲 ai=aiopva_i = a_i\,op\,v,這裏的opop是中綴形式的累加操作。在上面的例子中,調用 accumulate 會對某個ii計算ai=ai+va_i = a_i+vget 的結果是a1opa2op...opana_1\,op\,a_2\,op\,...op\,a_n。在上述例子中,這就是累加器的總和:a1+a2+...+ana_1+a_2+...+a_n。如果選擇一個不同的操作,可以計算最小值或最大值。一般情況下,這個操作必須滿足結合律和交換律。這說明,最終結果必須獨立於所結合的中間值的順序。

死鎖

    鎖和條件不能解決多線程中的所有問題。有可能會因爲每一個線程要等待條件滿足等原因而導致所有線程都被阻塞,這樣的狀態稱爲死鎖(deadlock)。還有一種很容易導致死鎖的情況: 某些情況下不使用 signalAll 方法而使用 signal方法,signalAll 會通知所有等待中的線程,而 signal 方法僅僅對一個線程解鎖。由於Java 編程語言中沒有任何東西可以避免或打破這種死鎖現象。所以我們必須仔細設計程序, 以確保不會出現死鎖。

線程局部變量

    線程共享變量會存在很多風險,有時可能要避免共享變量, 使用 ThreadLocal 輔助類爲各個線程提供各自的實例。例如,SimpleDateFormat 類不是線程安全的。假設有一個靜態變量:

public static final SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd");

如果兩個線程都執行以下操作:

String dateStamp = dateFormat.format(new Date());

結果可能很混亂,因爲 dateFormat 使用的內部數據結構可能會被併發的訪問所破壞。當然可以使用同步,但開銷很大;或者也可以在需要時構造一個局部 SimpleDateFormat 對象,不過這太浪費。要爲每個線程構造一個實例,可以這樣:

public static final ThreadLocal<SimpleDateFormat> dateFormat = ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));

要訪問具體的格式化方法,可以調用:

String dateStamp = dateFormat.get().format(new Date());

在一個給定線程中首次調用 get 時,會調用 initialValue 方法。在此之後,get 方法會返回屬於當前線程的那個實例。在多個線程中生成隨機數也存在類似的問題。java.util.Random 類是線程安全的。但是如果多個線程需要等待一個共享的隨機數生成器,這會很低效。也可以使用 ThreadLocal 輔助類爲各個線程提供一個單獨的生成器,不過Java SE 7還另外提供了一個便利類。只需要做以下調用:

int random = ThreadLocalRandom.current().nextInt(upperBound);

ThreadLocalRandom.current() 調用會返回特定於當前線程的 Random 類實例。

鎖測試與超時

    線程在調用 lock 方法來獲得另一個線程所持有的鎖的時候,很可能發生阻塞。應該更加謹慎地申請鎖。tryLock 方法試圖申請一個鎖, 在成功獲得鎖後返回 true,否則, 立即返回 false, 而且線程可以立即離開去做其他事情。

if (myLock.tryLock()) {
    // now the thread owns the lock
    try {...}
    finally { myLock.unlock(); }
} else {
    // do something else
}

調用 tryLock 時可以使用超時參數:

if (myLock.tryLock(100, TimeUnit.MILLISECONDS)) ...

也可以調用 lockInterruptibly方法,它相當於一個超時設爲無限的 tryLock 方法。locktryLock的區別

  • lock方法不能被中斷,如果一個線程在等待獲得一個鎖時被中斷,中斷線程在獲得鎖之前會一直處於阻塞狀態,如果出現死鎖,lock 方法則無法終止。
  • 如果調用帶有超時參數的 tryLock,那麼如果線程在等待期間被中斷,將拋出 InterruptedException異常,這會允許程序打破死鎖。

讀/寫鎖

    java.util.concurrent.locks 包 定 義 了 兩 個 鎖 類, 我 們 已 經 討 論 的 ReentrantLock 類 和 ReentrantReadWriteLock 類。 如果很多線程從一個數據結構讀取數據而很少線程修改其中數據的話, 後者是十分有用的。在這種情況下, 允許對讀者線程共享訪問是合適的。當然,寫者線程依然必須是互斥訪問的。下面是使用讀 / 寫鎖的必要步驟:
(1) 構 造 一 個 ReentrantReadWriteLock 對象:

private ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();

(2) 抽取讀鎖和寫鎖:

private Lock readLock = rwl.readLock();
private Lock writeLock = rwl.writeLock();

(3) 對所有的獲取方法加讀鎖:

public double getTotalBalance() {
    readLock.lock();
    try { ... }
    finally { readLock.unlock(); }
}

(4) 對所有的修改方法加寫鎖:

public void transfer(...) {
    writeLock.lock();
    try { ... }
    finally { writeLock.unlock(); }
}

【java.util.concurrent.locks.ReentrantReadWriteLock 5.0】

  1. Lock readLock()
    得到一個可以被多個讀操作共用的讀鎖,但會排斥所有寫操作。
  2. Lock writeLock()
    得到一個寫鎖,排斥所有其他的讀操作和寫操作。

爲什麼棄用 stop 和 suspend 方法

    初始的 Java 版本定義了一個 stop 方法用來終止一個線程, 以及一個 suspend 方法用來阻塞一個線程直至另一個線程調用 resumestopsuspend 方法有一些共同點:都試圖控制一個給定線程的行爲。stopsuspendresume 方法已經棄用。stop 方法天生就不安全,經驗證明 suspend 方法會經常導致死鎖。
    首先來看看 stop 方法, 該方法終止所有未結束的方法, 包括 run 方法。當線程被終止,立即釋放被它鎖住的所有對象的鎖,這會導致對象處於不一致的狀態。當線程要終止另一個線程時, 無法知道什麼時候調用 stop 方法是安全的, 什麼時候導致對象被破壞,因此,該方法被棄用了。在希望停止線程的時候應該中斷線程, 被中斷的線程會在安全的時候停止。
    接下來, 看看 suspend 方法有什麼問題。與 stop 不同,suspend 不會破壞對象。但是,如果用 suspend 掛起一個持有一個鎖的線程, 那麼該鎖在恢復之前是不可用的。如果調用 suspend 方法的線程試圖獲得同一個鎖, 那麼程序死鎖: 被掛起的線程等着被恢復,而將其掛起的線程等待獲得鎖

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