Java高併發編程詳解系列-線程上下文設計模式及ThreadLocal詳解

導語
  在之前的分享中提到過一個概念就是線程之間的通信,都知道在線程之間的通信是一件很消耗資源的事情。但是又不得不去做的一件事情。爲了保證多線程線程安全就必須進行線程之間的通信,保證每個線程獲取到的數據都是一樣的。那麼就需要知道線程上下文,對於線程上下文來講就是線程的依託。

什麼是上下文

   關於上下文(context),在開發中經常會遇到,例如Spring中的上下文ApplicationContext,Struts2的ActionContext,對於上下文來說就是系統整個生命週期的依託,提供了一些全局信息,例如Spring 容器信息、請求的Request信息、以及在運行的某個階段需要的運行時數據等。它貫穿了整個的程序運行的生命週期。

public class ApplicationContext {
    
    private ApplicationConfiguration configuration;
    
    private RuntimeInfo runtimeInfo;
    
    private static class Holder{
        private static ApplicationContext instance = new ApplicationContext();
    }
    
    public static  ApplicationContext getContext(){
        return Holder.instance;
    }


    public ApplicationConfiguration getConfiguration() {
        return configuration;
    }

    public void setConfiguration(ApplicationConfiguration configuration) {
        this.configuration = configuration;
    }

    public RuntimeInfo getRuntimeInfo() {
        return runtimeInfo;
    }

    public void setRuntimeInfo(RuntimeInfo runtimeInfo) {
        this.runtimeInfo = runtimeInfo;
    }
}

  在上面代碼中會看到configuration和runtimeinfo的整個生命週期會隨着被創建一直到系統運行結束,這裏就可以將ApplicationContext稱爲是系統上下文,例如configuration和runtimeinfo的就被稱爲是上下文中的成員。
  在設計系統上下問的時候,除了要考慮全局唯一性(單例設計模式可以保證)之外,還要考慮的就是其成員也是隻能被初始化一次的,例如有些情況下整個系統的配置信息是不能被改變的,在多線程場景下,上下文成員線程之間的安全性一定要得到保證。

如何設計線程上下文

  有些情況下,在單個線程執行的任務後續的步驟有很多,而且後一個步驟的輸入有可能是前一個步驟的輸出,比例在單個線程執行多個步驟的時候爲了使得這個線程功能單一會採用一種設計模式叫做責任鏈設計模式,

next
next
next
步驟1
步驟2
步驟3
步驟4

  雖然在有些時候後一個步驟未必會有需要前一個步驟的輸出結果,但是都需要將context上下文從頭到尾的進行傳遞,假如方法需要傳入的參數較少的情況下這種參數傳遞是是可以做到的,但是如果在有些參數較多的場景下,如果進行參數傳遞這種操作的話就會出現代碼冗餘度過高的問題。這個時候就可以用到線程上下文這種方式了。如下

public class Context {
    private ConcurrentHashMap<Thread,ApplicationContext> contexts = new ConcurrentHashMap<>();
    
    public ApplicationContext getContext(){
        ApplicationContext context = contexts.get(Thread.currentThread());
        if (context==null){
            context = new ApplicationContext();
            contexts.put(Thread.currentThread(),context);
        }
        return context;
    }
}


  不同的線程訪問到getContext()方法的時候,每個線程都會獲取到不一樣的Context,原因是採用了Thread.currentThread作爲contexts的key值,這樣可以保證線程上下文之間是獨立的,同時也不需要考慮線程上下文之間的安全性,所以線程上下文其實被稱爲是線程級別的單例。從上面代碼中也可以感覺到。它採用了單例模式來進行實現。

注意
  通過這種方式定義線程上下問題很有可能會導致內存泄露,contexts是一個Map的數據結構,用當前線程作爲key,當線程生命週期結束後,contexts中的Thread實例不會被釋放,與之對應的Value也就不會被釋放,時間太長的話就會導致內存泄露(Memory Leak),當然在另一個方面可以使用soft reference 或者是 weak reference 等引用類型,JVM會嘗試主動進行回收。對於引用類型可以參考博主的關於JVM相關的文章來進行學習。

ThreadLocal 詳解

  在面試的過程中經常會被問到多線程相關的知識,這個時候就不得不提到一個ThreadLocal的東西。自從JDK1.2開始Java就提供了一個java.lang.ThreadLocal的類,ThreadLocal爲每個使用該變量的線程都提供了一個獨立的副本,可以做到線程之間的數據隔離,每個線程都可以訪問各個線程內部的副本變量。這種操作類似於操作線程私有的內存一樣。

ThreadLocal 的使用場景以及注意事項

  ThreadLocal在Java開發中非常常見,一般在以下的情況下會使用到

  • 在進行對象跨層傳遞的時候,可以考慮使用ThreadLocal,避免方法多次傳遞,打破層次之間的約束
  • 線程間數據的隔離,例如在上面提到的線程上下文
  • 進行事務操作,用戶存儲線程事務信息

  ThreadLoca 並不是解決多線程下共享資源的技術,一般情況下,每個線程的ThreadLocal存儲的都是一個全新的對象(通過New關鍵字創建的),如果多線程的ThreadLocal存儲了一個對象的引用,那麼它還是會面臨資源競爭的問題,還是會導致數據不一致等併發問題。

ThreadLocal 的方法詳解以及源碼分析

  在分析源碼之前首先來看一個小例子。

public class ThreadLocalExample {
    public static void main(String[] args) {
        ThreadLocal<Integer> threadLocal = new ThreadLocal<>();
        IntStream.range(0,10).forEach(i->new Thread(()->{
            try{
                threadLocal.set(i);
                System.out.println(currentThread()+"set i "+threadLocal.get());
                TimeUnit.SECONDS.sleep(1);
                System.out.println(currentThread()+"get i "+threadLocal.get());
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }).start()
        );
    }
}

  從上面的代碼定義中可以看到只有一個全局唯一的ThreadLocal 的變量,然後後續啓動了10個線程通過set和get方法進行了操作,通過運行程序得到下面這個結果,會發現這些結果之間並不會相互之間有什麼影響。每個線程輸入的值之間完全不同並且彼此獨立。
在這裏插入圖片描述

  通過上面的例子對於ThreadLocal有了一些瞭解,在使用ThreadLocal的時候最常用的就是以下的一些方法

  • 1、initialVaue()方法
protected T initialValue() {
   return null;
}

  方法源碼很簡單,initialValue()方法爲ThreadLocal要保存的數據類型指定了一個初始化的值,在ThreadLocal中默認返回值是null。
  但是我們可以通過重寫initialValue()方法進行數據的初始化操作,例如下面代碼所示,線程並沒有對threadlocal進行set操作,但是還是可以通過get方法獲取到對應的值。通過輸出信息不難發現。

public class ThreadLocalTest {
    public static void main(String[] args) {
        ThreadLocal<Object> threadLocal = new ThreadLocal<Object>(){
            @Override
            protected Object initialValue() {
                return new Object();
            }
        };

        new Thread(()-> System.out.println(threadLocal.get())).start();
        System.out.println(threadLocal.get());
    }
}

  • set()方法

  在之前的代碼中也看到了關於set()方法的使用。set()方法主要是爲了ThreadLocal制定將要被存儲的數據,如果重寫的initialValue()方法在不調用set()方法的時候回使用initialValue的值。上面已經看到,源碼如下

public void set(T value) {
   Thread t = Thread.currentThread();
   ThreadLocalMap map = getMap(t);
   if (map != null)
      map.set(this, value);
   else
      createMap(t, value);
}


void createMap(Thread t, T firstValue) {
   t.threadLocals = new ThreadLocalMap(this, firstValue);
}

ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
   table = new Entry[INITIAL_CAPACITY];
   int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
   table[i] = new Entry(firstKey, firstValue);
   size = 1;
   setThreshold(INITIAL_CAPACITY);
   
}

在這裏插入圖片描述
  根據上述代碼可以看到,它是做了如下的一些操作

  • 1、獲取當前線程的Thread.currentThread()

  • 2、根據當前線程獲取與之相關的ThreadLocalMap數據結構

  • 3、如果map爲null則進入第四步,否則就進入第五步

  • 4、當map爲空的時候,創建一個ThreadLoaclMap ,用當前的ThreadLocal實例作爲key,將所要存放的數據作爲Value,對應到ThreadLocalMap中創建一個Entry。

  • 5、在map的set方法中遍歷整個map的Entry,如果發現ThreadLocal相同則使用新數據替換,set過程結束

  • 6、在遍歷map的entry過程中,發現Entry的key爲null,則直接將其推出,並使用新數據佔用被推出的數據的位置,整個過程主要是爲了防止內存泄露在上面提到過

  • 7、創建新的entry,使用ThreadLocal作爲key,將要存放的數據作爲value。

  • 8、最後根據ThreadLocalMap當前元素大小和閾值做比較再次進行key爲null的清理工作。

  • get()方法

  get方法用於返回當前線程在ThreadLocal中的數據備份,當前線程的數據都被存放到了一個ThreadLocalMap的數據結構中,後面會介紹這個數據結構,get源碼如下
在這裏插入圖片描述
在這裏插入圖片描述
  通過上面的代碼來簡單的分析一下數據拷貝的過程

  • 1、首先獲取當前線程Thread.currentThread()方法;
  • 2、根據Thread獲取ThreadLocalMap,其中ThreadLocalMap與Thread是關聯的,而且上面以及提到了我們存儲的數據其實是在ThreadLocalMap的Entry中。
  • 3、如果map已經被創建過了,則當前的ThreadLocal作爲key獲取Value
  • 4、如果Entry不爲null,則直接返回value,否則進入第五步
  • 5、如果第二步獲取不到對應的ThreadLocalMap,則執行setInitialValue()方法
  • 6、在setInitialValue() 方法中首先通過initialValue()方法來獲取初始值
  • 7、根據當前線程Thread獲取對應的ThreadLocalMap
  • 8、如果ThreadLocalMap不爲null。則爲map指定的initialValue()獲取的初始值,實際上在mapset方法中new了一個Entry對象。
  • 9、如果ThreadLocalMap爲null也就是首次使用,則需要先進行創建,並且設置對應的屬性,其實這裏使用了一個懶加載的機制。
  • 10、返回initialValue()方法的結果,當然這個結果在沒有被重寫的情況下爲null。

ThreadLocaMap
  無論是上面哪一種操作,都離不開一個對象ThreadLocalMap和其中的Entry。ThreadLocalMap是一個完全類似於HashMap的數據結構,僅僅用於存放線程存放在ThreadLocal中的數據備份,ThreadLocalMap的所有方法都是私有的。

  在ThreadLocalMap中用於實際存儲數據的Entry,它其實是一個WeakReference類型的子類型,之所以被設置爲WeakReference類型是爲了在JVM垃圾回收發生時,可以自動防止垃圾回收內存溢出情況,關於WeakReference的相關內容可以參考博主的有關JVM系列的分享。通過Entry源碼分析不難發現其實在Entry中是有ThreadLocal以及所需要的備份數據
在這裏插入圖片描述

關於ThreadLocal內存泄露問題分析

  前面一直提到過,在使用線程上下文這種方式的時候會有內存泄露的問題。其實ThreadLocal也是類似的,都使用了當前線程作爲一個KV。但是在上面描述有關內存泄露問題的時候,例如某個線程的結束了生命週期但是實際上它的上下文信息還是存在於KV中,隨着運行時間的增加,在上下文KV中會保留很多的上下文信息。

  這個問題從分析源碼可以看到在ThreadLocal中也是存在的所以在存儲的時候使用了WeakReference 由於WeakReference的特性在任何的GC操作中都會導致其回收,這個可以參考博主JVM相關的文章,這裏有個問題WeakReference的特性在任何的GC操作中都會導致回收,回收之後執行get()操作又會怎麼樣呢?這個其實不用擔心請看如下代碼。通過對下面幾段代碼的分析,其實會發現ThreadLocalMap在一定程度上是保證不會發生內存泄露的
在這裏插入圖片描述

在這裏插入圖片描述

在這裏插入圖片描述
下面就來看看如下的一段測試

public class ThreadLocalOOM {
    public static void main(String[] args) throws InterruptedException {
        ThreadLocal<byte[]> threadLocal  = new ThreadLocal<>();
        TimeUnit.SECONDS.sleep(30);
        threadLocal.set(new byte[1024*1024*100]);
        threadLocal.set(new byte[1024*1024*100]);
        threadLocal.set(new byte[1024*1024*100]);
        threadLocal = null;
        currentThread().join();
    }
}

  首先代碼定義了ThreadLocal<byte[]>的數據分別設置了100MB的數據,會發現如下的效果
在這裏插入圖片描述
  當Thread和ThreadLocal對象發生綁定關係之後,對象引用鏈如下圖所示
在這裏插入圖片描述
  但是在代碼中強制的將引用顯示的設置爲null;就會出現如下的效果。
在這裏插入圖片描述
  當ThreadLocal被顯式的指定爲空,執行GC操作,此時的對內存中的ThreadLocal被回收,同時在ThreadLocalMap中的Entry爲null,但是Value不會被釋放,除非當前線程結束生命週期再回被垃圾回收器回收。

  內存泄露和內存溢出是兩個不同的概念,內存泄露只是內存溢出的一個原因,但是兩者並不是等價的,內存泄露更多的是因爲程序中不再持有對於某個對象的引用。因爲沒有引用所以就導致該對象無法被垃圾收集器所回收,也就是說是因爲Root引用鏈路是可達的,就如同上圖中一樣,ThreadLocal到Entry.value 的引用鏈路一樣。

總結

  在上面的介紹中詳細介紹了線程上下文ThreadLocal。很多的開源的框架中都可以看到ThreadLocal的影子,這裏可能唯一欠缺的一段代碼就是使用ThreadLocal來保存線程上下文的代碼,其實不難發現如果將上面關於實現上下文操作的private ConcurrentHashMap<Thread,ApplicationContext> contexts = new ConcurrentHashMap<>();代碼換成ThreadLocal的話就會是個很好的實現。

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