併發程式設計的12條規範

架構師修行手冊發表於2024-01-15

來源:撿田螺的小男孩

前言

最近看了一下阿里巴巴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)

這是因為ExecutorsnewFixedThreadPool使用了無界的阻塞佇列的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 ConcurrentHashMapHashMap 的執行緒安全版本,它使用了分段鎖(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/,如需轉載,請註明出處,否則將追究法律責任。

相關文章