一、synchronized 原理
1.1 基本特點:
結合上面的鎖策略,我們就可以總結出,synchronized 具有以下特性(只考慮 JDK 1.8):
-
開始時是樂觀鎖,如果鎖沖突頻繁,就轉換為悲觀鎖。
-
開始是輕量級鎖實現,如果鎖被持有的時間較長,就轉換成重量級鎖。
-
實現輕量級鎖的時候大概率用到的自旋鎖策略。
-
是一種不公平鎖。
-
是一種可重入鎖。
-
不是讀寫鎖。
1.2 加鎖工作過程:
JVM 將 synchronized 鎖分為無鎖、偏向鎖、輕量級鎖、重量級鎖狀態。會根據情況,進行依次升級。
1.2.1 偏向鎖:
第一個嘗試加鎖的線程,優先進入偏向鎖狀態。 偏向鎖不是真的 “加鎖”,只是給對象頭中做一個 “偏向鎖的標記”,記錄這個鎖屬于哪個線程。如果后續沒有其他線程來競爭該鎖,那么就不用進行其他同步操作了(避免了加鎖解鎖的開銷)。如果后續有其他線程來競爭該鎖(剛才已經在鎖對象中記錄了當前鎖屬于哪個線程了,很容易識別當前申請鎖的線程是不是之前記錄的線程),那就取消原來的偏向鎖狀態,進入一般的輕量級鎖狀態。偏向鎖本質上相當于 “延遲加鎖”。能不加鎖就不加鎖,盡量來避免不必要的加鎖開銷。但是該做的標記還是得做的,否則無法區分何時需要真正加鎖。
1.2.2 輕量級鎖:
隨著其他線程進入競爭,偏向鎖狀態被消除,進入輕量級鎖狀態(自適應的自旋鎖)。此處的輕量級鎖就是通過 CAS 來實現。通過 CAS 檢查并更新一塊內存(比如 null => 該線程引用)如果更新成功,則認為加鎖成功,如果更新失敗,則認為鎖被占用,繼續自旋式的等待(并不放棄CPU)。
自旋操作是一直讓 CPU 空轉,比較浪費 CPU 資源。因此此處的自旋不會一直持續進行,而是達到一定的時間 / 重試次數,就不再自旋了。也就是所謂的 “自適應” 。
1.2.3 重量級鎖:
如果競爭進一步激烈,自旋不能快速獲取到鎖狀態,就會膨脹為重量級鎖。此處的重量級鎖就是指用到內核提供的 mutex 。
執行加鎖操作,先進入內核態,在內核態判定當前鎖是否已經被占用,如果該鎖沒有占用,則加鎖成功,并切換回用戶態,如果該鎖被占用,則加鎖失敗。此時線程進入鎖的等待隊列,掛起等待被操作系統喚醒。經歷了一系列的滄海桑田,這個鎖被其他線程釋放了,操作系統也想起了這個被掛起的線程,于是喚醒這個線程,嘗試重新獲取鎖。
1.3 鎖消除:
編譯器 + JVM 判斷鎖是否可消除。 如果可以,就直接消除。
鎖消除常常運用在:有些應用程序的代碼中,用到了 synchronized,但其實沒有在多線程環境下。(例如 StringBuffer)
StringBuffer sb = new StringBuffer();
sb.append("a");
sb.append("b");
sb.append("c");
sb.append("d");
此時每個 append 的調用都會涉及加鎖和解鎖,但如果只是在單線程中執行這個代碼,那么這些加鎖解鎖操作是沒有必要的,白白浪費了一些資源開銷。
1.4 鎖粗化:
一段邏輯中如果出現多次加鎖解鎖,編譯器 + JVM 會自動進行鎖的粗化。 鎖粗化這里涉及一個概念粒度(不是力度):加鎖的范圍內,包含代碼的多少,包含的代碼越多,就認為鎖的粒度就越粗,反之,鎖的粒度就越細。
綜上可以看到,synchronized 的策略是比較復雜的,在背后做了很多事情,目的為了讓程序猿哪怕啥都不懂,也不至于寫出特別慢的程序。JVM 開發者為了 Java 程序猿操碎了心。
1.5 面試題:
- 什么是偏向鎖?
答:偏向鎖不是真的加鎖,而只是在鎖的對象頭中記錄?個標記(記錄該鎖所屬的線程)。如果沒有其他線程參與競爭鎖,那么就不會真正執行加鎖操作,從而降低程序開銷。?旦真的涉及到其他的線程競爭,再取消偏向鎖狀態,進入輕量級鎖狀態。
- synchronized 實現原理是什么?
答:剛開始是一個標記,遇到所沖突升級成輕量級鎖,采用自旋鎖的方式實現,隨著鎖沖突的升級,鎖升級為重量級鎖,采用掛起等待鎖的方式,來實現鎖。
二、JUC(java.util.concurrent)的常見類
在 java.util.concurrent 中放了和多線程相關的組件。
2.1 Callable 接口:
Callable 是?個接口,相當于把線程封裝了?個 “返回值”。方便程序猿借助多線程的方式計算結果。可以認為是一個帶返回參數的 runnable 。里面要重寫的方法是 call( )。
- 理解 Callable 和 FutureTask:
Callable 和 Runnable 相對,都是描述一個 “任務”。Callable 描述的是帶有返回值的任務,Runnable
描述的是不帶返回值的任務。Callable 通常需要搭配 FutureTask 來使用 FutureTask 用來保存 Callable 的返回結果。因為 Callable 往往是在另?個線程中執行的,啥時候執行完并不確定。FutureTask 就可以負責這個等待結果出來的工作(如果在 futureTask.get()線程還沒執行完畢就會阻塞等待)。
FutureTask 可以直接傳入 Thread 的構造方法當中,于是我們掌握的 Thread 的構造方式又多了一種。
我們可以將它們的關系理解成吃麻辣燙的情形:去吃麻辣燙,Callable 就是菜籃,重寫的 call 方法里面就是點的菜,當餐點好后,前臺會給你一張 “小票” ,后廚開始工作(Thread 啟動)。這個小票就是 FutureTask,后面我們可以隨時憑這張小票去查看自己的這份麻辣燙做出來了沒有(線程是否執行完畢)。
演示案例:創建線程計算 1 + 2 + 3 + … + 1000,使用 Callable 版本。
import java.util.concurrent.*;
public class demo2 {public static void main(String[] args) throws InterruptedException, ExecutionException {Callable<Integer> callable = new Callable<Integer>() {//菜籃子int result = 0;@Overridepublic Integer call() throws Exception {//菜for(int i = 1;i <= 1000;i++){result += i;}return result;}};FutureTask<Integer> futureTask = new FutureTask<>(callable);//小票Thread t1 = new Thread(futureTask);//后廚t1.start();//后廚開始工作t1.join();System.out.println(futureTask.get());//小票取餐}
}
案例演示效果如下:
2.2 ReentrantLock:
顧名思義:可重入互斥鎖和 synchronized 定位類似,都是用來實現互斥效果,保證線程安全。
- ReentrantLock 的用法:
- lock():加鎖,如果獲取不到鎖就死等。
- trylock(超時時間):加鎖,如果獲取不到鎖,等待?定的時間之后就放棄加鎖。
- unlock():解鎖。
隨著版本的升級 synchronized 越來越好用了,ReentrantLock 就漸漸的用的少了,但是這里我們還是要學習,就說明其相對于 synchronized 有著一些特有的優勢:
- ReentrantLock 與 synchronized 的區別:
- synchronized 是一個關鍵字,是 JVM 內部實現的(大概率是基于 C++ 實現)。ReentrantLock 是標準庫的一個類,在 JVM 外實現的(基于 Java 實現)。
- synchronized 使用時不需要手動釋放鎖。ReentrantLock 使用時需要手動釋放,使用起來更靈活但是也容易遺漏 unlock。
- synchronized 在申請鎖失敗時,會死等。ReentrantLock 可以通過 trylock 的方式等待一段時間就放棄。
- synchronized 是非公平鎖,ReentrantLock 默認是非公平鎖,可以通過構造方法傳入一個 true 開啟公平鎖模式。
- 更強大的喚醒機制。synchronized 是通過 Object類 的 wait / notify 實現等待-喚醒。每次喚醒的是一個隨機等待的線程。ReentrantLock 搭配 Condition 類實現等待-喚醒,可以更精確控制喚醒某個指定的線程。
如何選擇使用哪個鎖?
- 鎖競爭不激烈的時候,使用 synchronized,效率更高,自動釋放更方便。
- 鎖競爭激烈的時候,使用 ReentrantLock,搭配 trylock 更靈活控制加鎖的行為,而不是死等。
- 如果需要使用公平鎖,使用 ReentrantLock。
2.3 原子類:
原子類內部用的是 CAS 實現,所以性能要比加鎖實現 i++ 高很多。原子類有以下幾個:
由于此模塊在上一章多線程進階(1)的 CAS 的應用中已經詳細解釋,這里就不再贅述。
2.4 Semaphore 信號量:
信號量,用來表示 “可用資源的個數”。本質上就是一個計數器。
- 理解信號量:
可以把信號量想象成是停車場的展示牌:當前有車位 100 個。表示有 100個可用資源。當有車開進去的時候,就相當于申請一個可用資源,可用車位就 -1(這個稱為信號量的 P操作)當有車開出來的時候,就相當于釋放一個可用資源,可用車位就 +1(這個稱為信號量的 V 操作)如果計數器的值已經為 0了,還嘗試申請資源,就會阻塞等待,直到有其他線程釋放資源。
Semaphore 的 PV 操作中的加減計數器操作都是原子的,可以在多線程環境下直接使用。
- 代碼案例演示:
import java.util.concurrent.*;
public class demo4 {public static void main(String[] args) {Semaphore semaphore = new Semaphore(1);Runnable runnable = new Runnable() {@Overridepublic void run() {try {semaphore.acquire();System.out.println(Thread.currentThread().getName() + "獲取到資源");} catch (InterruptedException e) {throw new RuntimeException(e);};try {Thread.sleep(3000);} catch (InterruptedException e) {throw new RuntimeException(e);}System.out.println(Thread.currentThread().getName() + "釋放資源");semaphore.release();}};Thread t1 = new Thread(runnable);Thread t2 = new Thread(runnable);t1.start();t2.start();}
}
- 案例演示結果如下:
只有在資源還有剩余的情況下進行 acquire 才不會進行阻塞。
在信號量為 1 是可以將其當作鎖來使用。鎖可以看成是 semaphore 的特例。
2.5 CountDownLatch:
同時等待 N 個任務執行結束。
當我們把一個任務拆分成很多個的時候,可以通過這個工具類來識別任務是否整體執行完畢了。
使用過程:構造 CountDownLatch 實例,初始化 10 表示有 10 個任務需要完成。每個任務執行完畢,都調用 latch.countDown() 。在 CountDownLatch 內部的計數器同時自減,主線程中使用latch.await();阻塞等待。所有任務執行完畢,相當于計數器為 0 了,解除阻塞。好處是如果是多個線程不用寫多個 join()。
案例演示:
import java.util.concurrent.CountDownLatch;
public class demo5 {public static void main(String[] args) throws InterruptedException {CountDownLatch latch = new CountDownLatch(2);Runnable runnable = new Runnable() {@Overridepublic void run() {try {Thread.sleep(3000);} catch (InterruptedException e) {throw new RuntimeException(e);}System.out.println(Thread.currentThread().getName() + "執行完畢");latch.countDown();}};Thread t1 = new Thread(runnable);Thread t2 = new Thread(runnable);t1.start();t2.start();latch.await();System.out.println("Main 線程執行完畢");}
}
效果如下:
Main 線程會等待 Thread 1,2 執行完畢后,再繼續執行。
三、線程安全的集合類
原來的集合類,大部分都不是線程安全的。(Vector,Stack,HashTable,是線程安全的(不建議用),其他的集合類不是線程安全的)。
3.1 多線程環境使用 ArrayList:
-
自己使用同步機制(synchronized 或者 ReentrantLock)
-
Collections.synchronizedList(new ArrayList):synchronizedList 是標準庫提供的一個基于 synchronized 進行線程同步的 List.synchronizedList 的關鍵操作上都帶有 synchronized。
-
使用 CopyOnWriteArrayList:當我們往一個容器中添加元素的時候,不直接往當前容器添加,而是先將當前容器進行 Copy,復制出一個新的容器,然后在新的容器里面添加元素,添加完元素之后,再將原容器的引用指向新的容器。
這樣做的好處是我們可以對 CopyOnWrite 容器進行并發的讀,而不需要加鎖,因為當前容器不會添加任何元素。所以 CopyOnWrite 容器也是一種讀寫分離的思想,讀和寫不同的容器。
優點:在讀多寫少的場景下,性能很高,不需要加鎖競爭。
缺點:1. 占用內存較多。2. 新寫的數據不能被第一時間讀取到。
3.2 多線程環境使用隊列:
-
ArrayBlockingQueue:基于數組實現的阻塞隊列。
-
LinkedBlockingQueue:基于鏈表實現的阻塞隊列。
-
PriorityBlockingQueue:基于堆實現的帶優先級的阻塞隊列。
-
TransferQueue:最多只包含一個元素的阻塞隊列。
3.3 多線程環境使用哈希表:
HashMap 本身不是線程安全的。在多線程環境下使用哈希表可以使用:Hashtable(不推薦使用),ConcurrentHashMap(推薦使用這個)。
3.3.1 Hashtable:
只是簡單的把關鍵方法加上了 synchronized 關鍵字。
這相當于直接針對 Hashtable 對象本省加鎖。不推薦使用這個的原因如下:
如果多線程訪問同?個 Hashtable 就會直接造成鎖沖突(沖突率太高了)。
size 屬性也是通過 synchronized 來控制同步,也是比較慢的。
?旦觸發擴容,就由該線程完成整個擴容過程。這個過程會涉及到大量的元素拷貝,效率會非常低。
3.3.2 ConcurrentHashMap:
相比于 Hashtable 做出了一系列的改進和優化。以 Java1.8 為例:
讀操作沒有加鎖(但是使用了 volatile 保證從內存讀取結果),只對寫操作進行加鎖,加鎖的方式仍然是用 synchronized,但不是鎖整個對象,而是 “鎖桶” (用每個鏈表的頭結點作為鎖對象))大大降低了鎖沖突的概率。
充分利用 CAS 特性。比如 size 屬性通過 CAS 來更新。避免出現重量級鎖的情況。
優化了擴容方式:化整為零。
發現需要擴容的線程,只需要創建?個新的數組,同時只搬幾個元素過去。擴容期間,新老數組同時存在。后續每個來操作 ConcurrentHashMap 的線程,都會參與搬家的過程。每個操作負責搬運一小部分元素。搬完最后一個元素再把老數組刪掉。這個期間,插入只往新數組中加。這個期間,查找需要同時查新數組和老數組。
結語:
其實寫博客不僅僅是為了教大家,同時這也有利于我鞏固知識點,和做一個學習的總結,由于作者水平有限,對文章有任何問題還請指出,非常感謝。如果大家有所收獲的話還請不要吝嗇你們的點贊收藏和關注,這可以激勵我寫出更加優秀的文章。
?