Java業務開發常見錯誤

1.併發工具類(沒有意識到線程重用導致用戶信息錯亂的 Bug)

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

案例:使用 Spring Boot 創建一個 Web 應用程序,使用 ThreadLocal 存放一個 Integer 的值,來暫且代表需要在線程中保存的用戶信息,這個值初始是 null。在業務邏輯中,我先從 ThreadLocal 獲取一次值,然後把外部傳入的參數設置到 ThreadLocal 中,來模擬從當前上下文獲取到用戶信息的邏輯,隨後再獲取一次值,最後輸出兩次獲得的值和線程名稱。

代碼:

private static final ThreadLocal<Integer> currentUser = ThreadLocal.withInitial(() -> null);

@GetMapping("wrong")
public Map wrong(@RequestParam("userId") Integer userId) {
    //設置用戶信息之前先查詢一次ThreadLocal中的用戶信息
    String before  = Thread.currentThread().getName() + ":" + currentUser.get();
    //設置用戶信息到ThreadLocal
    currentUser.set(userId);
    //設置用戶信息之後再查詢一次ThreadLocal中的用戶信息
    String after  = Thread.currentThread().getName() + ":" + currentUser.get();
    //彙總輸出兩次查詢結果
    Map result = new HashMap();
    result.put("before", before);
    result.put("after", after);
    return result;
}

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

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

爲了重現這個問題,將工作線程池改成1

運行程序後先讓用戶 1 來請求接口,可以看到第一和第二次獲取到用戶 ID 分別是 null 和 1,符合預期:

隨後用戶 2 來請求接口,這次就出現了 Bug,第一和第二次獲取到用戶 ID 分別是 1 和 2,顯然第一次獲取到了用戶 1 的信息,原因就是 Tomcat 的線程池重用了線程。從圖中可以看到,兩次請求的線程都是同一個線程:http-nio-8080-exec-1。

這個例子告訴我們,在寫業務代碼時,首先要理解代碼會跑在什麼線程上:

我們可能會抱怨學多線程沒用,因爲代碼裏沒有開啓使用多線程。但其實,可能只是我們沒有意識到,在 Tomcat 這種 Web 服務器下跑的業務代碼,本來就運行在一個多線程環境(否則接口也不可能支持這麼高的併發),並不能認爲沒有顯式開啓多線程就不會有線程安全問題。

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

理解了這個知識點後,我們修正這段代碼的方案是,在代碼的 finally 代碼塊中,顯式清除 ThreadLocal 中的數據。這樣一來,新的請求過來即使使用了之前的線程也不會獲取到錯誤的用戶信息了。修正後的代碼如下:

@GetMapping("right")
public Map right(@RequestParam("userId") Integer userId) {
    String before  = Thread.currentThread().getName() + ":" + currentUser.get();
    currentUser.set(userId);
    try {
        String after = Thread.currentThread().getName() + ":" + currentUser.get();
        Map result = new HashMap();
        result.put("before", before);
        result.put("after", after);
        return result;
    } finally {
        //在finally代碼塊中刪除ThreadLocal中的數據,確保數據不串
        currentUser.remove();
    }
}

ThreadLocal 是利用獨佔資源的方式,來解決線程安全問題,那如果我們確實需要有資源在線程之間共享,應該怎麼辦呢?這時,我們可能就需要用到線程安全的容器了

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

例子:我在相當多的業務代碼中看到過這個誤區,比如下面這個場景。有一個含 900 個元素的 Map,現在再補充 100 個元素進去,這個補充操作由 10 個線程併發進行。開發人員誤以爲使用了 ConcurrentHashMap 就不會有線程安全問題,於是不加思索地寫出了下面的代碼:在每一個線程的代碼邏輯中先通過 size 方法拿到當前元素數量,計算 ConcurrentHashMap 目前還需要補充多少元素,並在日誌中輸出了這個值,然後通過 putAll 方法把缺少的元素添加進去。爲方便觀察問題,我們輸出了這個 Map 一開始和最後的元素個數。


//線程個數
private static int THREAD_COUNT = 10;
//總元素數量
private static int ITEM_COUNT = 1000;

//幫助方法,用來獲得一個指定元素數量模擬數據的ConcurrentHashMap
private ConcurrentHashMap<String, Long> getData(int count) {
    return LongStream.rangeClosed(1, count)
            .boxed()
            .collect(Collectors.toConcurrentMap(i -> UUID.randomUUID().toString(), Function.identity(),
                    (o1, o2) -> o1, ConcurrentHashMap::new));
}

@GetMapping("wrong")
public String wrong() throws InterruptedException {
    ConcurrentHashMap<String, Long> concurrentHashMap = getData(ITEM_COUNT - 100);
    //初始900個元素
    log.info("init size:{}", concurrentHashMap.size());

    ForkJoinPool forkJoinPool = new ForkJoinPool(THREAD_COUNT);
    //使用線程池併發處理邏輯
    forkJoinPool.execute(() -> IntStream.rangeClosed(1, 10).parallel().forEach(i -> {
        //查詢還需要補充多少個元素
        int gap = ITEM_COUNT - concurrentHashMap.size();
        log.info("gap size:{}", gap);
        //補充元素
        concurrentHashMap.putAll(getData(gap));
    }));
    //等待所有任務完成
    forkJoinPool.shutdown();
    forkJoinPool.awaitTermination(1, TimeUnit.HOURS);
    //最後元素個數會是1000嗎?
    log.info("finish size:{}", concurrentHashMap.size());
    return "OK";
}

從日誌中可以看到:

初始大小 900 符合預期,還需要填充 100 個元素。

worker1 線程查詢到當前需要填充的元素爲 36,竟然還不是 100 的倍數。

worker13 線程查詢到需要填充的元素數是負的,顯然已經過度填充了。

最後 HashMap 的總項目數是 1536,顯然不符合填充滿 1000 的預期。

針對這個場景,我們可以舉一個形象的例子。ConcurrentHashMap 就像是一個大籃子,現在這個籃子裏有 900 個桔子,我們期望把這個籃子裝滿 1000 個桔子,也就是再裝 100 個桔子。有 10 個工人來幹這件事兒,大家先後到崗後會計算還需要補多少個桔子進去,最後把桔子裝入籃子。ConcurrentHashMap 這個籃子本身,可以確保多個工人在裝東西進去時,不會相互影響干擾,但無法確保工人 A 看到還需要裝 100 個桔子但是還未裝的時候,工人 B 就看不到籃子中的桔子數量。更值得注意的是,你往這個籃子裝 100 個桔子的操作不是原子性的,在別人看來可能會有一個瞬間籃子裏有 964 個桔子,還需要補 36 個桔子。

我們需要注意 ConcurrentHashMap 對外提供的方法或能力的限制:

  • 使用了 ConcurrentHashMap,不代表對它的多個操作之間的狀態是一致的,是沒有其他線程在操作它的,如果需要確保需要手動加鎖。
  • 諸如 size、isEmpty 和 containsValue 等聚合方法,在併發情況下可能會反映 ConcurrentHashMap 的中間狀態。因此在併發情況下,這些方法的返回值只能用作參考,而不能用於流程控制。顯然,利用 size 方法計算差異值,是一個流程控制。
  • 諸如 putAll 這樣的聚合方法也不能確保原子性,在 putAll 的過程中去獲取數據可能會獲取到部分數據。
@GetMapping("right")
public String right() throws InterruptedException {
    ConcurrentHashMap<String, Long> concurrentHashMap = getData(ITEM_COUNT - 100);
    log.info("init size:{}", concurrentHashMap.size());


    ForkJoinPool forkJoinPool = new ForkJoinPool(THREAD_COUNT);
    forkJoinPool.execute(() -> IntStream.rangeClosed(1, 10).parallel().forEach(i -> {
        //下面的這段複合邏輯需要鎖一下這個ConcurrentHashMap
        synchronized (concurrentHashMap) {
            int gap = ITEM_COUNT - concurrentHashMap.size();
            log.info("gap size:{}", gap);
            concurrentHashMap.putAll(getData(gap));
        }
    }));
    forkJoinPool.shutdown();
    forkJoinPool.awaitTermination(1, TimeUnit.HOURS);


    log.info("finish size:{}", concurrentHashMap.size());
    return "OK";
}

可以看到,只有一個線程查詢到了需要補 100 個元素,其他 9 個線程查詢到不需要補元素,最後 Map 大小爲 1000。到了這裏,你可能又要問了,使用 ConcurrentHashMap 全程加鎖,還不如使用普通的 HashMap 呢。其實不完全是這樣。

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章