Java併發知識點快速複習手冊(下)

qqxx6661發表於2019-02-01

前言

本文快速回顧了常考的的知識點,用作面試複習,事半功倍。

面試知識點複習手冊

已釋出知識點複習手冊

參考

本文內容參考自CyC2018的Github倉庫:CS-Notes

github.com/CyC2018/CS-…

有刪減,修改,補充額外增加內容

知識共享署名-非商業性使用 4.0 國際許可協議

本作品採用知識共享署名-非商業性使用 4.0 國際許可協議進行許可。

文章目錄

  • 執行緒不安全示例
  • Java 記憶體模型
  • ThreadLocal/Volatile/Synchronized/Atomic橫向對比
  • 執行緒安全
  • 鎖優化
  • 多執行緒開發良好的實踐
  • 補充經典併發集合和同步集合參考
  • Java執行緒鎖

執行緒不安全示例

如果多個執行緒對同一個共享資料進行訪問而不採取同步操作的話,那麼操作的結果是不一致的。

以下程式碼演示了 1000 個執行緒同時對 cnt 執行自增操作,操作結束之後它的值有可能小於 1000。

public class ThreadUnsafeExample {

    private int cnt = 0;

    public void add() {
        cnt++;
    }

    public int get() {
        return cnt;
    }
}
複製程式碼
public static void main(String[] args) throws InterruptedException {
    final int threadSize = 1000;
    ThreadUnsafeExample example = new ThreadUnsafeExample();
    final CountDownLatch countDownLatch = new CountDownLatch(threadSize);
    ExecutorService executorService = Executors.newCachedThreadPool();
    for (int i = 0; i < threadSize; i++) {
        executorService.execute(() -> {
            example.add();
            countDownLatch.countDown();
        });
    }
    countDownLatch.await();
    executorService.shutdown();
    System.out.println(example.get());
}
複製程式碼
997
複製程式碼

Java 記憶體模型

Java 記憶體模型試圖遮蔽各種硬體和作業系統的記憶體訪問差異,以實現讓 Java 程式在各種平臺下都能達到一致的記憶體訪問效果。

主記憶體與工作記憶體

處理器上的暫存器的讀寫的速度比記憶體快幾個數量級,為了解決這種速度矛盾,在它們之間加入了快取記憶體。

加入快取記憶體帶來了一個新的問題:快取一致性。如果多個快取共享同一塊主記憶體區域,那麼多個快取的資料可能會不一致,需要一些協議來解決這個問題。

在這裡插入圖片描述

所有的變數都儲存在主記憶體中,每個執行緒還有自己的工作記憶體,工作記憶體儲存在快取記憶體或者暫存器中,儲存了該執行緒使用的變數的主記憶體副本拷貝。

執行緒只能直接操作工作記憶體中的變數,不同執行緒之間的變數值傳遞需要通過主記憶體來完成。

在這裡插入圖片描述

記憶體間互動操作

Java 記憶體模型定義了 8 個操作來完成主記憶體和工作記憶體的互動操作

在這裡插入圖片描述

  • read:把一個變數的值從主記憶體傳輸到工作記憶體中
  • load:在 read 之後執行,把 read 得到的值放入工作記憶體的變數副本中
  • use:把工作記憶體中一個變數的值傳遞給執行引擎
  • assign:把一個從執行引擎接收到的值賦給工作記憶體的變數
  • store:把工作記憶體的一個變數的值傳送到主記憶體中
  • write:在 store 之後執行,把 store 得到的值放入主記憶體的變數中
  • lock:作用於主記憶體的變數
  • unlock

記憶體模型三大特性

1. 原子性

Java 記憶體模型保證了 read、load、use、assign、store、write、lock 和 unlock 操作具有原子性

Java 記憶體模型保證了 read、load、use、assign、store、write、lock 和 unlock 操作具有原子性,例如對一個 int 型別的變數執行 assign 賦值操作,這個操作就是原子性的。但是 Java 記憶體模型允許虛擬機器將沒有被 volatile 修飾的 64 位資料(long,double)的讀寫操作劃分為兩次 32 位的操作來進行,即 load、store、read 和 write 操作可以不具備原子性。

有一個錯誤認識就是,int 等原子性的型別在多執行緒環境中不會出現執行緒安全問題。前面的執行緒不安全示例程式碼中,cnt 屬於 int 型別變數,1000 個執行緒對它進行自增操作之後,得到的值為 997 而不是 1000。

原子類

在這裡插入圖片描述

使用 AtomicInteger 重寫之前執行緒不安全的程式碼之後得到以下執行緒安全實現:

public class AtomicExample {
    private AtomicInteger cnt = new AtomicInteger();

    public void add() {
        cnt.incrementAndGet();
    }

    public int get() {
        return cnt.get();
    }
}
複製程式碼
public static void main(String[] args) throws InterruptedException {
    final int threadSize = 1000;
    AtomicExample example = new AtomicExample(); // 只修改這條語句
    final CountDownLatch countDownLatch = new CountDownLatch(threadSize);
    ExecutorService executorService = Executors.newCachedThreadPool();
    for (int i = 0; i < threadSize; i++) {
        executorService.execute(() -> {
            example.add();
            countDownLatch.countDown();
        });
    }
    countDownLatch.await();
    executorService.shutdown();
    System.out.println(example.get());
}
複製程式碼
1000
複製程式碼

synchronized

除了使用原子類之外,也可以使用 synchronized 互斥鎖來保證操作的原子性。它對應的記憶體間互動操作為:lock 和 unlock,在虛擬機器實現上對應的位元組碼指令為 monitorenter 和 monitorexit。

public class AtomicSynchronizedExample {
    private int cnt = 0;

    public synchronized void add() {
        cnt++;
    }

    public synchronized int get() {
        return cnt;
    }
}
複製程式碼
public static void main(String[] args) throws InterruptedException {
    final int threadSize = 1000;
    AtomicSynchronizedExample example = new AtomicSynchronizedExample();
    final CountDownLatch countDownLatch = new CountDownLatch(threadSize);
    ExecutorService executorService = Executors.newCachedThreadPool();
    for (int i = 0; i < threadSize; i++) {
        executorService.execute(() -> {
            example.add();
            countDownLatch.countDown();
        });
    }
    countDownLatch.await();
    executorService.shutdown();
    System.out.println(example.get());
}
複製程式碼
1000
複製程式碼

2. 可見性

可見性指當一個執行緒修改了共享變數的值,其它執行緒能夠立即得知這個修改

Java 記憶體模型是通過在變數修改後將新值同步回主記憶體在變數讀取前從主記憶體重新整理變數值來實現可見性的。

主要有有三種實現可見性的方式:

  • volatile:僅僅用來保證該變數對所有執行緒的可見性,但不保證原子性。
  • synchronized,對一個變數執行 unlock 操作之前,必須把變數值同步回主記憶體。
  • final,被 final 關鍵字修飾的欄位在構造器中一旦初始化完成,並且沒有發生 this 逃逸(其它執行緒通過 this 引用訪問到初始化了一半的物件),那麼其它執行緒就能看見 final 欄位的值。

3. 有序性

有序性是指:在本執行緒內觀察,所有操作都是有序的在一個執行緒觀察另一個執行緒,所有操作都是無序的,無序是因為發生了指令重排序。

在 Java 記憶體模型中,允許編譯器和處理器對指令進行重排序,重排序過程不會影響到單執行緒程式的執行,卻會影響到多執行緒併發執行的正確性。

  • volatile 關鍵字通過新增記憶體屏障的方式來禁止指令重排,即重排序時不能把後面的指令放到記憶體屏障之前。

  • 可以通過 synchronized 來保證有序性,它保證每個時刻只有一個執行緒執行同步程式碼,相當於是讓執行緒順序執行同步程式碼。

happens-before

blog.csdn.net/qq_30137611…

happens-before是判斷資料是否存在競爭、執行緒是否安全的重要依據

定義:

如果操作A happens-before 於 操作B,那麼就可以確定,操作B執行完之後,j 的值一定為 1;因為happens-before關係可以向程式設計師保證:在操作B執行之前,操作A的執行後的影響[或者說結果](修改 i 的值)操作B是可以觀察到的[或者說可見的]

這裡列舉幾個常見的Java“天然的”happens-before關係

程式順序規則: 一個執行緒中的每個操作,happens-before於該執行緒中的任意後續操作(也就是說你寫的操作,如果是單執行緒執行,那麼前面的操作[程式邏輯上的前]就會happens-before於後面的操作) 這裡的影響指修改了 i 變數的值

監視器鎖規則: 對一個鎖的解鎖,happens-before 於隨後對這個鎖的加鎖

volatile變數規則: 對一個 volatile域的寫,happens-before於任意後續對這個volatile域的讀

④ 傳遞性:如果 A happens-before B,且 B happens-before C,那麼A happens-before C

在這裡插入圖片描述

總結

  • 保證原子性的操作:
    • read、load、assign、use、store和write(自身具有原子性)
    • 原子類
    • synchronized鎖
  • 保證可見性:
    • volatile
    • synchronized鎖
    • final
  • 保證有序性(重排序導致無序)的操作:
    • volatile
    • synchronized鎖

先行發生原則

上面提到了可以用 volatile 和 synchronized 來保證有序性。除此之外,JVM 還規定了先行發生原則,讓一個操作無需控制就能先於另一個操作完成。

主要有以下這些原則:

1. 單一執行緒原則

Single Thread rule

在一個執行緒內,在程式前面的操作先行發生於後面的操作。

2. 管程鎖定規則

Monitor Lock Rule

一個 unlock 操作先行發生於後面對同一個鎖的 lock 操作。

3. volatile 變數規則

Volatile Variable Rule

對一個 volatile 變數的寫操作先行發生於後面對這個變數的讀操作。

4. 執行緒啟動規則

Thread Start Rule

Thread 物件的 start() 方法呼叫先行發生於此執行緒的每一個動作。

5. 執行緒加入規則

Thread Join Rule

Thread 物件的結束先行發生於 join() 方法返回。

6. 執行緒中斷規則

Thread Interruption Rule

對執行緒 interrupt() 方法的呼叫先行發生於被中斷執行緒的程式碼檢測到中斷事件的發生,可以通過 interrupted() 方法檢測到是否有中斷髮生。

7. 物件終結規則

Finalizer Rule

一個物件的初始化完成(建構函式執行結束)先行發生於它的 finalize() 方法的開始。

8. 傳遞性

Transitivity

如果操作 A 先行發生於操作 B,操作 B 先行發生於操作 C,那麼操作 A 先行發生於操作 C。

ThreadLocal/Volatile/Synchronized/Atomic橫向對比

blog.csdn.net/u010687392/…

Atomic 原子性

內部實現

採用Lock-Free演算法替代鎖,加上原子操作指令實現併發情況下資源的安全、完整、一致性

而關於Lock-Free演算法,則是一種新的策略替代鎖來保證資源在併發時的完整性的,Lock-Free的實現有三步:

1、迴圈(for(;;)、while) 
2、CAS(CompareAndSet) 
3、回退(returnbreak複製程式碼

volatile 可見性 有序性

www.jianshu.com/p/195ae7c77…

通過關鍵字sychronize可以防止多個執行緒進入同一段程式碼,在某些特定場景中,volatile相當於一個輕量級的sychronize,因為不會引起執行緒的上下文切換

為何具有可見性

  • 對於普通變數

    • 讀操作會優先讀取工作記憶體的資料,如果工作記憶體中不存在,則從主記憶體中拷貝一份資料到工作記憶體中
    • 寫操作只會修改工作記憶體的副本資料,這種情況下,其它執行緒就無法讀取變數的最新值
  • 對於volatile變數

    • 讀操作時JMM會把工作記憶體中對應的值設為無效要求執行緒從主記憶體中讀取資料
    • 寫操作時JMM會把工作記憶體中對應的資料重新整理到主記憶體中,這種情況下,其它執行緒就可以讀取變數的最新值。

為何具有有序性(記憶體屏障)

記憶體屏障,又稱記憶體柵欄,是一個CPU指令。在程式執行時,為了提高執行效能,編譯器和處理器會對指令進行重排序

JMM為了保證在不同的編譯器和CPU上有相同的結果,通過插入特定型別的記憶體屏障來禁止特定型別的編譯器重排序和處理器重排序,插入一條記憶體屏障會告訴編譯器和CPU:不管什麼指令都不能和這條Memory Barrier指令重排序。

滿足下面的條件才應該使用volatile修飾變數

一般來說,volatile大多用於標誌位上(判斷操作),

  • 修改變數時不依賴變數的當前值(因為volatile是不保證原子性的)
  • 該變數不會納入到不變性條件中(該變數是可變的)
  • 在訪問變數的時候不需要加鎖(加鎖就沒必要使用volatile這種輕量級同步機制了)

synchronized 全能

但是由於操作上的優勢,只需要簡單的宣告一下即可,而且被它宣告的程式碼塊也是具有操作的原子性。

ThreadLocal

ThreadLocal提供了執行緒的區域性變數,每個執行緒都可以通過set()和get()來對這個區域性變數進行操作,但不會和其他執行緒的區域性變數進行衝突,實現了執行緒的資料隔離。

而ThreadLocal的設計,並不是解決資源共享的問題,而是用來提供執行緒內的區域性變數,這樣每個執行緒都自己管理自己的區域性變數,別的執行緒操作的資料不會對我產生影響,相當於封裝在Thread內部了,供執行緒自己管理。

用法

它有三個暴露的方法,set、get、remove。

內部實現

  • 每個Thread維護著一個ThreadLocalMap的引用
  • ThreadLocalMap是ThreadLocal的內部類,用Entry來進行儲存
  • 呼叫ThreadLocal的set()方法時,實際上就是往ThreadLocalMap設定值,key是ThreadLocal物件,值是傳遞進來的物件
  • 呼叫ThreadLocal的get()方法時,實際上就是往ThreadLocalMap獲取值,key是ThreadLocal物件
  • ThreadLocal本身並不儲存值,它只是作為一個key來讓執行緒從ThreadLocalMap獲取value

在這裡插入圖片描述

記憶體洩漏

如果ThreadLocal不設為static的,由於Thread的生命週期不可預知,這就導致了當系統gc時將會回收它,而ThreadLocal物件被回收了,此時它對應key必定為null,這就導致了該key對應得value拿不出來了而value之前被Thread所引用,所以就存在key為null、value存在強引用導致這個Entry回收不了,從而導致記憶體洩露。

避免記憶體洩露的方法:

  • ThreadLocal要設為static靜態的
  • 必須手動remove掉該ThreadLocal的值,這樣Entry就能夠在系統gc的時候正常回收,而關於ThreadLocalMap的回收,會在當前Thread銷燬之後進行回收。

使用場景

  • 管理資料庫的Connection

threadLocal能夠實現當前執行緒的操作都是用同一個Connection,保證了事務!

  • 避免一些引數傳遞

總結

關於Volatile關鍵字具有可見性,但不具有操作的原子性,而synchronized比volatile對資源的消耗稍微大點,但可以保證變數操作的原子性,保證變數的一致性,最佳實踐則是二者結合一起使用。

1、synchronized:解決多執行緒資源共享的問題,同步機制採用了“以時間換空間”的方式:訪問序列化,物件共享化。同步機制是提供一份變數,讓所有執行緒都可以訪問。

2、對於Atomic的出現,是通過原子操作指令+Lock-Free完成,從而實現非阻塞式的併發問題

3、對於Volatile,為多執行緒資源共享問題解決了部分需求,在非依賴自身的操作的情況下,對變數的改變將對任何執行緒可見。

4、對於ThreadLocal的出現,並不是解決多執行緒資源共享的問題,而是用來提供執行緒內的區域性變數,省去引數傳遞這個不必要的麻煩,ThreadLocal採用了“以空間換時間”的方式:訪問並行化,物件獨享化。ThreadLocal是為每一個執行緒都提供了一份獨有的變數,各個執行緒互不影響。

執行緒安全類

等待IO的方式:阻塞,非阻塞

獲得通知的方式:非同步,非非同步

多個執行緒不管以何種方式訪問某個類,並且在主調程式碼中不需要進行同步,都能表現正確的行為。

執行緒安全有以下幾種實現方式:

不可變

不可變(Immutable)的物件一定是執行緒安全的,不需要再採取任何的執行緒安全保障措施。只要一個不可變的物件被正確地構建出來,永遠也不會看到它在多個執行緒之中處於不一致的狀態。多執行緒環境下,應當儘量使物件成為不可變,來滿足執行緒安全。

不可變的型別:

  • final 關鍵字修飾的基本資料型別
  • String
  • 列舉型別
  • Number 部分子類,如 Long 和 Double 等數值包裝型別,BigInteger 和 BigDecimal 等大資料型別。但同為 Number 的原子類 AtomicInteger 和 AtomicLong 則是可變的。

對於集合型別,可以使用 Collections.unmodifiableXXX() 方法來獲取一個不可變的集合。

public class ImmutableExample {
    public static void main(String[] args) {
        Map<String, Integer> map = new HashMap<>();
        Map<String, Integer> unmodifiableMap = Collections.unmodifiableMap(map);
        unmodifiableMap.put("a", 1);
    }
}
複製程式碼
Exception in thread "main" java.lang.UnsupportedOperationException
    at java.util.Collections$UnmodifiableMap.put(Collections.java:1457)
    at ImmutableExample.main(ImmutableExample.java:9)
複製程式碼

Collections.unmodifiableXXX() 先對原始的集合進行拷貝,需要對集合進行修改的方法都直接丟擲異常。

public V put(K key, V value) {
    throw new UnsupportedOperationException();
}
複製程式碼

互斥同步

synchronized 和 ReentrantLock。

非阻塞同步

互斥同步最主要的問題就是進行執行緒阻塞和喚醒所帶來的效能問題,因此這種同步也稱為阻塞同步(Blocking Synchronization)。

從處理問題的方式上說,互斥同步屬於一種悲觀的併發策略,總是認為只要不去做正確的同步措施(例如加鎖),那就肯定會出現問題。

隨著硬體指令集的發展,我們有了另外一個選擇:基於衝突檢測的樂觀併發策略,通俗地說,就是先進行操作,如果沒有其他執行緒爭用共享資料,那操作就成功了;如果共享資料有爭用,產生了衝突,那就再採取其他的補償措施(最常見的補償措施就是不斷地重試,直到成功為止),這種樂觀的併發策略的許多實現都不需要把執行緒掛起,因此這種同步操作稱為非阻塞同步(Non-Blocking Synchronization)。

樂觀鎖需要操作衝突檢測這兩個步驟具備原子性,這裡就不能再使用互斥同步來保證了,只能靠硬體來完成。

1. CAS

硬體支援的原子性操作最典型的是:比較並交換(Compare-and-Swap,CAS)。

CAS 指令需要有 3 個運算元,分別是:

  • 記憶體位置(在 Java 中可以簡單理解為變數的記憶體地址,用 V 表示)
  • 舊的預期值(用 A 表示)
  • 新值(用 B 表示)。

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

但是無論是否更新了 V 的值,都會返回 V 的舊值,上述的處理過程是一個原子操作。

當多個執行緒嘗試使用CAS同時更新同一個變數時,只有其中一個執行緒能更新變數的值(A和記憶體值V相同時,將記憶體值V修改為B),而其它執行緒都失敗,失敗的執行緒並不會被掛起,而是被告知這次競爭中失敗,並可以再次嘗試(否則什麼都不做)

J.U.C 包裡面的整數原子類 AtomicInteger,其中的 compareAndSet() 和 getAndIncrement() 等方法都使用了 Unsafe 類的 CAS 操作。

2. AtomicInteger

J.U.C 包裡面的整數原子類 AtomicInteger 的方法呼叫了 Unsafe 類的 CAS 操作。

以下程式碼使用了 AtomicInteger 執行了自增的操作。

private AtomicInteger cnt = new AtomicInteger();

public void add() {
    cnt.incrementAndGet();
}
複製程式碼

以下程式碼是 incrementAndGet() 的原始碼,它呼叫了 Unsafe 的 getAndAddInt() 。

public final int incrementAndGet() {
    return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
}
複製程式碼

以下程式碼是 getAndAddInt() 原始碼,var1 指示物件記憶體地址,var2 指示該欄位相對物件記憶體地址的偏移,var4 指示操作需要加的數值,這裡為 1。通過 getIntVolatile(var1, var2) 得到舊的預期值,通過呼叫 compareAndSwapInt() 來進行 CAS 比較,如果該欄位記憶體地址中的值等於 var5,那麼就更新記憶體地址為 var1+var2 的變數為 var5+var4。

可以看到 getAndAddInt() 在一個迴圈中進行,發生衝突的做法是不斷的進行重試。

public final int getAndAddInt(Object var1, long var2, int var4) {
    int var5;
    do {
        var5 = this.getIntVolatile(var1, var2);
    } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));

    return var5;
}
複製程式碼

3. ABA

如果一個變數初次讀取的時候是 A 值,它的值被改成了 B,後來又被改回為 A,那 CAS 操作就會誤認為它從來沒有被改變過。

J.U.C 包提供了一個帶有標記的原子引用類“AtomicStampedReference”來解決這個問題,它可以通過控制變數值的版本來保證 CAS 的正確性。大部分情況下 ABA 問題不會影響程式併發的正確性,如果需要解決 ABA 問題,改用傳統的互斥同步可能會比原子類更高效。

無同步方案

要保證執行緒安全,並不是一定就要進行同步。如果一個方法本來就不涉及共享資料,那它自然就無須任何同步措施去保證正確性。

1. 棧封閉

多個執行緒訪問同一個方法的區域性變數時,不會出現執行緒安全問題,因為區域性變數儲存在虛擬機器棧中,屬於執行緒私有的。

public class StackClosedExample {
    public void add100() {
        int cnt = 0;
        for (int i = 0; i < 100; i++) {
            cnt++;
        }
        System.out.println(cnt);
    }
}
複製程式碼
public static void main(String[] args) {
    StackClosedExample example = new StackClosedExample();
    ExecutorService executorService = Executors.newCachedThreadPool();
    executorService.execute(() -> example.add100());
    executorService.execute(() -> example.add100());
    executorService.shutdown();
}
複製程式碼
100
100
複製程式碼

2. 執行緒本地儲存(Thread Local Storage)

如果一段程式碼中所需要的資料必須與其他程式碼共享,那就看看這些共享資料的程式碼是否能保證在同一個執行緒中執行。如果能保證,我們就可以把共享資料的可見範圍限制在同一個執行緒之內,這樣,無須同步也能保證執行緒之間不出現資料爭用的問題。

符合這種特點的應用並不少見,大部分使用消費佇列的架構模式(如“生產者-消費者”模式)都會將產品的消費過程儘量在一個執行緒中消費完。其中最重要的一個應用例項就是經典 Web 互動模型中的“一個請求對應一個伺服器執行緒”(Thread-per-Request)的處理方式,這種處理方式的廣泛應用使得很多 Web 服務端應用都可以使用執行緒本地儲存來解決執行緒安全問題。

可以使用 java.lang.ThreadLocal 類來實現執行緒本地儲存功能。

對於以下程式碼,thread1 中設定 threadLocal 為 1,而 thread2 設定 threadLocal 為 2。過了一段時間之後,thread1 讀取 threadLocal 依然是 1,不受 thread2 的影響。

public class ThreadLocalExample {
    public static void main(String[] args) {
        ThreadLocal threadLocal = new ThreadLocal();
        Thread thread1 = new Thread(() -> {
            threadLocal.set(1);
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println(threadLocal.get());
            threadLocal.remove();
        });
        Thread thread2 = new Thread(() -> {
            threadLocal.set(2);
            threadLocal.remove();
        });
        thread1.start();
        thread2.start();
    }
}
複製程式碼
1
複製程式碼

為了理解 ThreadLocal,先看以下程式碼:

public class ThreadLocalExample1 {
    public static void main(String[] args) {
        ThreadLocal threadLocal1 = new ThreadLocal();
        ThreadLocal threadLocal2 = new ThreadLocal();
        Thread thread1 = new Thread(() -> {
            threadLocal1.set(1);
            threadLocal2.set(1);
        });
        Thread thread2 = new Thread(() -> {
            threadLocal1.set(2);
            threadLocal2.set(2);
        });
        thread1.start();
        thread2.start();
    }
}
複製程式碼

每個 Thread 都有一個 ThreadLocal.ThreadLocalMap 物件。

/* ThreadLocal values pertaining to this thread. This map is maintained
 * by the ThreadLocal class. */
ThreadLocal.ThreadLocalMap threadLocals = null;
複製程式碼

當呼叫一個 ThreadLocal 的 set(T value) 方法時,先得到當前執行緒的 ThreadLocalMap 物件,然後將 ThreadLocal->value 鍵值對插入到該 Map 中。

public void set(T value) {
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t);
    if (map != null)
        map.set(this, value);
    else
        createMap(t, value);
}
複製程式碼

get() 方法類似。

public T get() {
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t);
    if (map != null) {
        ThreadLocalMap.Entry e = map.getEntry(this);
        if (e != null) {
            @SuppressWarnings("unchecked")
            T result = (T)e.value;
            return result;
        }
    }
    return setInitialValue();
}
複製程式碼

ThreadLocal 從理論上講並不是用來解決多執行緒併發問題的,因為根本不存在多執行緒競爭。

在一些場景 (尤其是使用執行緒池) 下,由於 ThreadLocal.ThreadLocalMap 的底層資料結構導致 ThreadLocal 有記憶體洩漏的情況,應該儘可能在每次使用 ThreadLocal 後手動呼叫 remove(),以避免出現 ThreadLocal 經典的記憶體洩漏甚至是造成自身業務混亂的風險。

3. 可重入程式碼(Reentrant Code)

這種程式碼也叫做純程式碼(Pure Code),可以在程式碼執行的任何時刻中斷它,轉而去執行另外一段程式碼(包括遞迴呼叫它本身),而在控制權返回後,原來的程式不會出現任何錯誤。

可重入程式碼有一些共同的特徵,例如不依賴儲存在堆上的資料和公用的系統資源、用到的狀態量都由引數中傳入、不呼叫非可重入的方法等。

鎖優化

這裡的鎖優化主要是指虛擬機器對synchronized的優化。

鎖競爭是kernal mode下的,會經過user mode(使用者態)到kernal mode(核心態) 的切換,是比較花時間的。

自旋鎖

自旋鎖的思想是讓一個執行緒在請求一個共享資料的鎖時執行忙迴圈(自旋)一段時間,如果在這段時間內能獲得鎖,就可以避免進入阻塞狀態。

它只適用於共享資料的鎖定狀態很短的場景

自旋次數的預設值是 10 次,使用者可以使用虛擬機器引數 -XX:PreBlockSpin 來更改。

在 JDK 1.6 中引入了自適應的自旋鎖。自適應意味著自旋的次數不再固定了,而是由前一次在同一個鎖上的自旋次數鎖的擁有者的狀態來決定。

鎖消除

鎖消除是指對於被檢測出不可能存在競爭的共享資料的鎖進行消除。檢測到某段程式碼是執行緒安全的(言外之意:無鎖也是安全的),JVM會安全地原有的鎖消除掉!

逃逸分析:如果堆上的共享資料不可能逃逸出去被其它執行緒訪問到,那麼就可以把它們當成私有資料對待,也就可以將它們上的鎖進行消除。

對於一些看起來沒有加鎖的程式碼,其實隱式的加了很多鎖。例如下面的字串拼接程式碼就隱式加了鎖:

public static String concatString(String s1, String s2, String s3) {
    return s1 + s2 + s3;
}
複製程式碼

String 是一個不可變的類,Javac 編譯器會對 String 的拼接自動優化。在 JDK 1.5 之前,會轉化為 StringBuffer 物件的連續 append() 操作,在 JDK 1.5 及以後的版本中,會轉化為 StringBuilder 物件的連續 append() 操作,即上面的程式碼可能會變成下面的樣子:

public static String concatString(String s1, String s2, String s3) {
    StringBuffer sb = new StringBuffer();
    sb.append(s1);
    sb.append(s2);
    sb.append(s3);
    return sb.toString();
}
複製程式碼

虛擬機器觀察變數 sb,很快就會發現它的動態作用域被限制在 concatString() 方法內部。也就是說,sb 的所有引用永遠不會“逃逸”到 concatString() 方法之外,其他執行緒無法訪問到它。因此,雖然這裡有鎖,但是可以被安全地消除掉。

鎖粗化

如果一系列的連續操作都對同一個物件反覆加鎖和解鎖,頻繁的加鎖操作就會導致效能損耗。

上一節的示例程式碼中連續的 append() 方法就屬於這類情況。如果虛擬機器探測到由這樣的一串零碎的操作都對同一個物件加鎖,將會把加鎖的範圍擴充套件(粗化)到整個操作序列的外部。對於上一節的示例程式碼就是擴充套件到第一個 append() 操作之前直至最後一個 append() 操作之後,這樣只需要加鎖一次就可以了。

但是如果一系列的連續操作都對同一個物件反覆加鎖和解鎖,甚至加鎖操作是出現在迴圈體中的,頻繁地進行互斥同步操作也會導致不必要的效能損耗。

偏向鎖

總結:在無競爭環境下,把整個同步都消除,CAS也不做。

偏向鎖的思想是偏向於讓第一個獲取鎖物件的執行緒,這個執行緒在之後獲取該鎖就不再需要進行同步操作,甚至連 CAS 操作也不再需要。

可以使用 -XX:+UseBiasedLocking=true 開啟偏向鎖,不過在 JDK 1.6 中它是預設開啟的。

當鎖物件第一次被執行緒獲得的時候,進入偏向狀態,標記為 1 01。同時使用 CAS 操作將執行緒 ID 記錄到 Mark Word 中,如果 CAS 操作成功,這個執行緒以後每次進入這個鎖相關的同步塊就不需要再進行任何同步操作。

當有另外一個執行緒去嘗試獲取這個鎖物件時,偏向狀態就宣告結束,此時撤銷偏向(Revoke Bias)後恢復到未鎖定狀態或者輕量級鎖狀態。

輕量級鎖

輕量級鎖是相對於傳統的重量級鎖而言,它使用 CAS 操作來避免重量級鎖使用互斥量的開銷。對於絕大部分的鎖,在整個同步週期內都是不存在競爭的,因此也就不需要都使用互斥量進行同步,可以先採用 CAS 操作進行同步,如果 CAS 失敗了再改用互斥量進行同步。(樂觀鎖)

JDK 1.6 引入了偏向鎖輕量級鎖,從而讓鎖擁有了四個狀態:無鎖狀態(unlocked)、偏向鎖狀態(biasble)、輕量級鎖狀態(lightweight locked)和重量級鎖狀態(inflated)。

如果 CAS 操作失敗了,虛擬機器首先會檢查物件的 Mark Word 是否指向當前執行緒的虛擬機器棧,如果是的話說明當前執行緒已經擁有了這個鎖物件,那就可以直接進入同步塊繼續執行,否則說明這個鎖物件已經被其他執行緒執行緒搶佔了。如果有兩條以上的執行緒爭用同一個鎖,那輕量級鎖就不再有效,要膨脹為重量級鎖。

但如果存在鎖競爭,除了互斥量的開銷外,還額外發生了CAS操作,因此在有競爭的情況下,輕量級鎖會比傳統的重量級鎖更慢。

簡單來說:如果發現同步週期內都是不存在競爭,JVM會使用CAS操作來替代作業系統互斥量。這個優化就被叫做輕量級鎖。

多執行緒開發良好的實踐

  • 縮小同步範圍,例如對於 synchronized,應該儘量使用同步塊而不是同步方法。

  • 多用同步類少用 wait() 和 notify(),多用CountDownLatch, Semaphore, CyclicBarrier 和 Exchanger 這些同步類。他們簡化了編碼操作,而用 wait() 和 notify() 很難實現對複雜控制流的控制。其次,這些類是由最好的企業編寫和維護,在後續的 JDK 中它們還會不斷優化和完善,使用這些更高等級的同步工具你的程式可以不費吹灰之力獲得優化。

  • 多用併發集合少用同步集合。

  • 使用本地變數ThreadLocal和不可變類來保證執行緒安全。

  • 使用執行緒池而不是直接建立 Thread 物件,這是因為建立執行緒代價很高,執行緒池可以有效地利用有限的執行緒來啟動任務。

  • 使用 BlockingQueue 實現生產者消費者問題。

補充經典併發集合和同步集合參考

www.cnblogs.com/suneryong/p…

不管是同步集合還是併發集合他們都支援執行緒安全,他們之間主要的區別體現在效能和可擴充套件性,還有他們如何實現的執行緒安全。同步HashMap, Hashtable, HashSet, Vector, ArrayList 相比他們併發的實現(比如:ConcurrentHashMap, CopyOnWriteArrayList, CopyOnWriteHashSet)會慢得多。造成如此慢的主要原因是鎖, 同步集合會把整個Map或List鎖起來,而併發集合不會。併發集合實現執行緒安全是通過使用先進的和成熟的技術像鎖剝離。比如ConcurrentHashMap 會把整個Map 劃分成幾個片段,只對相關的幾個片段上鎖,同時允許多執行緒訪問其他未上鎖的片段。

java.util.concurrent包中包含的併發集合類如下:

ConcurrentHashMap

CopyOnWriteArrayList

CopyOnWriteArraySet
複製程式碼

物件的釋出與逸出

  • 釋出(publish) 使物件能夠在當前作用域之外的程式碼中使用
  • 逸出(escape) 當某個不應該釋出的物件被髮布了

常見逸出的有下面幾種方式:

  • 靜態域逸出
  • public修飾的get方法
  • 方法引數傳遞
  • 隱式的this

具體解釋見:segmentfault.com/a/119000001…

安全釋出物件有幾種常見的方式:

  • 在靜態域中直接初始化 : public static Person = new Person();
    • 靜態初始化由JVM在類的初始化階段就執行了,JVM內部存在著同步機制,致使這種方式我們可以安全釋出物件
  • 對應的引用儲存到volatile或者AtomicReferance引用中
    • 保證了該物件的引用的可見性和原子性
  • 由final修飾
    • 該物件是不可變的
  • 由鎖來保護
    • 釋出和使用的時候都需要加鎖

Java執行緒鎖

segmentfault.com/a/119000001…

避免死鎖的方法

固定鎖順序避免死鎖

上面transferMoney()發生死鎖的原因是因為加鎖順序不一致而出現的~

  • 如果所有執行緒以固定的順序來獲得鎖,那麼程式中就不會出現鎖順序死鎖問題!

例子中,改造為得到對應的hash值來固定加鎖的順序,這樣我們就不會發生死鎖的問題了!

開放呼叫避免死鎖

如果在呼叫某個方法時不需要持有鎖,那麼這種呼叫被稱為開放呼叫!

使用定時鎖

使用顯式Lock鎖,在獲取鎖時使用tryLock()方法。當等待超過時限的時候,tryLock()不會一直等待,而是返回錯誤資訊。

關注我

我是蠻三刀把刀,目前為後臺開發工程師。主要關注後臺開發,網路安全,Python爬蟲等技術。

來微信和我聊聊:yangzd1102

Github:github.com/qqxx6661

原創部落格主要內容

  • 筆試面試複習知識點手冊
  • Leetcode演算法題解析(前150題)
  • 劍指offer演算法題解析
  • Python爬蟲相關技術分析和實戰
  • 後臺開發相關技術分析和實戰

同步更新以下部落格

1. Csdn

blog.csdn.net/qqxx6661

擁有專欄:Leetcode題解(Java/Python)、Python爬蟲開發

2. 知乎

www.zhihu.com/people/yang…

擁有專欄:碼農面試助攻手冊

3. 掘金

juejin.im/user/5b4801…

4. 簡書

www.jianshu.com/u/b5f225ca2…

個人專案:電商價格監控網站

本人長期維護的個人專案,完全免費,請大家多多支援。

實現功能

  • 京東商品監控:設定商品ID和預期價格,當商品價格【低於】設定的預期價格後自動傳送郵件提醒使用者。(一小時以內)
  • 京東品類商品監控:使用者訂閱特定品類後,該類降價幅度大於7折的【自營商品】會被選出併傳送郵件提醒使用者。
  • 品類商品瀏覽,商品歷史價格曲線,商品歷史最高最低價
  • 持續更新中...

網站地址

pricemonitor.online/

個人公眾號:Rude3Knife

個人公眾號:Rude3Knife

如果文章對你有幫助,不妨收藏起來並轉發給您的朋友們~

相關文章