同時使用執行緒本地變數以及物件快取的問題

funnyZpC發表於2024-07-20

同時使用執行緒本地變數以及物件快取的問題

如有轉載請著名出處:https://www.cnblogs.com/funnyzpc/p/18313879

前面

前些時間看別人寫的一段關於鎖的(物件快取+執行緒本地變數)的一段程式碼,這段程式碼大致描述了這麼一個功能:
外部傳入一個key,需要根據這個key去全域性變數裡面找是否存在,如有有則表示有人對這個key加鎖了,往下就不執行具體業務程式碼,同時,同時哦 還要判斷這個key是不是當前執行緒持有的,如果不是當前執行緒持有的也不能往下執行業務程式碼~
然後哦 還要在業務程式碼執行完成後釋放這個key鎖,也就是要從 ThreadLocal 裡面移除這個key。
當然需求不僅於此,就是業務的特殊性需要 ThreadLocal 同時持有多個不同的key,這就表明 ThreadLocal 的泛型肯定是個List或Set。
然後再說下程式碼,為了演示問題程式碼寫的比較簡略,以下我再一一說明可能存在的問題🎈

基本邏輯

功能大致包含兩個函式:

  • lock : 主要是查詢公共快取還有執行緒本地變數是否包含傳入的指定key,若無則嘗試寫入全域性變數及 ThreadLocal 並返回true以示獲取到鎖
  • release : 業務邏輯處理完成後呼叫此,此函式內主要是做全域性快取以及 ThreadLocal 內的key的移除並返回狀態(true/false)
  • contains : 公共方法,供以上兩個方法使用,邏輯:判斷全域性變數或 ThreadLocal 裡面有否有指定的key,此方法用 private 修飾

好了,準備看程式碼 😂

先看第一版

  • 程式碼
public class CacheObjectLock {
    // 全域性物件快取
    private static List<Object> GLOBAL_CACHE = new ArrayList<Object>(8);
    // 執行緒本地變數
    private static ThreadLocal<List<Object>> THREAD_CACHE = new ThreadLocal<List<Object>>();

    // 嘗試加鎖
    public synchronized boolean lock(Object obj){
        if(this.contains(obj)){
            return false;
        }
        List al = null;
        if((al=THREAD_CACHE.get())==null){
            al = new ArrayList(2);
            THREAD_CACHE.set(al);
        }
        al.add(obj);
        GLOBAL_CACHE.add(obj);
        return true;

    }
    // 判斷是否存在key
    public boolean contains(Object obj){
        List<Object> objs;
        return GLOBAL_CACHE.contains(obj)?true:(objs=THREAD_CACHE.get())==null?false:objs.contains(obj);
    }

    // 釋放key鎖,與上面的 lock 方法對應 
    public boolean release(Object obj){
        if( this.contains(obj) ){
            List<Object> objs = THREAD_CACHE.get();
            if(null!=objs){
                objs.remove(obj);
                GLOBAL_CACHE.remove(obj);
            }
            return true;
        }
        return false;
    }
}

  • 測試程式碼

    因為是鎖,所以必須要使用多執行緒測試,這裡我簡單使用 parallel stream +多輪迴圈去測試:

public class CacheObjectLockTest {
    private CacheObjectLock LOCK = new CacheObjectLock();

    public void test1(){
        IntStream.range(0,10000).parallel().forEach(i->{
            if(i%3==0){
                i-=2;
            }
            Boolean b = null;
            if((b=LOCK.lock(i))==false ){
                return ;
            }
            Boolean c = null;
            try {
                // do something ...
//                TimeUnit.MILLISECONDS.sleep(1);
            } catch (Exception e) {
                throw new RuntimeException(e);
            }finally {
                c = LOCK.release(i);
            }
            if(b!=c){
                System.out.println("b:"+b+" c:"+c+" => "+Thread.currentThread().getName());
            }
        });
//        LOCK.contains(9);
    }

    @Test
    public void test2(){
        for(int i=0;i<10;i++){
            this.test1();
        }
    }
}
  • 測試結果

  • 分析

    顯而易見,這是沒有對 release 加鎖導致的,其實呢,這樣說是不準確的...
    首先要明白 lock 上加的 synchronized 的同步鎖的範圍是對當前例項的,而 release 是沒有加 synchronized ,所以 release 是無視 lock 上加的 synchronized
    再仔細看看 GLOBAL_CACHE 是什麼?ArrayList ,明白了吧 ArrayList 不是執行緒安全的,因為 synchronized 的範圍只是 lock 函式這一 函式內 ,從測試程式碼可看到 LOCK.lock(i)
    開始一直到 LOCK.release(i) 這中間是沒有加同步鎖的,所以到 LOCK.lock(i) 開始一直到 LOCK.release(i) 這中間是存線上程競爭的,恰好又碰到 ArrayList 這一不安全因素自然會拋錯的!
    因為存在不安全類,所以我們有理由懷疑 THREAD_CACHE 的泛型變數也是存在多執行緒異常的,因為它這個泛型也是 ArrayList

再看第二版

好了,明白了問題之所在,自然解決辦法也十分easy:

  1. release 方法上新增 synchronized 宣告,這樣簡單粗暴
  2. 分別對 objs.remove(obj); 以及 GLOBAL_CACHE.remove(obj); 加同步鎖,這樣顆粒度更細

因為 synchronized 是寫獨佔的,所以無需在 contains 中單獨加鎖

  • 程式碼 (這裡僅有 release 變更)
    public synchronized boolean release(Object obj){
        if( this.contains(obj) ){
            List<Object> objs = THREAD_CACHE.get();
            if(null!=objs){
//                synchronized (objs){
                    objs.remove(obj);
//                }
//                synchronized (GLOBAL_CACHE){
                    GLOBAL_CACHE.remove(obj);
//                }
            }
            return true;
        }
        return false;
    }
  • 測試結果

  • 分析

    😂
    測試了多輪都是成功的,沒有任何異常,難道就一定沒有異常了???
    非也,非也~~~
    為了讓問題體現的的更清晰,先修改下測試用例並把 contains 方法置為 public,然後測試用例:

public class CacheObjectLockTest {
    private CacheObjectLock2 LOCK = new CacheObjectLock2();

    public void test1(){
        IntStream.range(0,10000).parallel().forEach(i->{
//            String it = "K"+i;
            if(i%3==0){
                i-=2;
            }
            Boolean b = null;
            if((b=LOCK.lock(i))==false ){
                return ;
            }
            Boolean c = null;
            try {
                // do something ...
//                TimeUnit.MILLISECONDS.sleep(1);
            } catch (Exception e) {
                throw new RuntimeException(e);
            }finally {
                c = LOCK.release(i);
            }
            if(b!=c){
                System.out.println("b:"+b+" c:"+c+" => "+Thread.currentThread().getName());
            }
        });
        LOCK.contains(9);
    }

    @Test
    public void test2(){
        for(int i=0;i<10;i++){
            this.test1();
        }
    }
}

在這一行打上斷點 LOCK.contains(9); 然後逐步進入到 ThreadLocalget() 方法中:

看到沒,雖然key已經被移除的,但是 ThreadLocal 裡面關聯的是 key外層的 ArrayList , 因為開發機配置都較好,一旦導致 ThreadLocal 膨脹,則 OOM 是必然的事兒!
我們知道 ThreadLocal 的基本特性,它會根據執行緒分開存放各自執行緒的所 set 進來的物件,若沒有呼叫其 remove 方法,變數會一直存在 ThreadLocal 這個 map 中,
若上述的測試程式碼放線上程池裡面被管理,執行緒池會根據負載會增減執行緒,如果每一次執行上述程式碼用的執行緒都不是固定的 ThreadLocal 必然會導致 jvm OOM 😂
這就像 java 裡面的 檔案讀寫,open 之後必須要 要有 close 操作。

最後更改

  • 程式碼
public class CacheObjectLock3 {
    private static List<Object> GLOBAL_CACHE = new ArrayList<Object>(8);
    private static ThreadLocal<List<Object>> THREAD_CACHE = new ThreadLocal<List<Object>>();
    
    public synchronized boolean lock(Object obj){
        if(this.contains(obj)){
            return false;
        }
        List al = null;
        if((al=THREAD_CACHE.get())==null){
            al = new ArrayList(2);
            THREAD_CACHE.set(al);
        }
        al.add(obj);
        GLOBAL_CACHE.add(obj);
        return true;

    }

    public boolean contains(Object obj){
        List<Object> objs;
        return GLOBAL_CACHE.contains(obj)?true:(objs=THREAD_CACHE.get())==null?false:objs.contains(obj);
    }

    public synchronized boolean release(Object obj){
        if( this.contains(obj) ){
            List<Object> objs = THREAD_CACHE.get();
            if(null!=objs){
//                synchronized (objs){
                    objs.remove(obj);
                    if(objs.isEmpty()){
                        THREAD_CACHE.remove();
                    }
//                }
//                synchronized (GLOBAL_CACHE){
                    GLOBAL_CACHE.remove(obj);
//                }
            }
            return true;
        }
        return false;
    }

}

  • 測試結果
    (截圖略)
    測試 ok 透過 ~

最後

以上程式碼未必是完美的,但至少看到了問題所在,尤其使用 ThreadLocal 的時候務必謹慎~
核心程式碼是僅是部分擷取過來的,如存在問題煩請告知於我,在此感謝了 ♥

相關文章