ThreadLocal原理及内存泄露

前言

在介绍ThreadLocal之前,先说两个概念内存泄露与内存溢出:

         内存泄漏memory leak :是指程序在申请内存后,无法释放已申请的内存空间,一次内存泄漏似乎不会有大的影响,但内存泄漏堆积后的后果就是内存溢出。 
         内存溢出 out of memory :没内存可以分配给新的对象了。 

         强引用: 不会被回收的内存。

         软引用: 内部不足的时候回收的内存。

         弱引用: 存活到垃圾回收前的内存。

ThreadLocal提供了线程独有的局部变量,可以在整个线程存活的过程中随时取用,极大地方便了一些逻辑的实现。常见的ThreadLocal用法有:

存储单个线程上下文信息。比如存储id等;
使变量线程安全。变量既然成为了每个线程内部的局部变量,自然就不会存在并发问题了;
减少参数传递。比如做一个trace工具,能够输出工程从开始到结束的整个一次处理过程中所有的信息,从而方便debug。由于需要在工程各处随时取用,可放入ThreadLocal。 

原理

我们知道,线程Thread对象中,每个线程对象内部都有一个的ThreadLocalMap对象。所以,每当我们定义一个ThreadLocal变量,就相当于往这个Map里放了一个key,并定义一个对应的value。每当使用ThreadLocal,就相当于get(key),寻找其对应的value。

每个Thread都有一个{@link Thread#threadLocals}变量,它就是放k-v的map,类型为{@link java.lang.ThreadLocal.ThreadLocalMap}。这个map的entry是{@link java.lang.ThreadLocal.ThreadLocalMap.Entry},具体的key和value类型分别是{@link ThreadLocal}(我们定义ThreadLocal变量就是在定义这个key)和 {@link Object}(我们定义ThreadLocal变量的值就是在定义这个value)。

(注:实际上key是指向ThreadLocal类型变量的弱引用WeakReference>,但可以先简单理解为ThreadLocal。) 

当设置一个ThreadLocal变量时,这个map里就多了一对ThreadLocal -> Object的映射。如果这个对象存储了多个大对象,则可能造成内存溢出OOM发生。为了防止这种情况发生,在ThreadLocal的源码中,有对应的策略,即调用 get()、set()、remove() 方法,均会清除 ThreadLocal内部的 内存。

上面说过ThreadLocal的内部是ThreadLocalMap(ThreadLocalMap原理跟Map有些类似,底层都是通过定义一个Entry数组用于存储数据所以此处不做深入介绍了,想了解的可自行百度相关资料)。ThreadLocalMap内部是由一个Entry数组组成。Entry类的构造函数为 Entry(弱引用的ThreadLocal对象, Object value对象)。因为Entry的key是一个弱引用的ThreadLocal对象,所以在 垃圾回收 之前将会清除此Entry对象的key。那么, ThreadLocalMap 中就会出现 key 为 null 的 Entry,就没有办法访问这些 key 为 null 的 Entry 的 value。这些 value 被Entry对象引用,所以value所占内存不会被释放。若在指定的线程任务里面,调用ThreadLocal对象的get()、set()、remove()方法,可以避免出现内存泄露。

      下图虚线表示弱引用。ThreadLocal对象被GC回收了,那么key变成了null。Map又是通过key拿到的value的对象。所以,GC在回收了key所占内存后,没法访问到value的值,因为需要通过key才能访问到value对象。另外,如图所示的引用链:CurrentThread -- Map -- Entry -- value ,所以,在当前线程没有被回收的情况下,value所占内存也不会被回收。所以可能会造成了内存溢出。 

ThreadLocal原理及内存泄露_第1张图片

通过一个简单程序来说明上图:

package example.concurrency.tl;

/**
 * 
 */
public class ThreadLocalDemo {

    private static final ThreadLocal TL_INT = ThreadLocal.withInitial(() -> 6);
    private static final ThreadLocal TL_STRING = ThreadLocal.withInitial(() -> "Hello, world");

    public static void main(String... args) {
	    // 6
        System.out.println(TL_INT.get());
        TL_INT.set(TL_INT.get() + 1);
        // 7
        System.out.println(TL_INT.get());
        TL_INT.remove();
        // 会重新初始化该value,6
        System.out.println(TL_INT.get());
    }
}

分析一下其中一个ThreadLocal变量TL_INT在JVM运行时数据区的位置:

  • Stack-ThreadLocalRef:TL_INT,变量的引用,在栈上;
  • Stack-CurrentThreadRef: 当前线程的线程栈,线程私有变量的引用都在线程栈上;
  • Heap-ThreadLocal:TL_INT引用所对应的ThreadLocal实例对象;
  • Heap-Map:当前线程内部的threadLocals变量所对应的map实例;
  • Heap-Entry:上述map的entry;
  • Heap-Entry-Key:上述entry的键的弱引用;
  • Heap-Entry-Value:上述entry的值的强引用;

对于上述程序,实际上我们在当前线程的threadlocals这个map里放了如下内容: 

 

 | TL_INT    -> 6 |
 | TL_STRING -> "Hello, world"|

  

对于一个普通的map,取其中某个key对应的值分两步:

  1. 找到这个map;
  2. 在map中,给出key,得到value。

想取出我们存放在当前线程里的map里的值同样需要这两步。但是,我们不需要告诉jvm map在哪儿,因为jvm知道当前线程,也知道其局部变量map。所以最终的get操作只需要知道key就行了:int localInt = TL_INT.get();。
看起来有些奇怪,不同于常规的map的get操作的接口的样子。 
 

为什么key使用弱引用

不妨反过来想想,如果使用强引用,当ThreadLocal对象(假设为ThreadLocal@123456)的引用(即:TL_INT,是一个强引用,指向ThreadLocal@123456)被回收了,ThreadLocalMap本身依然还持有ThreadLocal@123456的强引用,如果没有手动删除这个key,则ThreadLocal@123456不会被回收,所以只要当前线程不消亡,ThreadLocalMap引用的那些对象就不会被回收,可以认为这导致Entry内存泄漏。

那使用弱引用的好处呢?

如果使用弱引用,那指向ThreadLocal@123456对象的引用就两个:TL_INT强引用,和ThreadLocalMap中Entry的弱引用。一旦TL_INT被回收,则指向ThreadLocal@123456的就只有弱引用了,在下次gc的时候,这个ThreadLocal@123456就会被回收。

那么问题来了,ThreadLocal@123456对象只是作为ThreadLocalMap的一个key而存在的,现在它被回收了,但是它对应的value并没有被回收,内存泄露依然存在!而且key被删了之后,变成了null,value更是无法被访问到了!针对这一问题,ThreadLocalMap类的设计本身已经有了这一问题的解决方案,那就是在每次get()/set()/remove()ThreadLocalMap中的值的时候,会自动清理key为null的value。如此一来,value也能被回收了。

既然对key使用弱引用,能使key自动回收,那为什么不对value使用弱引用?答案显而易见,假设往ThreadLocalMap里存了一个value,gc过后value便消失了,那就无法使用ThreadLocalMap来达到存储全线程变量的效果了。(但是再次访问该key的时候,依然能取到value,此时取得的value是该value的初始值。即在删除之后,如果再次访问,取到null,会重新调用初始化方法。) 

 

内存泄露

总结一下内存泄露(本该回收的无用对象没有得到回收)的原因:

  • 弱引用一定程度上回收了无用对象,但前提是开发者手动清理掉ThreadLocal对象的强引用(如TL_INT)。只要线程一直不死,ThreadLocalMap的key-value一直在涨。

解决方法:当某个ThreadLocal变量(比如:TL_INT)不再使用时,记得TL_INT.remove(),删除该key。

比如在spring mvc的场景下,每次使用线程处理完一个请求,就在afterCompletion里清掉线程里的ThreadLocal变量:

    /**
     * 
     */
    public class UserHolder {
        private static final ThreadLocal userThreadLocal = new ThreadLocal();

        public static void set(User user){
            userThreadLocal.set(user);
        }

        public static User get(){
            return userThreadLocal.get();
        }

        public static void remove(){
            userThreadLocal.remove();
        }
    }

    /**
     * @author caikang
     * @date 2017/04/07
     */
    public class UserInterceptor extends HandlerInterceptorAdapter {
        @Override
        public boolean preHandle(HttpServletRequest request,
            HttpServletResponse response, Object handler) throws Exception {
            UserHolder.set(new User());
            return true;
        }

        @Override
        public void afterCompletion(HttpServletRequest request,
            HttpServletResponse response, Object handler, Exception ex) throws Exception {
            UserHolder.remove();
        }
    }

ThreadLocal为什么要设计成private static 

通常,我们需要 保证作为key的TL_INT类型能够被全局访问到,同时也必须 保证其为单例,因此,在一个类中将其设为static类型便成为了惯用做法。

首先,是否使用private修饰与Threadlocal本身无关。也就是说,是否使用private修饰是一个普通现象的问题而不是与ThreadLocal有关的一个具体问题。

其次,ThreadLocal一般会采用static修饰。这样做既有好处,也有坏处。好处是它一定程度上可以避免错误,至少它可以避免重复创建TSO(Thread Specific Object,即ThreadLocal所关联的对象)所导致的内存浪费。坏处是这样做可能正好形成内存泄露所需的条件。

这个结论分析如下:

我们知道,一个ThreadLocal实例对应当前线程中的一个TSO实例。因此,如果把ThreadLocal声明为某个类的实例变量(而不是静态变量),那么每创建一个该类的实例就会导致一个新的TSO实例被创建。显然,这些被创建的TSO实例是同一个类的实例。于是,同一个线程可能会访问到同一个TSO(指类)的不同实例,这即便不会导致错误,也会导致浪费(重复创建等同的对象)!因此,一般我们将ThreadLocal使用static修饰即可。例如:

public class ServletWithThreadLocal extends HttpServlet {
	private static final long serialVersionUID = -9179908895742969397L;
	final static ThreadLocal counter 
	= new ThreadLocal() {
		@Override
		protected HashMap initialValue() {
			return new Counter(0);
		}

	};

	@Override
	protected void doPost(HttpServletRequest req, HttpServletResponse resp)
	    throws ServletException, IOException {
		
		// 省略其他代码
	}

	@Override
  public void destroy() {
  }
}

       由于ThreadLocal是某个类(例如上面的ServletWithThreadLocal)的一个静态变量。因此,只要相应的类没有被垃圾回收掉,那么这个类就会持有对相应ThreadLocal实例的引用。另外,ThreadLocal的内部实现包括一个类似HashMap的对象,这里称之为ThreadLocalMap。ThreadLocalMap的key会持有对ThreadLocal实例的弱引用(Weak Reference),value会引用TSO实例。于是,以上面的Servlet为例(假设运行环境是Tomcat),我们可以得到如下的可达(Reachable)引用关系:

  +++>表示强引用(Strong Reference),--->表示弱引用(Weak Reference)

引用路径1:服务器线程池+++>工作者线程+++>ThreadLocalMap--->ThreadLocal

引用路径2:服务器线程池+++>工作者线程+++>ThreadLocalMap+++>TSO(Counter 实例)+++>;TSO对应的类(Counter 类)+++>WebAppClassLoader+++>包含ThreadLocal静态字段的类(ServletWithThreadLocal)+++>ThreadLocal

上面的引用关系可以画个图就很明了了。

       假如TSO是我们应用定义的类(例如上面的Counter),而非JDK标准库类(例如HashMap)。

       在Tomcat中,停止一个Web应用的时候(而不是停止服务器),由于服务器的工作者线程是被多个Web应用所共享的(即一个工作者线程可能为多个Web应用处理请求),因此此时工作者线程不会被垃圾回收。因此引用路径2仍然会导致对ThreadLocal实例的可达引用,于是ThreadLocal实例此时就不会被垃圾回收。这就是说产生了内存泄漏。这里面导致内存泄漏的引用关系中最关键的引用除了工作者线程引用ThreadLocalMap之外,就是某个类对ThreadLocal通过静态字段的引用了。所以,我说使用static关键字修饰ThreadLocal也有坏的一面。假如TSO是JDK标准库类(例如HashMap),那么情形稍有不同:由于JDK标准库类是由StandardClassLoader这个类加载器加载的,因此引用路径2此时相当于是断裂的。而此时,引用路径1仍然存在。因此,此情形会导致伪内存泄漏。那么,解决这种内存泄漏的方法也就不难:在Web应用被停止的时候打破ThreadLocalMap对TSO的引用,从而打破了整条引用路径2来实现的。而这通常意味着借用Filter——在Filter处理完一个请求之后调用ThreadLocal.remove()。这种方法固然可以规避内存泄漏,但是它实际上是TSO“退化”成为“请求”特有对象。

        上述的分析同时也说明了,尽管ThreadLocalMap内部使用了弱引用来引用ThreadLocal可以在一定程度上防止内存泄漏,但是这可能还不是最关键的,它并不能完全避免内存泄漏。

 

线程池

使用了线程池,可以达到“线程复用”的效果。但是归还线程之前记得清除ThreadLocalMap,要不然再取出该线程的时候,ThreadLocal变量还会存在。这就不仅仅是内存泄露的问题了,整个业务逻辑都可能会出错。

解决方法参考:

/** 
 */
protected void afterExecute(Runnable r, Throwable t) { }

override {@link ThreadPoolExecutor#afterExecute(r, t)}方法,对ThreadLocalMap进行清理,比如:

protected void afterExecute(Runnable r, Throwable t) { 
    // you need to set this field via reflection.
    Thread.currentThread().threadLocals = null;
}

ThreadLocal与Thread架构关系

在下面的图片中,展示了Thread、ThreadLocalMap、Entry、ThreadLocal的基本架构。

ThreadLocal原理及内存泄露_第2张图片

         虚线表示是弱引用。弱引用只要继承WeakReference类即可。所以说,当ThreadLocal对象被GC回收了以后,Entry对象的key就变成null了。这个时候没法访问到 Object Value了。并且最致命的是,Entry持有Object value。所以,value的内存将不会被释放。

         因为上述的原因,在ThreadLocal这个类的get()、set()、remove()方法,均有实现回收 key 为 null 的 Entry 的 value所占的内存。所以,为了防止内存泄露(没法访问到的内存),在不会再用ThreadLocal的线程任务末尾,调用一次 上述三个方法的其中一个即可

         因此,可以理解到为什么JDK源码中要把Entry对象,用 弱引用的ThreadLocal对象,设计为key,那是因为要手动编写代码释放ThreadLocalMap中 key为null的Entry对象。

         GC什么时候回收弱引用的对象?弱引用对象是存活到下一次垃圾回收发生之前对象。

         综上:JVM就会自动回收某些对象将其置为null,从而避免OutOfMemory的错误。弱引用的对象可以被JVM设置为null。我们的代码通过判断key是否为null,从而 手动释放 内存泄露的内存。

static class Entry extends WeakReference> {
            /** The value associated with this ThreadLocal. */
            Object value;
 
            Entry(ThreadLocal k, Object v) {
                super(k);
                value = v;
            }
        }

 

 

你可能感兴趣的:(java)