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

基礎篇已經講解了ThreadLocal的原理,本節著重來講解下使用ThreadLocal會導致內存泄露的原因,並講解使用ThreadLocal導致內存泄露的案例。

8.2.1 為何會出現內存泄露

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

image.png

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

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的構造函數裡面,也就是說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進行清理的,但是這些清理不是必須發生的,下面簡單說下ThreadLocalMap的remove方法的清理過程:

private void remove(ThreadLocal<?> key) {

//(1)計算當前ThreadLocal變數所在table數組位置,嘗試使用快速定位方法
Entry[] tab = table;
int len = tab.length;
int i = key.threadLocalHashCode & (len-1);
//(2)這裡使用循環是防止快速定位失效後,變數table數組
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數組裡面的其他元素是否有key為null的,有則清理。循環退出的條件是遇到table裡面有null的元素。所以這裡知道null元素後面的Entry裡面key 為null的元素不會被清理。

總結:ThreadLocalMap內部Entry中key使用的是對ThreadLocal對象的弱引用,這為避免內存泄露是一個進步,因為如果是強引用,那麼即使其他地方沒有對ThreadLocal對象的引用,ThreadLocalMap中的ThreadLocal對象還是不會被回收,而如果是弱引用則這時候ThreadLocal引用是會被回收掉的,雖然對於的value還是不能被回收,這時候ThreadLocalMap裡面就會存在key為null但是value不為null的entry項,雖然ThreadLocalMap提供了set,get,remove方法在一些時機下會對這些Entry項進行清理,但是這是不及時的,也不是每次都會執行的,所以一些情況下還是會發生內存泄露,所以在使用完畢後即使調用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)創建了一個核心線程數和最大線程數為5的線程池,這個保證了線程池裡面隨時都有5個線程在運行。
  • 代碼(2)創建了一個ThreadLocal的變數,泛型參數為LocalVariable,LocalVariable內部是一個Long數組。
  • 代碼(3)向線程池裡面放入50個任務
  • 代碼(4)設置當前線程的localVariable變數,也就是把new的LocalVariable變數放入當前線程的threadLocals變數。
  • 由於沒有調用線程池的shutdown或者shutdownNow方法所以線程池裡面的用戶線程不會退出,進而JVM進程也不會退出。

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

image.png

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

image.png

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

運行結果一的代碼,在設置線程的localVariable變數後沒有調用localVariable.remove()

方法,導致線程池裡面的5個線程的threadLocals變數裡面的new LocalVariable()實例沒有被釋放,雖然線程池裡面的任務執行完畢了,但是線程池裡面的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)創建一個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裡面的線程則負責把具體請求分發到具體的應用的servlet上進行處理。

有了上述知識,下面啟動tomcat訪問該servlet多次,會發現有可能輸出下面結果

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來執行serlvet的。

如果在訪問該servlet的同時打開了jconsole觀察堆內存會發現內存會飆升,究其原因是因為工作線程調用servlet的doGet方法時候,工作線程的threadLocals變數裡面被添加了new LocalVariable()實例,但是沒有被remove,另外多次訪問該servlet可能用的不是工作線程池裡面的同一個線程,這會導致工作線程池裡面多個線程都會存在內存泄露。

更糟糕的還在後面,上面的代碼在tomcat6.0的時代,應用reload操作後會導致載入該應用的webappClassLoader釋放不了,這是因為servlet的doGet方法裡面創建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給我們編程提供了方便,但是如果使用不當也會給我們帶來致命的災難,編碼時候要養成良好的習慣,線程中使用完ThreadLocal變數後,要記得及時remove掉。

加多:Java並發編程之美 一書已經在出售?

zhuanlan.zhihu.com圖標
推薦閱讀:

相关文章