Java執行緒安全策略與多執行緒併發最佳實踐
執行緒安全策略
不可變物件
不可變物件(Immutable Objects)是指物件一旦被建立它的狀態(物件的資料,也即物件屬性值)就不能改變,任何對它的改變都應該產生一個新的物件。
不可變物件需要滿足的條件:
- 物件建立以後其狀態就不能修改
- 物件所有域都是final型別
- 物件時正確建立的(在物件建立期間,this引用沒有逸出)
除了使用final自行封裝不可變物件之外,還可以通過以下兩種方式定義不可變物件
- Collections.unmodifiableXXX():XXX可以是Collection、List、Set、Map。使用api將已有普通集合類物件轉變成不可變物件。原理是進行包裝,然後定義所有修改函式丟擲異常。
- guava裡面的ImmutableXXX。XXX可以是Collection、List、Set、Map。
執行緒封閉
當訪問共享的可變資料時,通常需要同步。一種避免同步的方式就是不共享資料。如果僅在單執行緒內訪問資料,就不需要同步,這種技術稱為執行緒封閉。
常見執行緒封閉手段:
- 堆疊封閉:區域性變數,沒有併發問題
- threadlocal:特別好的執行緒封閉方法。通常在filter將使用者資訊儲存threadlocal。
spring中一定要在攔截器afterCompletion中,執行threadlocal的remove函式,執行緒池中使用同理。
同步容器
stringbuilder:執行緒不安全(可以在函式中定義,利用堆疊封閉避免了執行緒不安全,同時節省了加鎖的消耗,效能更好)
stringbuffer:執行緒安全(每個函式都是用synchronized修飾),可以做全域性變數。
SimpleDateFormat:JDK中的工具類,執行緒不安全。使用方法可以參考stringbuilder。
JodaTime:執行緒安全,功能更豐富。
ArrayList/HashSet/HashMap等Collections:都是執行緒不安全的
Vector/Stack/HashTable:都是執行緒安全的
先檢查再執行:if(condition(a)){handle(a)},這種形式如果沒有加鎖的話,就不是原子性,也是執行緒不安全的
併發容器
執行緒安全的容器除了上文提到的同步容器一些外,在Java的J.U.C(java.utils.concurrent的縮寫)下,同樣提供了執行緒安全的併發容器。
- CopyOnWriteArrayList
- 對應ArrayList,是執行緒安全容器。適合讀多寫少的場景(讀不加鎖,寫加可重入鎖。讀是讀的原陣列,寫是在新陣列)
- 缺點:
- 消耗記憶體,可能引發gc
- 不能用於實時讀
- CopyOnWriteArraySet/ConcurrentSkipListSet
- 對應HashSet/TreeSet,是執行緒安全容器。其中CopyOnWriteArraySet底層是CopyOnWriteArrayList
- ConcurrentHashMap/ConcurentSkipListMap
- 對應HashMap/TreeMap,是執行緒安全容器。ConcurentSkipListMap支援key有序,而且後者支援更高的併發數,因為它的存取時間和執行緒數是沒有關係的。
注意:併發容器的批量操作都不是執行緒安全的,例如呼叫removeAll,containsAll等,需要自行加鎖。
CopyOnWriteArrayList、CopyOnWriteArraySet,這種利用cow特性的資料結構,需要copy消耗記憶體,可能引發gc。
想免費學習(Java工程化、分散式架構、高併發、高效能、深入淺出、微服務架構、Spring、MyBatis、Netty、原始碼分析)等技術的朋友,可以加群:834962734,群裡有阿里大牛直播講解技術,以及Java大型網際網路技術的視訊免費分享給大家,歡迎進群一起深入交流學習,不管你是轉行,還是工作中想提升自己能力都可以!
死鎖
執行緒死鎖是指由於兩個或者多個執行緒互相持有對方所需要的資源,導致這些執行緒處於等待狀態,無法前往執行。
死鎖的必要條件
- 互斥條件。程序對於所分配到的資源具有排它性,即一個資源只能被一個程序佔用,直到被該程序釋放
- 請求和保持條件。一個程序因請求被佔用資源而發生阻塞時,對已獲得的資源保持不放。
- 不剝奪條件。任何一個資源在沒被該程序釋放之前,任何其他程序都無法對他剝奪佔用。
- 環路等待條件。當發生死鎖時,所等待的程序必定會形成一個環路(類似於死迴圈),造成永久阻塞。
死鎖示例程式碼:
@Slf4j public class DeadLock implements Runnable { public int flag = 1; //靜態物件是類的所有物件共享的 private static Object o1 = new Object(), o2 = new Object(); @Override public void run() { log.info("flag:{}", flag); if (flag == 1) { synchronized (o1) { try { Thread.sleep(500); } catch (Exception e) { e.printStackTrace(); } synchronized (o2) { log.info("1"); } } } if (flag == 0) { synchronized (o2) { try { Thread.sleep(500); } catch (Exception e) { e.printStackTrace(); } synchronized (o1) { log.info("0"); } } } } public static void main(String[] args) { DeadLock td1 = new DeadLock(); DeadLock td2 = new DeadLock(); td1.flag = 1; td2.flag = 0; //td1,td2都處於可執行狀態,但JVM執行緒排程先執行哪個執行緒是不確定的。 //td2的run()可能在td1的run()之前執行 new Thread(td1).start(); new Thread(td2).start(); } }
避免死鎖的方法
- 注意加鎖順序。當多個執行緒需要相同的一些鎖,但是按照不同的順序加鎖,死鎖就很容易發生。如果能確保所有的執行緒都是按照相同的順序獲得鎖,那麼死鎖就不會發生。
- 加鎖有時限。在嘗試獲取鎖的時候加一個超時時間,這也就意味著在嘗試獲取鎖的過程中若超過了這個時限該執行緒則放棄對該鎖請求。
- 死鎖檢測。主要是針對那些不可能實現按序加鎖並且鎖超時也不可行的場景。每當一個執行緒獲得了鎖,會線上程和鎖相關的資料結構中(map、graph等等)將其記下。除此之外,每當有執行緒請求鎖,也需要記錄在這個資料結構中。當一個執行緒請求鎖失敗時,這個執行緒可以遍歷鎖的關係圖看看是否有死鎖發生。
死鎖排查方法
雖然造成死鎖的原因是因為我們設計得不夠好,但是可能寫程式碼的時候不知道哪裡發生了死鎖。
JDK提供了兩種方式來給我們檢測:
- JconsoleJDK自帶的圖形化介面工具,使用JDK給我們的的工具JConsole
- Jstack是JDK自帶的命令列工具,主要用於執行緒Dump分析。
檢測出死鎖時的解決方案
一個可行的做法是釋放所有鎖,回退,並且等待一段隨機的時間後重試。這個和簡單的加鎖超時類似,不一樣的是隻有死鎖已經發生了才回退,而不會是因為加鎖的請求超時了。雖然有回退和等待,但是如果有大量的執行緒競爭同一批鎖,它們還是會重複地死鎖(編者注:原因同超時類似,不能從根本上減輕競爭)。
一個更好的方案是給這些執行緒設定優先順序,讓一個(或幾個)執行緒回退,剩下的執行緒就像沒發生死鎖一樣繼續保持著它們需要的鎖。如果賦予這些執行緒的優先順序是固定不變的,同一批執行緒總是會擁有更高的優先順序。為避免這個問題,可以在死鎖發生的時候設定隨機的優先順序。
多執行緒併發最佳實踐
1. 使用本地變數
儘量使用本地變數,而不是建立一個類或例項的變數。
class concurrentTask { private static List temp = new ArrayList<>(); public void execute(Message message) { // 使用本地變數保證執行緒安全 // List temp = new ArrayList<>(); temp.add(message.getId()); temp.add(message.getCode()); // ...省略各種業務邏輯 temp.clear(); } }
2. 使用不可變類
不可變類比如String 、Integer等一旦建立,不再改變,不可變類可以降低程式碼中需要的同步數量。
3. 最小化鎖的作用域範圍
"阿姆達爾定律",又稱"安達爾定理": S=1/(1-a+a/n)
a:平行計算部分所佔比例
n:並行處理結點個數
S:加速比
當1-a等於0時,沒有序列只有並行,最大加速比 S=n
當a=0時,只有序列沒有並行,最小加速比 S = 1
當n→∞時,極限加速比 s→ 1/(1-a)
例如,若序列程式碼佔整個程式碼的25%,則並行處理的總體效能不可能超過4。
4. 使用執行緒池,而不是直接使用new Thread執行
避免new Thread建立執行緒。通過執行緒池的管理,可提高執行緒的複用性(避免新建執行緒的昂貴的資源消耗),簡化執行緒生命週期的管理。JDK提供了各種ThreadPool執行緒池和Executor。
5. 寧可使用同步工具類也不要使用執行緒的wait和notify
同步工具類包括:countdownlaunch/Semaphore/Semaphore。應當優先使用這些同步工具,而不是去思考如何使用執行緒的wait和notify。此外,使用BlockingQueue實現生產消費的設計比使用wait和notify要好。
6. 使用blockingqueue實現生產消費模式
阻塞佇列是生產者-消費者模式的最好的實現方式,不僅包括單個生產者單個消費者,還支援多個生產者多個消費者情況。
7. 使用併發集合而不是加了鎖的同步集合
JDK提供了ConcurrentHashMap、CopyOnWriteArrayList、CopyOnWriteArraySet、BlockingQueue中的Deque和BlockingDeque五大併發集合,他們有著較好效能;儘量使用該併發集合,而避免使用synchronizedXXX的鎖同步集合。
8. 使用semaphore建立有界的訪問
為了建立穩定可靠的系統,對於資料庫、檔案系統和socket等資源必須要做有界的訪問,Semaphone可以限制這些資源開銷的選擇,Semaphone可以以最低的代價阻塞執行緒等待,可以通過Semaphone來控制同時訪問指定資源的執行緒數。
9. 寧可使用同步程式碼塊,也不使用同步的方法
主要針對synchronized關鍵字。使用synchronized關鍵字同步程式碼塊只會鎖定一個物件,而不會將整個方法鎖定(當類不是單例的時候)。如果更改共同的變數或類的欄位,首先應該選擇的是原子型變數,然後使用volatile。如果需要互斥鎖,可以考慮使用ReentrantLock。
10. 避免使用靜態變數
靜態變數在多執行緒併發環境中會造成較多的問題。當使用靜態變數時,優先將其指定為final變數,若用其來儲存集合Collection變數,則考慮使用只讀集合。詳見上文的不可變物件,同步容器和併發容器。
我們總是想得太