開發與維運

使用了併發工具類庫,線程就安全了嗎

使用了併發工具類庫,線程就安全了嗎

在這裡插入圖片描述

併發工具類庫

  • 有時會聽到有關線程安全和併發工具的一些片面的觀點和結論,比如“把 HashMap 改為 ConcurrentHashMap ,要不我們試試無鎖的 CopyOnWriteArrayList 吧,性能更好,事實上,這些說法都特殊場景下都不太準確
  • 為了方便開發者進行多線程編程,現代編程語言提供了各種併發工具類 並且提供了 JUC 包 java.util.concurrent , 但是沒有充分了解他們的使用場景、解決的問題,以及最佳實踐的話,盲目使用就可能導致一些坑、小則損失性能,大則無法保證多線程去看下業務邏輯正確性。

在這裡插入圖片描述


1. 沒有意識到線程重用導致用戶信息錯亂的 Bug

ThreadLocal提高一個線程的局部變量,訪問某個線程擁有自己局部變量。我們常常使用使用ThreadLocal 用來存儲用戶信息,但是發現ThreadLocal 有時獲取到的用戶信息是別人的,

我們知道,ThreadLocal適用於變量在線程間隔離,而在方法或類間共享的場景。如果用戶信息的獲取比較昂貴(比如從數據庫查詢用戶信息),那麼在 ThreadLocal中緩存數據是比較合適的做法。但,這麼做為什麼會出現用戶信息錯亂的 Bug ?

案例 :
private ThreadLocal<Integer> currentUser = ThreadLocal.withInitial(() -> null);



 @ApiOperation(value = "test2")
    @GetMapping("/test2")
    public ResponseMessage test2(@ApiParam(value = "id")@RequestParam(required = false) Integer id) {
        //設置用戶信息之前先查詢一次ThreadLocal中的用戶信息
        String before = Thread.currentThread().getName() + ":" + currentUser.get();
        currentUser.set(id);
        String after = Thread.currentThread().getName() + ":" + currentUser.get();
        //彙總輸出兩次查詢結果
        Map result = new HashMap();
        result.put("before", before);
        result.put("after", after);
        return ResultBody.success(result);

在這裡插入圖片描述

在設置用戶信息之前第一次獲取的值始終應該是 null,但我們要意識到,程序運行在 Tomcat 中,執行程序的線程是 Tomcat 的工作線程,而 Tomcat 的工作線程是基於線程池的。

  • 顧名思義,線程池會重用固定的幾個線程,一旦線程重用,那麼很可能首次從 ThreadLocal 獲取的值是之前其他用戶的請求遺留的值。這時,ThreadLocal 中的用戶信息就是其他用戶的信息。所以上圖中我新用戶 獲取到了 舊用戶遺留的 信息,

因為線程的創建比較昂貴,所以web服務器往往會使用線程池來處理請求,就意味著線程會被重用。這是,使用類似ThreadLocal工具來存放一些數據時,需要特別注意在代碼運行完後,顯式的去清空設置的睡覺。如果在代碼中使用來自定義線程池,也同樣會遇到這樣的問題

優化
 @ApiOperation(value = "test2")
    @GetMapping("/test2")
    public ResponseMessage test2(@ApiParam(value = "id")@RequestParam(required = false) Integer id) {
        //設置用戶信息之前先查詢一次ThreadLocal中的用戶信息
        String before = Thread.currentThread().getName() + ":" + currentUser.get();
        currentUser.set(id);
        Map result = new HashMap();
        try {
            String after = Thread.currentThread().getName() + ":" + currentUser.get();
            //彙總輸出兩次查詢結果
            
            result.put("before", before);
            result.put("after", after);
        }finally {
        //在finally代碼塊中刪除ThreadLocal中的數據,確保數據不串
            currentUser.remove();
        }
        return ResultBody.success(result);
    }

1. 使用了線程安全的併發工具,並不代表解決了所有的線程安全問題

JDK 1.5 後推出的 ConcurrentHashMap,是一個高性能的線程安全的哈希表容器。“線程安全”這四個字特別容易讓人誤解,因為 ConcurrentHashMap 只能保證提供的原子性讀寫操作是線程安全的。

案例
public class Test {

    private ConcurrentHashMap<String, Integer> map=new ConcurrentHashMap<String, Integer>();

        public static void main(String[] args) {
            final Test t=new Test();
            for (int i = 0; i < 10000; i++) {
                new Thread(new Runnable() {
                    @Override
                    public void run() {
                        t.add("key");
                    }
                }).start();
            }
        }

    public void add(String key){
        Integer value=map.get(key);
        if(value==null)
            map.put(key, 1);
        else
            map.put(key, value+1);

        System.out.println(map.get(key));
    }



}

在這裡插入圖片描述

解決:

public class Test {

    private ConcurrentHashMap<String, Integer> map=new ConcurrentHashMap<String, Integer>();

        public static void main(String[] args) {
            final Test t=new Test();
            for (int i = 0; i < 10000; i++) {
                new Thread(new Runnable() {
                    @Override
                    public void run() {
                        t.add("key");
                    }
                }).start();
            }
        }

    public synchronized void add(String key){
        Integer value=map.get(key);
        if(value==null)
            map.put(key, 1);
        else
            map.put(key, value+1);

        System.out.println(map.get(key));
    }



}

如果只是調用put或者get方法,ConcurrentHashMap是線程安全的,但是如果調用了get後在調用map.put(key, value+1)之前有另外的線程去調用了put,然後你再去執行put,就有可能將結果覆蓋掉,但這個其實也不能算ConcurrentHashMap線程不安全,ConcurrentHashMap內部操作是線程安全的,但是外部操作還是要靠自己來保證同步,即使在線程安全的情況下,也是可能違反原子操作規則。。。


3. 沒有認清併發工具的使用場景,因而導致性能問題

除了 ConcurrentHashMap 這樣通用的併發工具類之外,我們的工具包中還有些針對特殊場景實現的生面孔。一般來說,針對通用場景的通用解決方案,在所有場景下性能都還可以,屬於“萬金油”;而針對特殊場景的特殊實現,會有比通用解決方案更高的性能,但一定要在它針對的場景下使用,否則可能會產生性能問題甚至是 Bug。

CopyOnWrite 是一個時髦的技術,不管是 Linux 還是 Redis 都會用到。在 Java 中,

CopyOnWriteArrayList 雖然是一個線程安全的 ArrayList,但因為其實現方式是,每次
修改數據時都會複製一份數據出來,所以有明顯的適用場景,即讀多寫少或者說希望無鎖讀的場景。

案例:

測試寫的性能

public static void main(String[] args) {
        CopyOnWriteArrayList<Integer> cowal = new CopyOnWriteArrayList<Integer>();
        ArrayList<Integer> list = new ArrayList<Integer>();

        int count = 500;
        long time1 = System.currentTimeMillis();
        while (System.currentTimeMillis() - time1 < count) {
            cowal.add(1);
        }
        time1 = System.currentTimeMillis();
        while (System.currentTimeMillis() - time1 < count) {
            list.add(1);
        }
        System.out.println("CopyOnWriteArrayList在" + count + "毫秒時間內添加元素個數為:  "
                + cowal.size());
        System.out.println("ArrayList在" + count + "毫秒時間內添加元素個數為:  "
                + list.size());

    }

在這裡插入圖片描述

  • 以 add 方法為例,每次 add 時,都會用 Arrays.copyOf 創建一個新數組,頻繁 add 時內存的申請釋放消耗會很大

讀性能比較

public static void main(String[] args) throws InterruptedException {

        // create object of CopyOnWriteArrayList
        List<Integer> ArrLis = new ArrayList<>();


        List<Integer> copyOnWriteArrayList = new CopyOnWriteArrayList<>();

        System.gc();

        for (int i = 0; i < 100000; i++) {
            ArrLis.add(i);
        }

        for (int i = 0; i < 100000; i++) {
            copyOnWriteArrayList.add(i);
        }

        Thread.sleep(500);
        long startTime = System.currentTimeMillis();    //獲取開始時間
        // print CopyOnWriteArrayList
        System.out.println("ArrayList: "
                + ArrLis);
        // 2nd index in the arraylist
        System.out.println(" index: "
                + ArrLis.get(5000));
        long endTime = System.currentTimeMillis();    //獲取結束時間
        System.out.println(" ArrayList  : 程序運行時間:" + (endTime - startTime) + "ms");    //輸出程序運行時間

        Thread.sleep(500);

        long startTime2 = System.currentTimeMillis();    //獲取開始時間
        // print CopyOnWriteArrayList
        System.out.println("copyOnWriteArrayList: "
                + copyOnWriteArrayList);
        // 2nd index in the arraylist
        System.out.println(" index: "
                + copyOnWriteArrayList.get(5000));
        long endTime2 = System.currentTimeMillis();    //獲取結束時間
        System.out.println(" copyOnWriteArrayList  : 程序運行時間:" + (endTime2 - startTime2) + "ms");    //輸出程序運行時間

        System.gc();
    }

在這裡插入圖片描述

  • 總結:雖然JDK 給我們提供了一些併發工具類,我們要想充分體現他的性能 還需要更加的去了解他的機制 ,不然可能就會成為項目中的累贅

個人博客地址:http://blog.yanxiaolong.cn/

Leave a Reply

Your email address will not be published. Required fields are marked *