1. 程式人生 > >ThreadLocal使用注意:執行緒不安全,可能會發生記憶體洩漏

ThreadLocal使用注意:執行緒不安全,可能會發生記憶體洩漏

先說可能會發生記憶體洩漏:

前言

ThreadLocal 的作用是提供執行緒內的區域性變數,這種變數線上程的生命週期內起作用,減少同一個執行緒內多個函式或者元件之間一些公共變數的傳遞的複雜度。但是如果濫用ThreadLocal,就可能會導致記憶體洩漏。下面,我們將圍繞三個方面來分析ThreadLocal 記憶體洩漏的問題

  • ThreadLocal 實現原理
  • ThreadLocal為什麼會記憶體洩漏
  • ThreadLocal 最佳實踐

ThreadLocal 實現原理

ThreadLocal

ThreadLocal的實現是這樣的:每個Thread 維護一個 ThreadLocalMap

 對映表,這個對映表的 key 是 ThreadLocal例項本身,value 是真正需要儲存的 Object

也就是說 ThreadLocal 本身並不儲存值,它只是作為一個 key 來讓執行緒從 ThreadLocalMap 獲取 value。值得注意的是圖中的虛線,表示 ThreadLocalMap 是使用 ThreadLocal 的弱引用作為 Key 的,弱引用的物件在 GC 時會被回收。

ThreadLocal為什麼會記憶體洩漏

ThreadLocalMap使用ThreadLocal的弱引用作為key,如果一個ThreadLocal沒有外部強引用來引用它,那麼系統 GC 的時候,這個ThreadLocal

勢必會被回收,這樣一來,ThreadLocalMap中就會出現keynullEntry,就沒有辦法訪問這些keynullEntryvalue,如果當前執行緒再遲遲不結束的話,這些keynullEntryvalue就會一直存在一條強引用鏈:Thread Ref -> Thread -> ThreaLocalMap -> Entry -> value永遠無法回收,造成記憶體洩漏。

其實,ThreadLocalMap的設計中已經考慮到這種情況,也加上了一些防護措施:在ThreadLocalget(),set(),remove()的時候都會清除執行緒ThreadLocalMap

裡所有keynullvalue

但是這些被動的預防措施並不能保證不會記憶體洩漏:

  • 使用staticThreadLocal,延長了ThreadLocal的生命週期,可能導致的記憶體洩漏(參考ThreadLocal 記憶體洩露的例項分析)。
  • 分配使用了ThreadLocal又不再呼叫get(),set(),remove()方法,那麼就會導致記憶體洩漏。

為什麼使用弱引用

從表面上看記憶體洩漏的根源在於使用了弱引用。網上的文章大多著重分析ThreadLocal使用了弱引用會導致記憶體洩漏,但是另一個問題也同樣值得思考:為什麼使用弱引用而不是強引用?

我們先來看看官方文件的說法:

To help deal with very large and long-lived usages, the hash table entries use WeakReferences for keys. 為了應對非常大和長時間的用途,雜湊表使用弱引用的 key。

下面我們分兩種情況討論:

  • key 使用強引用:引用的ThreadLocal的物件被回收了,但是ThreadLocalMap還持有ThreadLocal的強引用,如果沒有手動刪除,ThreadLocal不會被回收,導致Entry記憶體洩漏。
  • key 使用弱引用:引用的ThreadLocal的物件被回收了,由於ThreadLocalMap持有ThreadLocal的弱引用,即使沒有手動刪除,ThreadLocal也會被回收。value在下一次ThreadLocalMap呼叫set,getremove的時候會被清除。

比較兩種情況,我們可以發現:由於ThreadLocalMap的生命週期跟Thread一樣長,如果都沒有手動刪除對應key,都會導致記憶體洩漏,但是使用弱引用可以多一層保障:弱引用ThreadLocal不會記憶體洩漏,對應的value在下一次ThreadLocalMap呼叫set,get,remove的時候會被清除

因此,ThreadLocal記憶體洩漏的根源是:由於ThreadLocalMap的生命週期跟Thread一樣長,如果沒有手動刪除對應key就會導致記憶體洩漏,而不是因為弱引用。

ThreadLocal 最佳實踐

綜合上面的分析,我們可以理解ThreadLocal記憶體洩漏的前因後果,那麼怎麼避免記憶體洩漏呢?

  • 每次使用完ThreadLocal,都呼叫它的remove()方法,清除資料。

在使用執行緒池的情況下,沒有及時清理ThreadLocal,不僅是記憶體洩漏的問題,更嚴重的是可能導致業務邏輯出現問題。所以,使用ThreadLocal就跟加鎖完要解鎖一樣,用完就清理。

執行緒不安全:

大家通常知道,ThreadLocal類可以幫助我們實現執行緒的安全性,這個類能使執行緒中的某個值與儲存值的物件關聯起來。ThreadLocal提供了get與set等訪問介面或方法,這些方法為每個使用該變數的執行緒都存有一份獨立的副本,因此get總是返回由當前執行執行緒在呼叫set時設定的最新值。從概念上看,我們把ThreadLocal<T>理解成一個包含了Map<Thread,T>的物件,其中Map的key用來標識不同的執行緒,而Map的value存放了特定該執行緒的某個值。但是ThreadLocal的實現並非如此,我們以這樣的理解方式去使用ThreadLocal也並不能實現真正的執行緒安全。

  下面我們舉一個例子進行說明,Number是擁有一個int型成員變數的類:

複製程式碼

public class Number {
    
    private int num;

    public int getNum() {
        return num;
    }

    public void setNum(int num) {
        this.num = num;
    }

    @Override
    public String toString() {
        return "Number [num=" + num + "]";
    }
    
}

複製程式碼

  NotSafeThread是一個實現了Runable介面的類,其中我們建立了一個ThreadLocal<Number>型別的變數value,用來存放不同執行緒的num值,接著我們用執行緒池的方式啟動了5個執行緒,我們希望使用ThreadLocal類為5個不同的執行緒都存放一個Number型別的副本,根除對變數的共享,並且在呼叫ThreadLocal類的get()方法時,返回與執行緒關聯的Number物件,而這些Number物件我們希望它們都能跟蹤自己的計數值:

複製程式碼

public class NotSafeThread implements Runnable {

    public static Number number = new Number();

    public static int i = 0;

    public void run() {
        //每個執行緒計數加一
        number.setNum(i++);
     //將其儲存到ThreadLocal中
        value.set(number);
        //輸出num值
        System.out.println(value.get().getNum());
    }

    public static ThreadLocal<Number> value = new ThreadLocal<Number>() {
    };

    public static void main(String[] args) {
        ExecutorService newCachedThreadPool = Executors.newCachedThreadPool();
        for (int i = 0; i < 5; i++) {
            newCachedThreadPool.execute(new NotSafeThread());
        }
    }

}

複製程式碼

  啟動程式:輸出結果

0
1
2
3
4

  看起來一切正常,每個執行緒好像都有自己關於Number的儲存空間,但是我們簡單的在輸出前加一個延時:

複製程式碼

public class NotSafeThread implements Runnable {

    public static Number number = new Number();

    public static int i = 0;

    public void run() {
        //每個執行緒計數加一
        number.setNum(i++);
        //將其儲存到ThreadLocal中
        value.set(number);
        //延時2秒
        try {
            TimeUnit.SECONDS.sleep(2);
        } catch (InterruptedException e) {
            // TODO Auto-generated catch block
        }
        //輸出num值
        System.out.println(value.get().getNum());
    }

    public static ThreadLocal<Number> value = new ThreadLocal<Number>() {
    };

    public static void main(String[] args) {
        ExecutorService newCachedThreadPool = Executors.newCachedThreadPool();
        for (int i = 0; i < 5; i++) {
            newCachedThreadPool.execute(new NotSafeThread());
        }
    }

}

複製程式碼

  執行程式,輸出:

4
4
4
4
4

  為什麼每個執行緒都輸出4?難道他們沒有獨自儲存自己的Number副本嗎?為什麼其他執行緒還是能夠修改這個值?我們看一下ThreadLocal的原始碼:

複製程式碼

    public void set(Object obj)
    {
        Thread thread = Thread.currentThread();//獲取當前執行緒
        ThreadLocalMap threadlocalmap = getMap(thread);
        if(threadlocalmap != null)
            threadlocalmap.set(this, obj);
        else
            createMap(thread, obj);
    }

複製程式碼

  其中getMap方法:

    ThreadLocal.ThreadLocalMap getMap(Thread thread)
    {
        return thread.inheritableThreadLocals;//返回的是thread的成員變數
    }

  可以看到,這些特定於執行緒的值是儲存在當前的Thread物件中,並非儲存在ThreadLocal物件中。並且我們發現Thread物件中儲存的是Object物件的一個引用,這樣的話,當有其他執行緒對這個引用指向的物件做修改時,當前執行緒Thread物件中儲存的值也會發生變化。這也就是為什麼上面的程式為什麼會輸出一樣的結果:5個執行緒中儲存的是同一Number物件的引用,線上程睡眠2s的時候,其他執行緒將num變數進行了修改,因此它們最終輸出的結果是相同的。

  那麼,ThreadLocal的“為每個使用該變數的執行緒都存有一份獨立的副本,因此get總是返回由當前執行執行緒在呼叫set時設定的最新值。”這句話中的“獨立的副本”,也就是我們理解的“執行緒本地儲存”只能是每個執行緒所獨有的物件並且不與其他執行緒進行共享,大概是這樣的情況:

    public static ThreadLocal<Number> value = new ThreadLocal<Number>() {
        public Number initialValue(){//為每個執行緒儲存的值進行初始化操作
            return new Number();
        }
    };

  或者

    public void run() {
        value.set(new Number());
    }

  好吧...這個時候估計你會說:那這個ThreadLocal有什麼用嘛,每個執行緒都自己new一個物件使用,只有它自己使用這個物件而不進行共享,那麼程式肯定是執行緒安全的咯。這樣看起來我不使用ThreadLocal,在需要用某個物件的時候,直接new一個給本執行緒使用不就好咯。

  確實,ThreadLocal的使用不是為了能讓多個執行緒共同使用某一物件,而是我有一個執行緒A,其中我需要用到某個物件o,這個物件o在這個執行緒A之內會被多處呼叫,而我不希望將這個物件o當作引數在多個方法之間傳遞,於是,我將這個物件o放到TheadLocal中,這樣,在這個執行緒A之內的任何地方,只要執行緒A之中的方法不修改這個物件o,我都能取到同樣的這個變數o。

  再舉一個在實際中應用的例子,例如,我們有一個銀行的BankDAO類和一個個人賬戶的PeopleDAO類,現在需要個人向銀行進行轉賬,在PeopleDAO類中有一個賬戶減少的方法,BankDAO類中有一個賬戶增加的方法,那麼這兩個方法在呼叫的時候必須使用同一個Connection資料庫連線物件,如果他們使用兩個Connection物件,則會開啟兩段事務,可能出現個人賬戶減少而銀行賬戶未增加的現象。使用同一個Connection物件的話,在應用程式中可能會設定為一個全域性的資料庫連線物件,從而避免在呼叫每個方法時都傳遞一個Connection物件。問題是當我們把Connection物件設定為全域性變數時,你不能保證是否有其他執行緒會將這個Connection物件關閉,這樣就會出現執行緒安全問題。解決辦法就是在進行轉賬操作這個執行緒中,使用ThreadLocal中獲取Connection物件,這樣,在呼叫個人賬戶減少和銀行賬戶增加的執行緒中,就能從ThreadLocal中取到同一個Connection物件,並且這個Connection物件為轉賬操作這個執行緒獨有,不會被其他執行緒影響,保證了執行緒安全性。

  程式碼如下:

複製程式碼

public class ConnectionHolder {
    
    public static ThreadLocal<Connection> connectionHolder = new ThreadLocal<Connection>() {
    };
    
    public static Connection getConnection(){
        Connection connection = connectionHolder.get();
        if(null == connection){
            connection = DriverManager.getConnection(DB_URL);
            connectionHolder.set(connection);
        }
        return connection;
    }

}

複製程式碼

  在框架中,我們需要將一個事務上下文(Transaction  Context)與某個執行中的執行緒關聯起來。通過將事務上下文儲存在靜態的ThreaLocal物件中(這個上下文肯定是不與其他執行緒共享的),可以很容易地實現這個功能:當框架程式碼需要判斷當前執行的是哪一個事務時,只需從這個ThreadLocal物件中讀取事務上下文,避免了在呼叫每個方法時都需要傳遞執行上下文資訊。