天天看點

5000字, 講透如何保證線程安全!

前言

線程安全問題一直是并發程式設計中比較讓人頭疼的問題.

通過前面的學習, 我們知道:

線程之是以不安全, 主要是多線程下對可變的共享資源的争用導緻的.

衡量線程是否安全, 主要從三個特性入手

  • 原子性
  • 可見效
  • 有序性

隻要保證了這三個特性,我們就認為線程是安全的, 多線程下執行結果才會和單線程執行結果統一起來.

本章,我們就來聊聊”如何保證線程安全“的問題.

如何保證原子性

常用的保證Java操作原子性的工具是"鎖和同步方法(或者同步代碼塊)".

我們舉個例子: 

public class Test {
    private static int count = 0;

    public static void addCount() {
         count++;
    }
    public static void main(String[] args) throws Exception {
        for (int i = 0; i < 10; i++) {
            Thread thread = new Thread(new Runnable() {
                @Override
                public void run() {
                    for (int j = 0; j < 1000; j++) {
                        addCount();
                    }
                }
            });
            thread.start();
        }
        // 主線程睡眠1s,保證子線程都執行完畢
        Thread.sleep(1000);
        System.out.println("count=" + count);
    }
}
      

  

可以看出,

子線程計數器累加到1000,               然後主線程建立了10個子線程來跑,               是以,最終結果是應該是10000,           

但是大家運作代碼看看, 發現各種錯誤的輸出都有!

原因就是 “count++”這個操作不是我們以為的“原子操作“, 它其實是三步操作

  1. 從主存中讀取count的值,複制一份到CPU寄存器
  2. CPU寄存器中,CPU執行指令對 count 進行加1 操作
  3. 把count重新重新整理到主存

單線程當然沒有問題, 但當多線程時, 就會存在問題.

是以我們必須解決這個問題!

保證原子性 - 鎖

使用鎖, 可以保證 同一時間隻有一個線程能拿到鎖,也就保證了同一時間隻有一個線程能執行申請鎖和釋放鎖之間的代碼.

使用方式:

// 聲明一個鎖              private static ReentrantLock lock = new ReentrantLock();                   public static void addCount() {              lock.lock();              try {              count++;              } finally {              lock.unlock();              }              }           

需要強調的是記得 finally 釋放鎖,防止異常導緻鎖一直無法釋放!

try{              //加鎖代碼              }finally{              lock.unlock();              }           
保證原子性 - 同步方法

與鎖類似的是同步方法或者同步代碼塊,Java使用關鍵字synchronized進行同步.

需要注意的是, synchronized是有作用範圍的.

synchronized的作用範圍:

  • 修飾非靜态方法(或成員變量),鎖的是this對象, 就是類的執行個體對象(即: 對象鎖)
public synchronized void addCount() {              count++;              }           
  • 修飾靜态方法(或成員變量), 鎖的是Class對象本身, 因為靜态成員不專屬于任何一個執行個體對象 (即: 類鎖)
public static synchronized void addCount() {              count++;              }           
  • 修飾代碼塊時, 鎖住的是synchronized關鍵字後面括号内的對象. 
public class Test{              private Object object = new Object();              public void addCount() {              //此時,鎖住的是object對象變量              synchronized (object) {              count++;              }              //此時鎖住的是目前執行個體對象              synchronized (this) {              count++;              }              //此時鎖住的是目前Test類的class對象              synchronized (Test.class) {              count++;              }              }                }           

無論使用鎖還是synchronized, 本質都是一樣

通過鎖或同步來實作資源的排它性,進而實際目标代碼段同一時間隻會被一個線程執行,進而保證了目标代碼段的原子性.

既然提到了鎖,我們就不得不提下“悲觀鎖、樂觀鎖、CAS無鎖以及可重入鎖”的概念, 這些知識點也是面試中常出現的, (參見後文補充知識點)

如何保證可見性

Java提供了volatile關鍵字來保證可見性.

當使用volatile修飾某個變量時,              它會保證對該變量的修改會立即被更新到記憶體中,              并且将其它線程緩存中對該變量的緩存設定成無效              是以其它線程需要讀取該值時必須從主記憶體中讀取,              進而得到最新的值.           

我們還舉介紹可見性時的例子,

private static volatile boolean isRuning = false;           

如果用volatile來修飾isRuning, 再運作你會發現, 程式能得到預期結果了.

volatile适用于不需要保證原子性,但卻需要保證可見性的場景,一種典型的使用場景是用它修飾用于停止線程的狀态标記

關于“不需要保證原子性”這點, 大家可以參考介紹“原子性”的那個案例(多線程count++), 将count定義為volatile修飾的變量

private static volatile int count = 0;           

運作你會發現最終結果并不是預期值, 原因就在于:

兩個線程A,B同時進行count++,              count++是三步操作                  1. 從主存中讀取count的值,複制一份到CPU寄存器              2. CPU寄存器中,CPU執行指令對 count 進行加1 操作              3. 把count重新重新整理到主存                  假設count = 1              A和B讀取count都是1,複制到各自的緩存中              假設A先執行完了, 将count = 2回寫進主存,               因為volatile, 是以通知其它線程count值有更新.                  B呢,此時正好執行到最後一步,于是儲存的是2,而不是我們認為的3!           
如何保證有序性

針對編譯器和處理器對指令進行重新排序時,可能影響多線程程式并發執行的正确性問題,

Java中可通過volatile關鍵字在一定程式上保證順序性,另外還可以通過鎖和同步(synchronized)來保證順序性.

事實上, 鎖和synchronized即可以保證原子性,也可以保證可見性以及順序性.因為它們是通過保證同一時間隻有一個線程執行目标代碼段來實作的

鎖和synchronized可以“勝任”一切,為什麼還需要volatile?

synchronized和鎖需要通過作業系統來仲裁誰獲得鎖,開銷比較高;              而volatile開銷小很多,              是以在隻需要保證可見性的條件下,              使用volatile的性能要比使用鎖和synchronized高得多.           

除了從應用層面保證目标代碼段執行的順序性外,

JVM還通過被稱為happens-before原則隐式地保證順序性.

兩個操作的執行順序隻要可以通過happens-before推導出來,              則JVM會保證其順序性,              反之JVM對其順序性不作任何保證,可對其進行任意必要的重新排序以擷取高效率.           
補充知識 - happens-before

在JMM(Java記憶體模型)中, 

如果一個操作的執行結果需要對另一個操作可見,              那麼這兩個操作之間必須要存在happens-before關系,               這兩個操作既可以在同一個線程,也可以在不同的兩個線程中           

我們需要關注的happens-before規則如下: 

  • 傳遞規則
如果操作A先行發生于操作B,而操作B又先行發生于操作C,              則可以得出操作A先行發生于操作C           
  • 鎖定規則
一個unlock操作肯定會在後面對同一個鎖的lock操作前發生,               鎖隻有被釋放了才會被再次擷取           
  • volatile變量規則
對一個volatile修飾的變量的寫操作先行發生于後面對這個變量的讀操作           
  • 程式次序規則
一個線程内,按照代碼順序,書寫在前面的操作先行發生于書寫在後面的操作           
  • 線程啟動規則
Thread對象的start()方法先發生于此線程的其它動作           
  • 線程終結原則
線程中所有的操作都先行發生于線程的終止檢測,               我們可以通過Thread.join()方法結束,               Thread.isAlive()的傳回值手段檢測到線程已經終止執行              (所有終結的線程都不可再用)           
  • 線程中斷規則
對線程interrupt()方法的調用先行發生于被中斷線程的代碼檢測到中斷事件的發生           
  • 對象終結規則
一個對象的初始化完成先行發生于他的finalize()方法的開始           
補充知識 - 悲觀鎖和樂觀鎖
  • 悲觀鎖

處理資料時,假設會有其他外部修改,是以每次都會鎖住資料, 防止外部的操作. 

  • 樂觀鎖

處理資料時,不加鎖而是假設沒有沖突而去完成某項操作,如果因為沖突失敗就重試,直到成功為止.

初一看, 大家可能會任務樂觀鎖好像比悲觀鎖性能高,其實也要看具體場景! 

因為樂觀鎖的重試機制, 是以當并發量很高的時候, 重試的次數就會劇增, 此時, 顯然性能是不如悲觀鎖的!

顯而易見, 鎖或同步就是悲觀鎖, 它們以"犧牲性能"來保證原子性.

那麼, 有沒有無需加鎖也能保證原子性的方式呢?

補充知識 - CAS(無鎖)

CAS 是英文單詞 Compare And Swap 的縮寫,翻譯過來就是比較并替換.

CAS有3個操作數,記憶體值V, 舊的預期值A,要修改的新值B.

當且僅當預期值A和記憶體值V相同時, 将記憶體值V修改為B,否則什麼都不做.

我們舉個例子:

假設V = 10;

線程1想要使得V的值加1, 按CAS, 

此時, A=10, B = 11;

線程2突然修改了V=11;

線程1發現, (A=10) != (V=11), 是以, 不允許更新!

5000字, 講透如何保證線程安全!

CAS是一種樂觀鎖的機制,它不會阻塞任何線程. 是以在效率上,它會比 鎖和同步要高. 

上文中我們說“count++”自增操作不是原子的, 這導緻了并發問題, 那麼如何解決呢?

Java提供了并發原子類AtomicInteger來解決自增操作原子性的問題,其底層就是使用了CAS原理

private static AtomicInteger count = new AtomicInteger();
 public static void addCount() {
        count.incrementAndGet();
 }
      

CAS雖然在普通場景下優于鎖和同步, 但是同時引入了一個“ABA”問題!

ABA問題:

我們還舉上一個例子:

假設V = 10;              線程1想要使得V的值加1, 按CAS, 此時, A=10, B = 11;              線程2突然修改了V=11;              線程3突然修改了V=10;              線程1發現, (A=10) = (V=10), 是以, 允許更新!           

雖然數字結果上沒有問題, 但是如果需要追溯過程就會存在漏洞!

因為CAS把線程3修改的V=10,當成了V的初始值10, 認為它從未更改過!

針對ABA問題,雖然也能通過增加版本号等等來解決, 不過有句忠告:

使用CAS要考慮清楚“ABA”問題是否會影響程式并發的正确性,如果需要解決ABA問題,改用鎖或同步可能更高效

補充知識 - 可重入鎖

介紹完以上知識,不知道大家關于“鎖”的使用,有沒有這樣的疑惑

A線程對某個對象加鎖後,               在A線程内部如果再次要擷取同一個對象的鎖,會怎樣?               會不會死鎖?           

針對這樣的問題, 提出了可重入鎖這個東西!

所謂可重入鎖,指的是以線程為機關,              當一個線程擷取對象鎖之後,這個線程可以再次擷取本對象上的鎖,              而其他的線程是不可以的.(同一個加鎖線程自己調用自己不會發生死鎖情況)           

可重入鎖是為了防止死鎖

它的實作原理是

通過為每個鎖關聯一個請求計數和一個占有它的線程.              當計數為 0 時,認為鎖是未被占有的.              線程請求一個未被占有的鎖時, jvm将記錄鎖的占有者,并且将請求計數器置為1 .              如果同一個線程再次請求這個鎖,計數将遞增;              每次占用線程退出同步塊,計數器值将遞減.              直到計數器為0,鎖被釋放.           

synchronized 和 ReentrantLock 都是可重入鎖

  • ReentrantLock 表現為 API 層面的互斥鎖(lock() 和 unlock() 方法配合 try/finally 語句塊來完成);