并发编程:ThreadLocal内存泄露问题

发布于:2025-08-29 ⋅ 阅读:(19) ⋅ 点赞:(0)

ThreadLocal的作用是提供线程内的局部变量,这种变量在线程的生命周期内起作用,它的出现是为了解决对象不能被多线程共享访问的问题。由上篇文章中我们了解到通过ThreadLocal.set()方法将对象实例保存在每个线程自己所拥有的ThreadLocalMap 中,这样每个线程使用自己的对象实例,彼此不会影响达到隔离的作用,从而就解决了对象在被共享访问带来线程安全问题。

如果将同步机制和ThreadLocal做一个横向比较的话,同步机制则是通过控制线程访问共享对象的顺序,而ThreadLocal则是为每一个线程分配一个该对象。打个比方说,现在有100个同学需要填写一张表格但是只有一支笔,同步就相当于A使用完这支笔后给B,B使用后给C用…老师就控制着这支笔的使用顺序,使得同学之间不会产生冲突。而ThreadLocal就相当于,老师直接准备了100支笔,这样每个同学都使用自己的,同学之间就不会产生冲突。

很显然这就是两种不同的思路,同步机制以“时间换空间”,由于每个线程在同一时刻共享对象只能被一个线程访问造成整体上响应时间增加,但是对象只占有一份内存,牺牲了时间效率换来了空间效率即“时间换空间”。而ThreadLocal为每个线程都分配了一份对象,自然而然内存使用率增加,每个线程各用各的,整体上时间效率要增加很多,牺牲了空间效率换来时间效率即“空间换时间”。

ThreadLocal 实现原理

ThreadLocal的实现是这样的:每个Thread 维护一个 ThreadLocalMap 映射表,这个映射表的 key 是 ThreadLocal 实例本身,value 是真正需要存储的 Object。

也就是说 ThreadLocal 本身并不存储值,它只是作为一个 key 来让线程从 ThreadLocalMap 获取 value。

ThreadLocal为什么会内存泄漏

在这里插入图片描述

上图中,实线代表强引用,虚线代表的是弱引用,如果ThreadLocal外部强引用被置为null(ThreadLocalInstance=null)的话,ThreadLocal实例就没有一条引用链路可达,很显然在GC(垃圾回收)的时候势必会被回收,因此entry就存在key为null的情况,无法通过一个key 为null去访问到该entry的value。同时,就存在了这样一条引用链:threadRef->currentThread->threadLocalMap->entry->valueRef->valueMemory,导致在垃圾回收的时候进行可达性分析的时候,value可达从而不会被回收掉,但是该value永远不能被访问到,这样就存在了内存泄漏。当然,如果线程执行结束后,ThreadLocal,threadRef会断掉,因此ThreadLocal,ThreadLocalMap,entry都会被回收掉。可是,在实际使用中我们都是会用线程池去维护我们的线程,比如在Executors.newFixedThreadPool()时创建线程的时候,为了复用线程是不会结束的,所以ThreadLocal内存泄漏就值得我们关注。

3.1. 已经做出了哪些改进?

ThreadLocalMap的设计中已经考虑到这种情况,也加上了一些防护措施:在ThreadLocal的get(),set(),remove()的时候都会清除线程ThreadLocalMap里所有key为null的value。

但是这些被动的预防措施并不能保证不会内存泄漏:

使用static的ThreadLocal,延长了ThreadLocal的生命周期,可能导致的内存泄漏。

分配使用了ThreadLocal又不再调用get(),set(),remove()方法,那么就会导致内存泄漏。

下面不妨我们看看这些被动的措施:

为了叙述,针对key为null的entry,源码注释为stale entry,直译为不新鲜的entry,这里我就称之为“脏entry”。比如在ThreadLocalMap的set方法中:

private void set(ThreadLocal<?> key, Object value) {

   // We don't use a fast path as with get() because it is at
   // least as common to use set() to create new entries as
   // it is to replace existing ones, in which case, a fast
   // path would fail more often than not.

   Entry[] tab = table;
   int len = tab.length;
   int i = key.threadLocalHashCode & (len-1);

   for (Entry e = tab[i];
            e != null;
            e = tab[i = nextIndex(i, len)]) {
       ThreadLocal<?> k = e.get();

       if (k == key) {
           e.value = value;
           return;
       }

       if (k == null) {
           replaceStaleEntry(key, value, i);
           return;
       }
    }

   tab[i] = new Entry(key, value);
   int sz = ++size;
   if (!cleanSomeSlots(i, sz) && sz >= threshold)
       rehash();
}

在该方法中针对脏entry做了这样的处理:

如果当前table[i]!=null的话说明hash冲突就需要向后环形查找,若在查找过程中遇到脏entry就通过replaceStaleEntry进行处理;

如果当前table[i]==null的话说明新的entry可以直接插入,但是插入后会调用cleanSomeSlots方法检测并清除脏entry。

3.2. 为什么使用弱引用?

从表面上看内存泄漏的根源在于使用了弱引用。网上的文章大多着重分析ThreadLocal使用了弱引用会导致内存泄漏,但是另一个问题也同样值得思考:为什么使用弱引用而不是强引用?

我们先来看看官方文档的说法:

To help deal with very large and long-lived usages, the hash table entries use WeakReferences for keys.
为了应对非常大和长时间的用途,哈希表使用弱引用的 key。

下面我们分两种情况讨论:

A、key 使用强引用:引用的ThreadLocal的对象被回收了,但是ThreadLocalMap还持有ThreadLocal的强引用,如果没有手动删除,ThreadLocal不会被回收,导致Entry内存泄漏。

B、key 使用弱引用:引用的ThreadLocal的对象被回收了,由于ThreadLocalMap持有ThreadLocal的弱引用,即使没有手动删除,ThreadLocal也会被回收。value在下一次ThreadLocalMap调用set,get,remove的时候会被清除。

比较两种情况,我们可以发现:由于ThreadLocalMap的生命周期跟Thread一样长,如果都没有手动删除对应key,都会导致内存泄漏,但是使用弱引用可以多一层保障:弱引用ThreadLocal不会内存泄漏,对应的value在下一次ThreadLocalMap调用set,get,remove的时候会被清除。

因此,ThreadLocal内存泄漏的根源是:由于ThreadLocalMap的生命周期跟Thread一样长,如果没有手动删除对应key就会导致内存泄漏,而不是因为弱引用。使用弱引用的话在threadLocal生命周期里会尽可能的保证不出现内存泄漏的问题,达到安全的状态。

3.3. Thread.exit()

当线程退出时会执行exit方法:

private void exit() {
   if (group != null) {
       group.threadTerminated(this);
       group = null;
   }
   /* Aggressively null out all reference fields: see bug 4006245 */
   target = null;
   /* Speed the release of some of these resources */
   threadLocals = null;
   inheritableThreadLocals = null;
   inheritedAccessControlContext = null;
   blocker = null;
   uncaughtExceptionHandler = null;
}

从源码可以看出当线程结束时,会令threadLocals=null,也就意味着GC的时候就可以将ThreadLocalMap进行垃圾回收,换句话说ThreadLocalMap生命周期实际上Thread的生命周期相同。

ThreadLocal 最佳解决

综合上面的分析,我们可以理解ThreadLocal内存泄漏的前因后果,那么怎么避免内存泄漏呢?

每次使用完ThreadLocal,都调用它的remove()方法,清除数据。

在使用线程池的情况下,没有及时清理ThreadLocal,不仅是内存泄漏的问题,更严重的是可能导致业务逻辑出现问题。所以,使用ThreadLocal就跟加锁完要解锁一样,用完就清理。


网站公告

今日签到

点亮在社区的每一天
去签到