如何使用ThreadLocal避免執行緒安全問題?

語言: CN / TW / HK

這篇文章是關於ThreadLocal的第二篇文章。

在上一篇文章,Yasin給大家介紹了什麼是ThreadLocal,以及ThreadLocal的基本原理。

那在實際工作中,ThreadLocal一般用來做什麼呢?今天我們以一個簡單的應用場景為例,給大家介紹如何用ThreadLocal來幫助我們解決多執行緒的安全問題。

這是一個簡單的統計計數的問題。比如說我們想要統計一段時間內某個介面的呼叫量。每次訪問介面,統計量都 +1。首先我們來一個最簡單的執行緒不安全的基礎實現:

@RestController
@RequestMapping("orders")
public class OrderController {

    private Integer count = 0;

    @GetMapping("/visit")
    public Integer visit() throws InterruptedException {
        count++;
        Thread.sleep(100);
        return 0;
    }

    @GetMapping("/stat")
    public Integer stat() {
        return count;
    }
}
複製程式碼

這裡我們假設呼叫這個介面會有100毫秒的消耗(模擬同步IO操作)。稍微懂一點多執行緒知識的同學都知道,這個時候是執行緒不安全的。假如同時多個執行緒來訪問這個介面,就會造成資料不一致問題。我們試著用ab來測試一下。

# 總共呼叫10000次,100併發
$ ab -n 10000 -c 100 localhost:8080/orders/visit

$ curl localhost:8080/orders/stat
9953(base)
複製程式碼

我們預期呼叫stat應該返回10000才對,但實際返回了9953。為什麼會造成這樣的結果呢?是因為 count++ 這個操作不是執行緒安全的。這裡涉及到一個記憶體模型的知識,對於這個操作,首先我們是從記憶體裡面讀取原來的值,放在了執行緒本地記憶體裡。然後進行 +1 操作,再寫回到記憶體裡。

Java記憶體模型
Java記憶體模型

這個時候如果多個執行緒操作的話,有可能執行緒A這邊還沒來得及寫,執行緒B那邊讀取的是原來的值。這樣子的話就會造成資料不一致的問題。結果就會比預期的小。

那如何解決這個執行緒安全的問題呢?解決辦法有很多種。我們先嚐試用一個最簡單的辦法,就是加鎖。上篇文章我們聊到解決多執行緒問題有幾種思路,其中一個是排隊。使用鎖就是排隊的理念,它可以絕對的保證執行緒安全。我們先來看一下使用鎖之後的效果。

@GetMapping("/visit")
public Integer visit() throws InterruptedException {
    Thread.sleep(100);
    this.add();
    return 0;
}

private synchronized void add() {
    count++;
}
複製程式碼

同樣壓測一下。可以看到結果是正確的,符合我們期望的10000。

$ ab -n 10000 -c 100 localhost:8080/orders/visit

$ curl localhost:8080/orders/stat
10000(base)
複製程式碼

那有沒有什麼其它辦法可以做到執行緒安全呢。

前面我們說到,對於這個case來說,使用count++會造成執行緒不安全,那是因為多個執行緒都在爭用同一個資源count。我們可以使用“避免”的思想,使得一個執行緒只用自己的資源,不去用別人的資源就好啦,這樣子就不會存線上程安全問題了。

我們使用ThreadLocal,修改一下程式碼:

@RestController
@RequestMapping("orders")
public class OrderController {

    private static final ThreadLocal<Integer> TL = ThreadLocal.withInitial(() -> 0);

    @GetMapping("/visit")
    public Integer visit() throws InterruptedException {
        Thread.sleep(100);
        TL.set(TL.get() + 1);
        return 0;
    }

    @GetMapping("/stat")
    public Integer stat() {
        return TL.get();
    }
}
複製程式碼

同樣用ab測一下。

$ ab -n 10000 -c 100 localhost:8080/orders/visit

$ curl localhost:8080/orders/stat
99(base)
複製程式碼

當我們訪問統計量介面,發現只能得到當前執行緒的統計量。那我們怎麼才能得到所有執行緒加起來的統計量總和呢?

這個功能ThreadLocal並沒有實現,需要我們自己編寫程式碼輔助。其實思路很簡單,我們只需要把每個執行緒對應的value的引用,放到一個統一的容器裡面,然後我們需要用的時候從這個容器取出來遍歷一遍就好了。

首先,我們嘗試使用一個HashSet來儲存這個值。這裡需要注意的是我們在初始化這個值的時候需要加鎖。因為HashSet並不是執行緒安全的。

@RestController
@RequestMapping("orders")
public class OrderController {

    private static final Set<Integer> SET = new HashSet<>();
    private static final ThreadLocal<Integer> TL = ThreadLocal.withInitial(() -> {
        Integer value = 0;
        addSet(value);
        return value;
    });
    
    private static synchronized void addSet(Val<Integer> val) {
        SET.add(val);
    }

    @GetMapping("/visit")
    public Integer visit() throws InterruptedException {
        Thread.sleep(100);
        TL.set(TL.get() + 1);
        return 0;
    }

    @GetMapping("/stat")
    public Integer stat() {
        return SET.stream().reduce(Integer::sum).orElse(-1);
    }
}
複製程式碼

但是我們測試一下發現,好像並不生效,stat結果總是0。為什麼呢?

因為Integer有些特殊,它是一個原生型別int的封裝類,它內部有一個快取,當它的值比較小(-128~127)的時候,使用的是同一個物件。而+1操作也不會改變原來引用對應的值。所以它不能作為一個正常的引用物件來使用。

那如何解決這個問題?很簡單,我們在外面給他包一層物件就好了。

public class Val<T> {
    T v;

    public T getV() {
        return v;
    }

    public void setV(T v) {
        this.v = v;
    }
}

@RestController
@RequestMapping("orders")
public class OrderController {

    private static final Set<Val<Integer>> SET = new HashSet<>();
    private static final ThreadLocal<Val<Integer>> TL = ThreadLocal.withInitial(() -> {
        Val<Integer> val = new Val<>();
        val.setV(0);
        addSet(val);
        return val;
    });

    private static synchronized void addSet(Val<Integer> val) {
        SET.add(val);
    }

    @GetMapping("/visit")
    public Integer visit() throws InterruptedException {
        Thread.sleep(100);
        Val<Integer> val = TL.get();
        val.setV(val.getV() + 1);
        return 0;
    }

    @GetMapping("/stat")
    public Integer stat() {
        return SET.stream().map(Val::getV).reduce(Integer::sum).orElse(-1);
    }
}
複製程式碼

然後我們再測試一下,發現可以得到我們預期的結果。

$ ab -n 10000 -c 100 localhost:8080/orders/visit

$ curl localhost:8080/orders/stat
10000(base)
複製程式碼

有些同學可能會疑惑。那這個比起直接使用synchronized或者原子類,孰優孰劣呢?

其實兩者用的思想不一樣,上鎖和原子類使用的是排隊的思想,而ThreadLocal使用的是避免的思想。它通過自己的一個設計哲學避免了執行緒的爭用,所以效率也會比較高。要知道,排隊是很危險的,一旦你的臨界區比較耗時,很有可能造成大量執行緒阻塞,導致系統不可用。

臨界區:多個執行緒爭用資源的區域,同時只能有一個執行緒執行那部分程式碼。

我們這個case由於執行緒爭用的資源很簡單,臨界區就是一個Integer型別的變數,所以看不太出來使用ThreadLocal的優勢。但如果臨界區的消耗較大,ThreadLocal的優勢就體現出來了。大家可以嘗試在前面的synchronized方法中sleep 100ms試一下效果。

雖然ThreadLocal不一定能避免所有的執行緒安全問題,比如這個case,我們在初始化addSet的時候,仍然要同步上鎖。但是他可以把執行緒安全的問題縮小範圍,提升效能。

那麼你get到使用ThreadLocal的精髓了嗎?還有哪些場景可以使用ThreadLocal呢?下篇文章我們會解析主流框架的原始碼,看看大神們是如何使用ThreadLocal的。

關於作者

我是Yasin,一個有顏有料又有趣的程式設計師。

微信公眾號:編了個程

個人網站:http://yasinshaw.com

關注我的公眾號,和我一起成長~

公眾號
公眾號