使用ThreadLocal不當可能會導致內存泄露

8.2 使用ThreadLocal不當可能會導致內存泄露

基礎篇已經(jīng)講解了ThreadLocal的原理,本節(jié)著重來講解下使用ThreadLocal會導致內存泄露的原因,并講解使用ThreadLocal導致內存泄露的案例。

8.2.1 為何會出現(xiàn)內存泄露

基礎篇我們講到了ThreadLocal只是一個工具類,具體存放變量的是在線程的threadLocals變量里面,threadLocals是一個ThreadLocalMap類型的,


image.png

如上圖ThreadLocalMap內部是一個Entry數(shù)組,Entry繼承自WeakReference,Entry內部的value用來存放通過ThreadLocal的set方法傳遞的值,那么ThreadLocal對象本身存放到哪里了嗎?下面看看Entry的構造函數(shù):

 Entry(ThreadLocal<?> k, Object v) {
                super(k);
                value = v;
 }
 
public WeakReference(T referent) {
   super(referent);
}
    
Reference(T referent) {
   this(referent, null);
}

Reference(T referent, ReferenceQueue<? super T> queue) {
   
   this.referent = referent;
   this.queue = (queue == null) ? ReferenceQueue.NULL : queue;
}

可知k被傳遞到了WeakReference的構造函數(shù)里面,也就是說ThreadLocalMap里面的key為ThreadLocal對象的弱引用,具體是referent變量引用了ThreadLocal對象,value為具體調用ThreadLocal的set方法傳遞的值。

當一個線程調用ThreadLocal的set方法設置變量時候,當前線程的ThreadLocalMap里面就會存放一個記錄,這個記錄的key為ThreadLocal的引用,value則為設置的值。如果當前線程一直存在而沒有調用ThreadLocal的remove方法,并且這時候其它地方還是有對ThreadLocal的引用,則當前線程的ThreadLocalMap變量里面會存在ThreadLocal變量的引用和value對象的引用是不會被釋放的,這就會造成內存泄露的。但是考慮如果這個ThreadLocal變量沒有了其他強依賴,而當前線程還存在的情況下,由于線程的ThreadLocalMap里面的key是弱依賴,則當前線程的ThreadLocalMap里面的ThreadLocal變量的弱引用會被在gc的時候回收,但是對應value還是會造成內存泄露,這時候ThreadLocalMap里面就會存在key為null但是value不為null的entry項。其實在ThreadLocal的set和get和remove方法里面有一些時機是會對這些key為null的entry進行清理的,但是這些清理不是必須發(fā)生的,下面簡單說下ThreadLocalMap的remove方法的清理過程:

private void remove(ThreadLocal<?> key) {

  //(1)計算當前ThreadLocal變量所在table數(shù)組位置,嘗試使用快速定位方法
  Entry[] tab = table;
  int len = tab.length;
  int i = key.threadLocalHashCode & (len-1);
  //(2)這里使用循環(huán)是防止快速定位失效后,變量table數(shù)組
  for (Entry e = tab[i];
       e != null;
       e = tab[i = nextIndex(i, len)]) {
      //(3)找到
      if (e.get() == key) {
          //(4)找到則調用WeakReference的clear方法清除對ThreadLocal的弱引用
          e.clear();
          //(5)清理key為null的元素
          expungeStaleEntry(i);
          return;
      }
  }
}
        
 private int expungeStaleEntry(int staleSlot) {
            Entry[] tab = table;
            int len = tab.length;

            //(6)去掉去value的引用
            tab[staleSlot].value = null;
            tab[staleSlot] = null;
            size--;

            Entry e;
            int i;
            for (i = nextIndex(staleSlot, len);
                 (e = tab[i]) != null;
                 i = nextIndex(i, len)) {
                ThreadLocal<?> k = e.get();
                
                //(7)如果key為null,則去掉對value的引用。
                if (k == null) {
                    e.value = null;
                    tab[i] = null;
                    size--;
                } else {
                    int h = k.threadLocalHashCode & (len - 1);
                    if (h != i) {
                        tab[i] = null;
                        while (tab[h] != null)
                            h = nextIndex(h, len);
                        tab[h] = e;
                    }
                }
            }
            return i;
        }
  • 步驟(4)調用了Entry的clear方法,實際調用的是父類WeakReference的clear方法,作用是去掉對ThreadLocal的弱引用。
  • 步驟(6)是去掉對value的引用,到這里當前線程里面的當前ThreadLocal對象的信息被清理完畢了。
  • 代碼(7)從當前元素的下標開始看table數(shù)組里面的其他元素是否有key為null的,有則清理。循環(huán)退出的條件是遇到table里面有null的元素。所以這里知道null元素后面的Entry里面key 為null的元素不會被清理。

總結:ThreadLocalMap內部Entry中key使用的是對ThreadLocal對象的弱引用,這為避免內存泄露是一個進步,因為如果是強引用,那么即使其他地方?jīng)]有對ThreadLocal對象的引用,ThreadLocalMap中的ThreadLocal對象還是不會被回收,而如果是弱引用則這時候ThreadLocal引用是會被回收掉的,雖然對于的value還是不能被回收,這時候ThreadLocalMap里面就會存在key為null但是value不為null的entry項,雖然ThreadLocalMap提供了set,get,remove方法在一些時機下會對這些Entry項進行清理,但是這是不及時的,也不是每次都會執(zhí)行的,所以一些情況下還是會發(fā)生內存泄露,所以在使用完畢后即使調用remove方法才是解決內存泄露的王道。

8.2.2 線程池中使用ThreadLocal導致的內存泄露

下面先看線程池中使用ThreadLocal的例子:

public class ThreadPoolTest {

    static class LocalVariable {
        private Long[] a = new Long[1024*1024];
    }

    // (1)
    final static ThreadPoolExecutor poolExecutor = new ThreadPoolExecutor(5, 5, 1, TimeUnit.MINUTES,
            new LinkedBlockingQueue<>());
    // (2)
    final static ThreadLocal<LocalVariable> localVariable = new ThreadLocal<LocalVariable>();

    public static void main(String[] args) throws InterruptedException {
        // (3)
        for (int i = 0; i < 50; ++i) {
            poolExecutor.execute(new Runnable() {
                public void run() {
                    // (4)
                    localVariable.set(new LocalVariable());
                    // (5)
                    System.out.println("use local varaible");
                    //localVariable.remove();

                }
            });

            Thread.sleep(1000);
        }
        // (6)
        System.out.println("pool execute over");
    }
  • 代碼(1)創(chuàng)建了一個核心線程數(shù)和最大線程數(shù)為5的線程池,這個保證了線程池里面隨時都有5個線程在運行。
  • 代碼(2)創(chuàng)建了一個ThreadLocal的變量,泛型參數(shù)為LocalVariable,LocalVariable內部是一個Long數(shù)組。
  • 代碼(3)向線程池里面放入50個任務
  • 代碼(4)設置當前線程的localVariable變量,也就是把new的LocalVariable變量放入當前線程的threadLocals變量。
  • 由于沒有調用線程池的shutdown或者shutdownNow方法所以線程池里面的用戶線程不會退出,進而JVM進程也不會退出。

運行當前代碼,使用jconsole監(jiān)控堆內存變化如下圖:


image.png

然后解開localVariable.remove()注釋,然后在運行,觀察堆內存變化如下:


image.png

從運行結果一可知,當主線程處于休眠時候進程占用了大概77M內存,運行結果二則占用了大概25M內存,可知運行代碼一時候內存發(fā)生了泄露,下面分析下泄露的原因。

運行結果一的代碼,在設置線程的localVariable變量后沒有調用localVariable.remove()
方法,導致線程池里面的5個線程的threadLocals變量里面的new LocalVariable()實例沒有被釋放,雖然線程池里面的任務執(zhí)行完畢了,但是線程池里面的5個線程會一直存在直到JVM退出。這里需要注意的是由于localVariable被聲明了static,雖然線程的ThreadLocalMap里面是對localVariable的弱引用,localVariable也不會被回收。運行結果二的代碼由于線程在設置localVariable變量后即使調用了localVariable.remove()方法進行了清理,所以不會存在內存泄露。

總結:線程池里面設置了ThreadLocal變量一定要記得及時清理,因為線程池里面的核心線程是一直存在的,如果不清理,那么線程池的核心線程的threadLocals變量一直會持有ThreadLocal變量。

8.2.3 Tomcat的Servlet中使用ThreadLocal導致內存泄露

首先看一個Servlet的代碼如下:


public class HelloWorldExample extends HttpServlet {

    private static final long serialVersionUID = 1L;

    static class LocalVariable {
        private Long[] a = new Long[1024 * 1024 * 100];
    }

    //(1)
    final static ThreadLocal<LocalVariable> localVariable = new ThreadLocal<LocalVariable>();

    @Override
    public void doGet(HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException {
        //(2)
        localVariable.set(new LocalVariable());

        response.setContentType("text/html");
        PrintWriter out = response.getWriter();

        out.println("<html>");
        out.println("<head>");

        out.println("<title>" + "title" + "</title>");
        out.println("</head>");
        out.println("<body bgcolor=\"white\">");
        //(3)
        out.println(this.toString());
        //(4)
        out.println(Thread.currentThread().toString());

        out.println("</body>");
        out.println("</html>");
    }
}
  • 代碼(1)創(chuàng)建一個localVariable對象,
  • 代碼(2)在servlet的doGet方法內設置localVariable值
  • 代碼(3)打印當前servlet的實例
  • 代碼(4)打印當前線程

修改tomcat的conf下sever.xml配置如下:


    <Executor name="tomcatThreadPool" namePrefix="catalina-exec-" 
        maxThreads="10" minSpareThreads="5"/>

    <Connector executor="tomcatThreadPool" port="8080" protocol="HTTP/1.1" 
               connectionTimeout="20000" 
               redirectPort="8443" />

這里設置了tomcat的處理線程池最大線程為10個,最小線程為5個,那么這個線程池是干什么用的那?這里回顧下Tomcat的容器結構,如下圖:

image.png

Tomcat中Connector組件負責接受并處理請求,其中Socket acceptor thread 負責接受用戶的訪問請求,然后把接受到的請求交給Worker threads pool線程池進行具體處理,后者就是我們在server.xml里面配置的線程池。Worker threads pool里面的線程則負責把具體請求分發(fā)到具體的應用的servlet上進行處理。

有了上述知識,下面啟動tomcat訪問該servlet多次,會發(fā)現(xiàn)有可能輸出下面結果

HelloWorldExample@2a10b2d2 Thread[catalina-exec-5,5,main]
HelloWorldExample@2a10b2d2 Thread[catalina-exec-1,5,main]
HelloWorldExample@2a10b2d2 Thread[catalina-exec-4,5,main]

其中前半部分是打印的servlet實例,這里都一樣說明多次訪問的都是一個servlet實例,后半部分中catalina-exec-5,catalina-exec-1,catalina-exec-4,說明使用了connector中線程池里面的線程5,線程1,線程4來執(zhí)行serlvet的。
如果在訪問該servlet的同時打開了jconsole觀察堆內存會發(fā)現(xiàn)內存會飆升,究其原因是因為工作線程調用servlet的doGet方法時候,工作線程的threadLocals變量里面被添加了new LocalVariable()實例,但是沒有被remove,另外多次訪問該servlet可能用的不是工作線程池里面的同一個線程,這會導致工作線程池里面多個線程都會存在內存泄露。

更糟糕的還在后面,上面的代碼在tomcat6.0的時代,應用reload操作后會導致加載該應用的webappClassLoader釋放不了,這是因為servlet的doGet方法里面創(chuàng)建new LocalVariable()的時候使用的是webappclassloader,所以LocalVariable.class里面持有webappclassloader的引用,由于LocalVariable的實例沒有被釋放,所以LocalVariable.class對象也沒有沒釋放,所以
webappclassloader也沒有被釋放,那么webappclassloader加載的所有類也沒有被釋放。這是因為應用reload的時候connector組件里面的工作線程池里面的線程還是一直存在的,并且線程里面的threadLocals變量并沒有被清理。而在tomcat7.0里面這個問題被修復了,應用在reload時候會清理工作線程池中線程的threadLocals變量,tomcat7.0里面reload后會有如下提示:

十二月 31, 2017 5:44:24 下午 org.apache.catalina.loader.WebappClassLoader checkThreadLocalMapForLeaks
嚴重: The web application [/examples] created a ThreadLocal with key of type [java.lang.ThreadLocal] (value [java.lang.ThreadLocal@63a3e00b]) and a value of type [HelloWorldExample.LocalVariable] (value [HelloWorldExample$LocalVariable@4fd7564b]) but failed to remove it when the web application was stopped. Threads are going to be renewed over time to try and avoid a probable memory leak.

8.2.4 總結

Java提供的ThreadLocal給我們編程提供了方便,但是如果使用不當也會給我們帶來致命的災難,編碼時候要養(yǎng)成良好的習慣,線程中使用完ThreadLocal變量后,要記得及時remove掉。

歡迎關注微信公眾號 '技術原始積累'

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
【社區(qū)內容提示】社區(qū)部分內容疑似由AI輔助生成,瀏覽時請結合常識與多方信息審慎甄別。
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發(fā)布,文章內容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

友情鏈接更多精彩內容