併發程式設計的12條規範
來源:撿田螺的小男孩
前言
最近看了一下阿里巴巴Java開發手冊,整理了併發處理的12條規範,並且都給出對應程式碼的例子,大家看完一定會有收穫的。
1. 獲取單例物件需要保證執行緒安全
我們在獲取單例物件的時候,要確保線性安全哈。
比如雙重檢查鎖定(Double-Checked Locking)的單例模式,就是一個經典案例,你在獲取單例項物件的時候,就需要保證線性安全,比如加synchronized
確保現象安全,程式碼如下:
public class Singleton {
private volatile static Singleton instance;
private Singleton() { }
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
大家在寫資源驅動類、工具類、單例工廠類的時候,都需要注意獲取單例物件需要保證執行緒安全哈。
2. 建立執行緒或執行緒池時請指定有意義的執行緒名稱,方便出錯時回溯。
使用執行緒池時,如果沒有給執行緒池一個有意義的名稱,將不好排查回溯問題。
反例:
public class TianLuoBoyThreadTest {
public static void main(String[] args) throws Exception {
ThreadPoolExecutor executorOne = new ThreadPoolExecutor(5, 5, 1,
TimeUnit.MINUTES, new ArrayBlockingQueue<Runnable>(20));
executorOne.execute(()->{
System.out.println("關注公眾號:撿田螺的小男孩");
throw new NullPointerException();
});
}
}
執行結果:
關注公眾號:撿田螺的小男孩
Exception in thread "pool-1-thread-1" java.lang.NullPointerException
at com.example.dto.TianLuoBoyThreadTest.lambda$main$0(ThreadTest.java:17)
at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149)
at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
at java.lang.Thread.run(Thread.java:748)
可以發現,預設列印的執行緒池名字是pool-1-thread-1
,如果排查問題起來,並不友好。因此建議大家給自己執行緒池自定義個容易識別的名字。其實用CustomizableThreadFactory
即可,正例如下:
public class ThreadTest {
public static void main(String[] args) throws Exception {
ThreadPoolExecutor executorOne = new ThreadPoolExecutor(5, 5, 1,
TimeUnit.MINUTES, new ArrayBlockingQueue<Runnable>(20),
new CustomizableThreadFactory("TianluoBoy-Thread-pool"));
executorOne.execute(()->{
System.out.println("關注公眾號:撿田螺的小男孩");
throw new NullPointerException();
});
}
}
3. 執行緒資源必須透過執行緒池提供,不允許在應用中自行顯式建立執行緒。
日常開發中,我們經常需要使用到多執行緒。執行緒資源要求透過執行緒池提供,而不允許顯式建立執行緒。
因為如果顯示建立執行緒,可能造成系統建立大量同類執行緒而導致消耗完記憶體。使用執行緒池主要有這些好處:
幫我們管理執行緒,避免增加建立執行緒和銷燬執行緒的資源損耗。因為執行緒其實也是一個物件,建立一個物件,需要經過類載入過程,銷燬一個物件,需要走 GC
垃圾回收流程,都是需要資源開銷的。提高響應速度:如果任務到達了,相對於從執行緒池拿執行緒,重新去建立一條新執行緒執行,速度肯定慢很多。 重複利用:執行緒用完,再放回池子,可以達到重複利用的效果,節省資源。
反例(顯式建立執行緒):
public class DirectThreadCreation {
public static void main(String[] args) {
for (int i = 0; i < 10; i++) {
Thread thread = new Thread(new WorkerThread("Task " + i));
thread.start();
}
}
}
class WorkerThread implements Runnable {
private String taskName;
public WorkerThread(String taskName) {
this.taskName = taskName;
}
@Override
public void run() {
System.out.println(Thread.currentThread().getName() + " executing " + taskName);
// 執行任務的具體邏輯
}
}
正例(執行緒池):
public class ThreadPoolExample {
public static void main(String[] args) {
// 建立固定大小的執行緒池
ExecutorService executor = Executors.newFixedThreadPool(5);
// 提交任務給執行緒池執行
for (int i = 0; i < 10; i++) {
Runnable task = new WorkerThread("Task " + i);
executor.execute(task);
}
// 關閉執行緒池
executor.shutdown();
}
}
class WorkerThread implements Runnable {
private String taskName;
public WorkerThread(String taskName) {
this.taskName = taskName;
}
@Override
public void run() {
System.out.println(Thread.currentThread().getName() + " executing " + taskName);
// 執行任務的具體邏輯
}
}
4. SimpleDateFormat 是執行緒不安全的類,一般不要定義為 static 變數,如果定義為static,必須加鎖
SimpleDateFormat 是執行緒不安全的類,因為它內部維護了一個 Calendar 例項,而 Calendar 不是執行緒安全的。因此,在多執行緒環境下,如果多個執行緒共享一個 SimpleDateFormat 例項,可能會導致併發問題。
如果需要在多執行緒環境下使用SimpleDateFormat
,可以透過加鎖的方式來確保執行緒安全。
public class SafeDateFormatExample {
private static final Object lock = new Object();
private static final SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
public static void main(String[] args) {
Runnable task = () -> {
try {
parseAndPrintDate("2022-01-01 12:30:45");
} catch (ParseException e) {
e.printStackTrace();
}
};
// 啟動多個執行緒來同時解析日期
for (int i = 0; i < 5; i++) {
new Thread(task).start();
}
}
private static void parseAndPrintDate(String dateString) throws ParseException {
synchronized (lock) {
Date date = sdf.parse(dateString);
System.out.println(Thread.currentThread().getName() + ": Parsed date: " + date);
}
}
}
5. 執行緒池不允許使用 Executors 去建立,而是透過 ThreadPoolExecutor 的方式
這是因為Executors 返回的執行緒池:
FixedThreadPool
允許的請求佇列長度為Integer.MAX_VALUE
,可能會堆積大量的請求,從而導致OOM
CachedThreadPool
:允許的建立執行緒數量為 Integer.MAX_VALUE,可能會建立大量的執行緒,從而導致OOM
。
反例:
/**
* 公眾號:撿田螺的小男孩
*/
public class NewFixedTest {
public static void main(String[] args) {
ExecutorService executor = Executors.newFixedThreadPool(10);
for (int i = 0; i < Integer.MAX_VALUE; i++) {
executor.execute(() -> {
try {
Thread.sleep(10000);
} catch (InterruptedException e) {
//do nothing
}
});
}
}
}
使用 Executors的newFixedThreadPool
建立的執行緒池,是會有坑的,它預設是無界的阻塞佇列,如果任務過多,會導致OOM問題。執行一下以上程式碼,出現了OOM。
Exception in thread "main" java.lang.OutOfMemoryError: GC overhead limit exceeded
at java.util.concurrent.LinkedBlockingQueue.offer(LinkedBlockingQueue.java:416)
at java.util.concurrent.ThreadPoolExecutor.execute(ThreadPoolExecutor.java:1371)
at com.example.dto.NewFixedTest.main(NewFixedTest.java:14)
這是因為Executors
的newFixedThreadPool
使用了無界的阻塞佇列的LinkedBlockingQueue
,如果執行緒獲取一個任務後,任務的執行時間比較長(比如,上面demo程式碼設定了10秒),會導致佇列的任務越積越多,導致機器記憶體使用不停飆升, 最終出現OOM。
而ThreadPoolExecutor
建立的時候,需要明確配置執行緒池引數,可以避免資源耗盡風險。
6. 高併發的時候,同步呼叫要考慮鎖的粒度。
高併發時,同步呼叫應該去考量鎖的效能損耗。能用無鎖資料結構,就不要用鎖;能鎖區塊,就不要鎖整個方法體;能用物件鎖,就不要用類鎖。
通俗易懂講就是,在保證資料安全的情況下,儘可能使加鎖的程式碼塊工作量儘可能的小。因為在高併發場景,為了防止超賣等情況,我們經常需要加鎖來保護共享資源。但是,如果加鎖的粒度過粗,是很影響介面效能的。 再比如,我們不推薦在加鎖的程式碼塊中,再呼叫RPC
方法。
對於鎖的粒度,我給大家個程式碼例子哈:
比如,在業務程式碼中,有一個ArrayList
因為涉及到多執行緒操作,所以需要加鎖操作,假設剛好又有一段比較耗時的操作(程式碼中的slowNotShare
方法)不涉及執行緒安全問題。反例加鎖,就是一鍋端,全鎖住:
//不涉及共享資源的慢方法
private void slowNotShare() {
try {
TimeUnit.MILLISECONDS.sleep(100);
} catch (InterruptedException e) {
}
}
//錯誤的加鎖方法
public int wrong() {
long beginTime = System.currentTimeMillis();
IntStream.rangeClosed(1, 10000).parallel().forEach(i -> {
//加鎖粒度太粗了,slowNotShare其實不涉及共享資源
synchronized (this) {
slowNotShare();
data.add(i);
}
});
log.info("cosume time:{}", System.currentTimeMillis() - beginTime);
return data.size();
}
正例:
public int right() {
long beginTime = System.currentTimeMillis();
IntStream.rangeClosed(1, 10000).parallel().forEach(i -> {
slowNotShare();//可以不加鎖
//只對List這部分加鎖
synchronized (data) {
data.add(i);
}
});
log.info("cosume time:{}", System.currentTimeMillis() - beginTime);
return data.size();
}
7. HashMap 在容量不夠進行 resize 時由於高併發可能出現死鏈,導致 CPU 飆升。
HashMap 在容量不夠進行 resize 時由於高併發可能出現死鏈,導致 CPU 飆升。在開發過程中可以使用其它資料結構或加鎖來規避此風險。
在普通的 HashMap
中,可能出現死鎖的場景通常與多執行緒併發修改 HashMap 的結構有關。這種情況下,多個執行緒同時對 HashMap 進行插入、刪除等操作,可能導致連結串列形成環,進而導致死鎖。
比如這個例子,演示了多執行緒同時對 HashMap 進行修改可能導致死鎖的情況:
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.CountDownLatch;
public class HashMapDeadlockExample {
public static void main(String[] args) throws InterruptedException {
final Map<String, String> hashMap = new HashMap<>();
final CountDownLatch latch = new CountDownLatch(2);
// 執行緒1向HashMap中插入元素
Thread thread1 = new Thread(() -> {
for (int i = 0; i < 100000; i++) {
hashMap.put(String.valueOf(i), String.valueOf(i));
}
latch.countDown();
});
// 執行緒2刪除HashMap中的元素
Thread thread2 = new Thread(() -> {
for (int i = 0; i < 100000; i++) {
hashMap.remove(String.valueOf(i));
}
latch.countDown();
});
thread1.start();
thread2.start();
// 等待兩個執行緒執行完成
latch.await();
// 列印HashMap的大小
System.out.println("HashMap size: " + hashMap.size());
}
}
解決或規避這個問題的方式可以使用使用ConcurrentHashMap
: ConcurrentHashMap
是 HashMap
的執行緒安全版本,它使用了分段鎖(Segment)來提高併發效能,減小鎖的粒度,降低了併發衝突的可能性。
8.使用 CountDownLatch 進行非同步轉同步操作,每個執行緒退出前必須呼叫 countDown方法。
使用 CountDownLatch 進行非同步轉同步操作,每個執行緒退出前必須呼叫 countDown
方法,執行緒執行程式碼注意 catch
異常,確保 countDown 方法被執行到,避免主執行緒無法執行至 await 方法,直到超時才返回結果。
CountDownLatch
是一個多執行緒同步工具,它的作用是允許一個或多個執行緒等待其他執行緒完成操作。在這裡,你想要使用 CountDownLatch
實現非同步轉同步操作,確保每個執行緒退出前都呼叫countDown
方法。給個程式碼示例,演示瞭如何使用 CountDownLatch 實現這種同步:
import java.util.concurrent.CountDownLatch;
public class AsyncToSyncExample {
public static void main(String[] args) throws InterruptedException {
int numThreads = 3; // 假設有3個執行緒
// 建立一個 CountDownLatch,計數器初始化為執行緒數量
CountDownLatch latch = new CountDownLatch(numThreads);
// 啟動多個執行緒
for (int i = 0; i < numThreads; i++) {
Thread thread = new Thread(() -> {
try {
// 執行緒執行的業務邏輯
doSomeWork();
} catch (Exception e) {
e.printStackTrace();
} finally {
// 無論如何,都需要呼叫 countDown 方法
latch.countDown();
}
});
thread.start();
}
// 等待所有執行緒完成,最多等待5秒(超時時間可以根據實際情況調整)
if (!latch.await(5000, java.util.concurrent.TimeUnit.MILLISECONDS)) {
// 超時處理邏輯
System.out.println("Timeout while waiting for threads to finish.");
} else {
// 所有執行緒執行完成後的邏輯
System.out.println("All threads have finished their work.");
}
}
private static void doSomeWork() {
// 模擬執行緒執行的業務邏輯
try {
Thread.sleep(2000);
System.out.println(Thread.currentThread().getName() + " has finished its work.");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
9. 多執行緒並行處理定時任務時,Timer 執行多個 TimeTask 時,只要其中之一沒有捕獲丟擲的異常,其它任務便會自動終止執行。
在 Timer 執行多個 TimerTask 時,如果其中一個 TimerTask 丟擲了未捕獲的異常,將導致整個 Timer 終止,而未丟擲異常的任務也將停止執行。這是因為 Timer 的設計導致一個任務的異常會影響到整個 Timer 的執行。程式碼如下:
import java.util.Timer;
import java.util.TimerTask;
public class TimerTaskExample {
public static void main(String[] args) {
Timer timer = new Timer();
// 任務1,丟擲異常
TimerTask task1 = new TimerTask() {
@Override
public void run() {
System.out.println("Task 1 is running...");
throw new RuntimeException("Exception in Task 1");
}
};
// 任務2
TimerTask task2 = new TimerTask() {
@Override
public void run() {
System.out.println("Task 2 is running...");
}
};
// 安排任務1和任務2執行
timer.schedule(task1, 0, 1000);
timer.schedule(task2, 0, 1000);
}
}
使用 ScheduledExecutorService
則沒有這個問題:
public class ScheduledExecutorExample {
public static void main(String[] args) {
ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(2);
// 任務1,每隔2秒執行一次,可能丟擲異常
scheduler.scheduleAtFixedRate(() -> {
try {
System.out.println("Task 1 is running...");
throw new RuntimeException("Exception in Task 1");
} catch (Exception e) {
e.printStackTrace();
}
}, 0, 2, TimeUnit.SECONDS);
// 任務2,每隔3秒執行一次
scheduler.scheduleAtFixedRate(() -> {
try {
System.out.println("Task 2 is running...");
} catch (Exception e) {
e.printStackTrace();
}
}, 0, 3, TimeUnit.SECONDS);
}
}
10. 避免 Random 例項被多執行緒使用,雖然共享該例項是執行緒安全的,但會因競爭同一seed 導致的效能下降。
雖然 Random
例項的方法是執行緒安全的,但是當多個執行緒共享相同的Random
例項並競爭相同的 seed
時,可能會因為競爭而導致效能下降。這是因為 Random 使用一個原子變數來維護其內部狀態,當多個執行緒同時呼叫 nextInt
等方法時,可能會發生競爭,從而影響效能。
大家可以看下這個例子哈:
import java.util.Random;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
public class SharedRandomPerformanceExample {
public static void main(String[] args) throws InterruptedException {
int numThreads = 10;
int iterations = 1000000;
// 共享一個 Random 例項
Random sharedRandom = new Random();
// 使用多執行緒執行任務
ExecutorService executorService = Executors.newFixedThreadPool(numThreads);
for (int i = 0; i < numThreads; i++) {
executorService.execute(() -> {
for (int j = 0; j < iterations; j++) {
int randomNumber = sharedRandom.nextInt();
// 模擬使用隨機數的業務邏輯
}
});
}
executorService.shutdown();
executorService.awaitTermination(1, TimeUnit.MINUTES);```
}
}
在這個例子中,多個執行緒共享相同的 Random
例項 sharedRandom
,並且在迴圈中呼叫 nextInt
方法。由於 Random
內部使用CAS
操作來維護其狀態,多個執行緒可能會競爭同一 seed
導致效能下降。
如果你希望避免這種競爭,可以考慮為每個執行緒建立獨立的 Random 例項,以確保每個執行緒都有自己的狀態。在 JDK7
之後,可以直接使用 API ThreadLocalRandom
,而在 JDK7 之前,需要編碼保證每個執行緒持有一個例項。
11.併發修改同一記錄時,避免更新丟失,需要加鎖。
併發修改同一記錄時,避免更新丟失,需要加鎖。要麼在應用層加鎖,要麼在快取加鎖,要麼在資料庫層使用樂觀鎖,使用 version
作為更新依據。
如果每次訪問衝突機率小於20%
,推薦使用樂觀鎖,因為證明併發不是很高。否則使用悲觀鎖。樂觀鎖的重試次數不得小於3 次。
12. 對多個資源、資料庫表、物件同時加鎖時,需要保持一致的加鎖順序,否則可能會造成死鎖。
執行緒一需要對錶 A、B、C
依次全部加鎖後才可以進行更新操作,那麼執行緒二的加鎖順序也必須是 A、B、C
,否則可能出現死鎖。在多執行緒環境中,當需要對多個資源、資料庫表或物件同時加鎖時,為了避免死鎖,所有執行緒必須保持一致的加鎖順序。這就是所謂的"鎖順序規範"。
大家有興趣可以看下這個例子哈,兩個執行緒按照相同的順序加鎖以避免死鎖:
public class DeadlockExample {
private static final Object lockA = new Object();
private static final Object lockB = new Object();
public static void main(String[] args) {
Thread thread1 = new Thread(() -> {
synchronized (lockA) {
System.out.println("Thread 1 acquired lockA");
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (lockB) {
System.out.println("Thread 1 acquired lockB");
}
}
});
Thread thread2 = new Thread(() -> {
// 保持一致的加鎖順序,先嚐試獲取 lockA,再獲取 lockB
synchronized (lockA) {
System.out.println("Thread 2 acquired lockA");
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (lockB) {
System.out.println("Thread 2 acquired lockB");
}
}
});
thread1.start();
thread2.start();
}
}
來自 “ ITPUB部落格 ” ,連結:https://blog.itpub.net/70027824/viewspace-3003860/,如需轉載,請註明出處,否則將追究法律責任。
相關文章
- Java併發程式設計---java規範與模式下的併發程式設計1.1Java程式設計模式
- ios12設計規範iOS
- ios12設計規範(上)iOS
- JS程式設計規範JS程式設計
- React程式設計規範React程式設計
- python程式設計規範Python程式設計
- 程式設計小記-程式設計規範程式設計
- MySQL資料庫規範 (設計規範+開發規範+操作規範)MySql資料庫
- Go 語言程式設計規範Go程式設計
- python 程式設計規範有哪些?Python程式設計
- 程式設計命名規範(網文)程式設計
- MySQL 設計與開發規範MySql
- 併發程式設計程式設計
- Golang 併發程式設計中條件變數的理解與使用Golang程式設計變數
- Shell程式設計規範與變數程式設計變數
- Python程式設計規範+最佳實踐Python程式設計
- 上位機程式設計編碼規範程式設計
- 微信小程式元件設計規範微信小程式元件
- SAP官方釋出的ABAP程式設計規範程式設計
- java併發程式設計系列:java併發程式設計背景知識Java程式設計
- Java併發程式設計的藝術,解讀併發程式設計的優缺點Java程式設計
- 名片設計規範
- Greenplum索引設計的規範索引
- Restful API 的設計規範RESTAPI
- java 併發程式設計Java程式設計
- 併發程式設計—— LinkedTransferQueue程式設計
- 併發程式設計(ReentrantLock)程式設計ReentrantLock
- Go 併發程式設計Go程式設計
- golang併發程式設計Golang程式設計
- Golang 併發程式設計Golang程式設計
- Python併發程式設計Python程式設計
- 併發程式設計 synchronized程式設計synchronized
- 併發程式設計(四)程式設計
- 併發程式設計(二)程式設計
- Java併發程式設計Java程式設計
- 併發程式設計13程式設計
- .NET 中的併發程式設計程式設計
- Go 併發程式設計 - 併發安全(二)Go程式設計