在基础篇已经讲解了 ThreadLocal 的原理,本节着重介绍使用 ThreadLocal 会导致内存泄漏的原因,并给出使用 ThreadLocal 导致内存泄漏的案例。

为何会出现内存泄漏

在基础篇我们讲了,ThreadLocal 只是一个工具类,具体存放变量的是线程的 threadLocals 变量。threadLocals 是一个 ThreadLocalMap 类型的变量,该类型如图 11-10 所示。

使用 ThreadLocal 不当可能会导致内存泄漏 - 图1

图 11-10

由图 11-10 可知,ThreadLocalMap 内部是一个 Entry 数组,Entry 继承自 WeakReference,Entry 内部的 value 用来存放通过 ThreadLocal 的 set 方法传递的值,那么 ThreadLocal 对象本身存放到哪里了呢?下面看看 Entry 的构造函数。

  1. Entry(ThreadLocal<? > k, Object v) {
  2. super(k);
  3. value = v;
  4. }
  5. public WeakReference(T referent) {
  6. super(referent);
  7. }
  8. Reference(T referent) {
  9. this(referent, null);
  10. }
  11. Reference(T referent, ReferenceQueue<? super T> queue) {
  12. this.referent = referent;
  13. this.queue = (queue == null) ? ReferenceQueue.NULL : queue;
  14. }

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 方法中的清理过程。

  1. private void removeThreadLocal<? > key {
  2. //(1)计算当前 ThreadLocal 变量所在的 table 数组位置,尝试使用快速定位方法
  3. Entry[] tab = table
  4. int len = tab.length
  5. int i = key.threadLocalHashCode & len-1);
  6. //(2)这里使用循环是防止快速定位失效后,遍历 table 数组
  7. for Entry e = tab[i];
  8. e ! = null;
  9. e = tab[i = nextIndex(i, len)]) {
  10. //(3)找到
  11. if e.get() == key {
  12. //(4)找到则调用 WeakReference 的 clear 方法清除对 ThreadLocal 的弱引用
  13. e.clear();
  14. //(5)清理 key 为 null 的元素
  15. expungeStaleEntryi);
  16. return;
  17. }
  18. }
  19. }

代码(4)调用了 Entry 的 clear 方法,实际调用的是父类 WeakReference 的 clear 方法,作用是去掉对 ThreadLocal 的弱引用。

如下代码(6)去掉对 value 的引用,到这里当前线程里面的当前 ThreadLocal 对象的信息被清理完毕了。

  1. private int expungeStaleEntryint staleSlot {
  2. Entry[] tab = table
  3. int len = tab.length
  4. //(6)去掉对 value 的引用
  5. tab[staleSlot].value = null
  6. tab[staleSlot] = null
  7. size--;
  8. Entry e
  9. int i
  10. for i = nextIndex(staleSlot, len);
  11. (e = tab[i]) ! = null;
  12. i = nextIndex(i, len)) {
  13. ThreadLocal<? > k = e.get();
  14. //(7)如果 key 为 null,则去掉对 value 的引用
  15. if k == null {
  16. e.value = null
  17. tab[i] = null
  18. size--;
  19. } else {
  20. int h = k.threadLocalHashCode & len -1);
  21. if h ! = i {
  22. tab[i] = null
  23. while tab[h] ! = null
  24. h = nextIndexh, len);
  25. tab[h] = e
  26. }
  27. }
  28. }
  29. return i;
  30. }

代码(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 方法才是解决内存泄漏问题的王道。

在线程池中使用 ThreadLocal 导致的内存泄漏

下面先看一个在线程池中使用 ThreadLocal 的例子。

  1. public class ThreadPoolTest {
  2. static class LocalVariable {
  3. private Long[] a = new Long[10241024];
  4. }
  5. // (1)
  6. final static ThreadPoolExecutor poolExecutor = new ThreadPoolExecutor(5, 5, 1,
  7. TimeUnit.MINUTES,
  8. new LinkedBlockingQueue<>());
  9. // (2)
  10. final static ThreadLocal<LocalVariable> localVariable = new
  11. ThreadLocal<LocalVariable>();
  12. public static void main(String[] args) throws InterruptedException {
  13. // (3)
  14. for (int i = 0; i < 50; ++i) {
  15. poolExecutor.execute(new Runnable() {
  16. public void run() {
  17. // (4)
  18. localVariable.set(new LocalVariable());
  19. // (5)
  20. System.out.println("use local varaible");
  21. //localVariable.remove();
  22. }
  23. });
  24. Thread.sleep(1000);
  25. }
  26. // (6)
  27. System.out.println("pool execute over");
  28. }

代码(1)创建了一个核心线程数和最大线程数都为 5 的线程池。

代码(2)创建了一个 ThreadLocal 的变量,泛型参数为 LocalVariable,LocalVariable 内部是一个 Long 数组。

代码(3)向线程池里面放入 50 个任务。

代码(4)设置当前线程的 localVariable 变量,也就是把 new 的 LocalVariable 变量放入当前线程的 threadLocals 变量中。

由于没有调用线程池的 shutdown 或者 shutdownNow 方法,所以线程池里面的用户线程不会退出,进而 JVM 进程也不会退出。

运行代码,使用 jconsole 监控堆内存变化,如图 11-11 所示。

使用 ThreadLocal 不当可能会导致内存泄漏 - 图2

图 11-11

然后去掉 localVariable.remove()注释,再运行,观察堆内存变化,如图 11-12 所示。

使用 ThreadLocal 不当可能会导致内存泄漏 - 图3

图 11-12

从运行结果一(图 11-11)可知,当主线程处于休眠时,进程占用了大概 77MB 内存,运行结果二(图 11-12)显示占用了大概 25MB 内存,由此可知运行代码一时发生了内存泄漏,下面分析泄露的原因。

第一次运行代码时,在设置线程的 localVariable 变量后没有调用 localVariable.remove()方法,这导致线程池里面 5 个核心线程的 threadLocals 变量里面的 new LocalVariable()实例没有被释放。虽然线程池里面的任务执行完了,但是线程池里面的 5 个线程会一直存在直到 JVM 进程被杀死。这里需要注意的是,由于 localVariable 被声明为了 static 变量,虽然在线程的 ThreadLocalMap 里面对 localVariable 进行了弱引用,但是 localVariable 不会被回收。第二次运行代码时,由于线程在设置 localVariable 变量后及时调用了 localVariable. remove()方法进行了清理,所以不会存在内存泄漏问题。

总结:如果在线程池里面设置了 ThreadLocal 变量,则一定要记得及时清理,因为线程池里面的核心线程是一直存在的,如果不清理,线程池的核心线程的 threadLocals 变量会一直持有 ThreadLocal 变量。

在 Tomcat 的 Servlet 中使用 ThreadLocal 导致内存泄漏

首先看一个 Servlet 的代码。

  1. public class HelloWorldExample extends HttpServlet {
  2. private static final long serialVersionUID = 1L;
  3. static class LocalVariable {
  4. private Long[] a = new Long[1024 1024 100];
  5. }
  6. //(1)
  7. final static ThreadLocal<LocalVariable> localVariable = new
  8. ThreadLocal<LocalVariable>();
  9. @Override
  10. public void doGet(HttpServletRequest request, HttpServletResponse response)
  11. throws IOException, ServletException {
  12. //(2)
  13. localVariable.set(new LocalVariable());
  14. response.setContentType("text/html");
  15. PrintWriter out = response.getWriter();
  16. out.println("<html>");
  17. out.println("<head>");
  18. out.println("<title>" + "title" + "</title>");
  19. out.println("</head>");
  20. out.println("<body bgcolor=\"white\">");
  21. //(3)
  22. out.println(this.toString());
  23. //(4)
  24. out.println(Thread.currentThread().toString());
  25. out.println("</body>");
  26. out.println("</html>");
  27. }
  28. }

代码(1)创建一个 localVariable 对象。

代码(2)在 Servlet 的 doGet 方法内设置 localVariable 值。

代码(3)打印当前 Servlet 的实例。

代码(4)打印当前线程。

修改 Tomcat 的 conf 下 sever.xml 配置如下。

  1. <Executor name="tomcatThreadPool" namePrefix="catalina-exec-"
  2. maxThreads="10" minSpareThreads="5"/>
  3. <Connector executor="tomcatThreadPool" port="8080" protocol="HTTP/1.1"
  4. connectionTimeout="20000"
  5. redirectPort="8443" />

这里设置了 Tomcat 的处理线程池的最大线程数为 10,最小线程数为 5。那么这个线程池是干什么用的呢?我们回顾下 Tomcat 的容器结构,如图 11-13 所示。

使用 ThreadLocal 不当可能会导致内存泄漏 - 图4

图 11-13

Tomcat 中的 Connector 组件负责接收并处理请求,其中 Socket acceptor thread 负责接收用户的访问请求,然后把接收到的请求交给 Worker threads pool 线程池进行具体处理,后者就是我们在 server.xml 里面配置的线程池。Worker threads pool 里面的线程则负责把具体请求分发到具体的应用的 Servlet 上进行处理。

那么,下面启动 Tomcat 访问该 Servlet 多次,你会发现可能输出下面的结果:

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

输出的前半部分是 Servlet 实例,可以看出都一样,这说明多次访问的是同一个 Servlet 实例,后半部分中的 catalina-exec-5、catalina-exec-1、catalina-exec-4,则说明使用了 Connector 中的线程池里面的线程 5、线程 1,线程 4 来执行 Servlet。

如果在访问该 Servlet 的同时打开 jconsole 观察堆内存,会发现内存飙升,究其原因是因为工作线程在调用 Servlet 的 doGet 方法时,工作线程的 threadLocals 变量里面被添加了 LocalVariable 实例,但是后来没有清除。另外多次访问该 Servlet 可能使用的不是工作线程池里面的同一个线程,这会导致工作线程池里面多个线程都会存在内存泄漏问题。

更糟糕的还在后面,上面的代码在 Tomcat 6.0 时代,应用 reload 操作后会导致加载该应用的 webappClassLoader 释放不了,这是因为在 Servlet 的 doGet 方法里面创建 LocalVariable 时使用的是 webappClassLoader,所以 LocalVariable.class 里面持有对 webappclassloader 的引用。由于 LocalVariable 实例没有被释放,所以 LocalVariable.class 对象也没有被释放,因而 webappClassLoader 也没有被释放,那么 webappClassLoader 加载的所有类也没有被释放。这是因为当应用 reload 时,Connector 组件里面的工作线程池里面的线程还是一直存在的,并且线程里面的 threadLocals 变量并没有被清理。而在 Tomcat 7.0 中这个问题被修复了,应用在加载时会清理工作线程池中线程的 threadLocals 变量。在 Tomcat 7.0 中,加载后会有如下提示。

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

小结

Java 提供的 ThreadLocal 给我们编程提供了方便,但是如果使用不当也会给我们带来麻烦,所以要养成良好的编码习惯,在线程中使用完 ThreadLocal 变量后,要记得及时清除掉。