面试官:你说说ThreadLocal为什么会导致内存泄漏?

2,066 阅读12分钟

1. 前言

“ThreadLocal为什么会导致内存泄漏,如何避免?”

这是笔者在面试阿里时,面试官提出的问题,当时回答的并不好,今天刚好有时间,决定复盘一下,彻底弄清楚内存泄漏的原因,并分享给大家。

1.1 何为内存泄漏?

首先我们有必要了解,到底何为「内存泄漏」?笔者这里引用百度百科的解释。

内存泄漏(Memory Leak)是指程序中已动态分配的堆内存由于某种原因程序未释放或无法释放,造成系统内存的浪费,导致程序运行速度减慢甚至系统崩溃等严重后果。

站在Java的角度来说,就是JVM创建的对象永远都无法访问到,但是GC又不能回收对象所占用的内存。少量的内存泄漏并不会出现什么严重问题,无非是浪费了一些内存资源罢了,但是随着时间的积累,内存泄漏的越来越多就会导致「内存溢出」,程序崩溃。

因此,开发者必须非常小心,尽量避免内存泄漏,一旦发现就要尽快解决,以免造成严重后果。

1.2 ThreadLocal介绍

本篇文章主要记录ThreadLocal内存泄漏的原因,但是怕部分读者可能还不太了解ThreadLocal,所以还是决定再稍微介绍一下。

多个线程访问同一个共享变量时,如果不做同步控制,往往会出现「数据不一致」的问题,通常会使用synchronized关键字加锁来解决,ThreadLocal则换了一个思路。

ThreadLocal本身并不存储值,它依赖于Thread类中的ThreadLocalMap,当调用set(T value)时,ThreadLocal将自身作为Key,值作为Value存储到Thread类中的ThreadLocalMap中,这就相当于所有线程读写的都是自身的一个私有副本,线程之间的数据是隔离的,互不影响,也就不存在线程安全问题了。

2. 内存泄漏的原因

ThreadLocalMap内部维护了一个Entry[] table来存储键值对的映射关系,内存泄漏和Entry类有非常大的关系,下面是Entry的源码:

static class Entry extends WeakReference<ThreadLocal<?>> {
    /** The value associated with this ThreadLocal. */
    Object value;

    Entry(ThreadLocal<?> k, Object v) {
        super(k);
        value = v;
    }
}

Entry将ThreadLocal作为Key,值作为value保存,它继承自WeakReference,注意构造函数里的第一行代码super(k),这意味着ThreadLocal对象是一个「弱引用」。有的同学可能对「弱引用」不太熟悉,这里再介绍一下Java的四种引用关系。

Java的四种引用

在JDK1.2之前,“引用”的概念过于狭隘,如果Reference类型的数据存储的是另外一块内存的起始地址,就称该Reference数据是某块地址、对象的引用,对象只有两种状态:被引用、未被引用。 这样的描述未免过于僵硬,对于这一类对象则无法描述:内存足够时暂不回收,内存吃紧时进行回收。例如:缓存数据。

在JDK1.2之后,Java对引用的概念做了一些扩充,将引用分为四种,由强到弱依次为:

  • 强引用(Strongly Reference) 指代码中普遍存在的赋值行为,如:Object o = new Object(),只要强引用关系还在,对象就永远不会被回收。
  • 软引用(Soft Reference) 还有用处,但是非必须存活的对象,JVM会在内存溢出前对其进行回收,例如:缓存。
  • 弱引用(Weak Reference) 非必须存活的对象,引用关系比软引用还弱,不管内存是否够用,下次GC一定回收。
  • 虚引用(Phantom Reference) 也称“幽灵引用”、“幻影引用”,最弱的引用关系,完全不影响对象的回收,等同于没有引用,虚引用的唯一的目的是对象被回收时会收到一个系统通知。

综上所述,由于ThreadLocal对象是弱引用,如果外部没有强引用指向它,它就会被GC回收,导致Entry的Key为null,如果这时value外部也没有强引用指向它,那么value就永远也访问不到了,按理也应该被GC回收,但是由于Entry对象还在强引用value,导致value无法被回收,这时「内存泄漏」就发生了,value成了一个永远也无法被访问,但是又无法被回收的对象。

Entry对象属于ThreadLocalMapThreadLocalMap属于Thread,如果线程本身的生命周期很短,短时间内就会被销毁,那么「内存泄漏」立刻就会得到解决,只要线程被销毁,value也会随之被回收。问题是,线程本身是非常珍贵的计算机资源,很少会去频繁的创建和销毁,一般都是通过线程池来使用,这就将线程的生命周期大大拉长,「内存泄漏」的影响也会越来越大。

2.1 弱引用是原罪吗?

网上有的文章将ThreadLocal内存泄漏的原因怪罪于Entry的Key的弱引用,这个说法是极其错误的!

不用弱引用就能避免「内存泄漏」了吗?当然不是!!! 恰恰相反,使用弱引用是JDK在尽量避免程序出现「内存泄漏」,如下代码:

public class Test {
	public static void main(String[] args) {
		ThreadLocal threadLocal = new ThreadLocal();
		threadLocal.set(new Object());
		threadLocal = null;
	}
}

创建一个ThreadLocal对象,并设置一个Object对象,然后将其置空。如果Key不是弱引用的话,threadLocal无法被回收,也无法被访问,object无法被回收,也无法被访问,Key和Value同时出现了「内存泄漏」。

当Key是弱引用时,threadLocal由于外部没有强引用了,GC可以将其回收,ThreadLocal通过key.get()==null可以判断Key已经被回收了,当前Entry是一个废弃的过期节点,因此ThreadLocal可以自发的清理这些过期节点,来避免「内存泄漏」。

ThreadLocalMap是一个容器,不可能只进不出,否则时间长了必然会导致「内存溢出」,这也是大家平时使用各种容器对象时需要注意的点!ThreadLocal通过弱引用技术,可以及时发现过期的节点并清理,因此,弱引用是ThreadLocal来避免「内存泄漏」的,而不是导致内存泄漏的元凶。

2.2 如何避免内存泄漏?

使用ThreadLocal时,一般建议将其声明为static final的,避免频繁创建ThreadLocal实例。 尽量避免存储大对象,如果非要存,那么尽量在访问完成后及时调用remove()删除掉。

2.3 ThreadLocal做出的努力

ThreadLocal不是洪水猛兽,不要听到「内存泄漏」就不敢使用它,只要你规范化使用是不会有问题的。再者,就算你不规范使用,ThreadLocal也做出了很多努力来最大程度的帮你避免发生「内存泄漏」。

前面已经说过,由于Key是弱引用,因此ThreadLocal可以通过key.get()==null来判断Key是否已经被回收,如果Key被回收,就说明当前Entry是一个废弃的过期节点,ThreadLocal会自发的将其清理掉。

ThreadLocal会在以下过程中清理过期节点:

  1. 调用set()方法时,采样清理、全量清理,扩容时还会继续检查。
  2. 调用get()方法,没有直接命中,向后环形查找时。
  3. 调用remove()时,除了清理当前Entry,还会向后继续清理。

1、set()的清理逻辑 当线程调用ThreadLocal.set(T value)时,它会将ThreadLocal对象作为Key,值作为value设置到ThreadLocalMap中,源码如下:

private void set(ThreadLocal<?> key, Object value) {
	Entry[] tab = table;
	int len = tab.length;
	// 计算下标,算法:hashCode & (len - 1),和HashMap一样,这里不详叙。
	int i = key.threadLocalHashCode & (len-1);

	for (Entry e = tab[i];
		 /*
		 如果下标元素不是null,有两种情况:
		 1.同一个Key,覆盖value。
		 2.哈希冲突了。
		  */
		 e != null;
		 /*
		 哈希冲突的解决方式:开放定址法的线性探测。
		 当前下标被占用了,就找next,找到尾巴还没找到就从头开始找。
		 直到找到没有被占用的下标。
		  */
		 e = tab[i = nextIndex(i, len)]) {
		ThreadLocal<?> k = e.get();

		if (k == key) {
			// 相同的Key,则覆盖value。
			e.value = value;
			return;
		}

		if (k == null) {
			/*
			下标被占用,但是Key.get()为null。说明ThreadLocal被回收了。
			需要进行替换。
			 */
			replaceStaleEntry(key, value, i);
			return;
		}
	}

	tab[i] = new Entry(key, value);
	int sz = ++size;
	/*
	1.判断是否可以清理一些槽位。
	2.如果清理成功,就无需扩容了,因为已经腾出一些位置留给下次使用。
	3.如果清理失败,则要判断是否需要扩容。
	 */
	if (!cleanSomeSlots(i, sz) && sz >= threshold)
		rehash();
}

如果Entry.get()==null说明发生哈希冲突了,且旧Key已经被回收了,此时ThreadLocal会替换掉旧的value,避免发生「内存泄漏」。 如果没有哈希冲突,ThreadLocal仍然会调用cleanSomeSlots来清理部分节点,源码如下:

/*
清理部分槽位。
1.如果清理成功,就不用扩容了,因为已经腾出一部分位置了。
2.出于性能考虑,不会做所有元素做清理工作,而是采样清理。
set()时,n=size,搜索范围较小。
 */
private boolean cleanSomeSlots(int i, int n) {
	boolean removed = false;
	Entry[] tab = table;
	int len = tab.length;
	do {
		i = nextIndex(i, len);
		Entry e = tab[i];
		if (e != null && e.get() == null) {
			// 一旦搜索到了过期元素,则n=len,扩大搜索范围
			n = len;
			removed = true;
			// 真正清理的逻辑
			i = expungeStaleEntry(i);
		}
		/*
		采样规则: n >>>= 1 (折半)
		例:100 > 50 > 25 > 12 > 6 > 3 > 1
		 */
	} while ( (n >>>= 1) != 0);
	return removed;
}

真正的清理逻辑在expungeStaleEntry()中,源码如下:

/*
删除过期的元素:占用下标,但是ThreadLocal实例已经被回收的元素。
 */
private int expungeStaleEntry(int staleSlot) {
	Entry[] tab = table;
	int len = tab.length;

	// 清理当前Entry
	tab[staleSlot].value = null;
	tab[staleSlot] = null;
	size--;

	// Rehash until we encounter null
	Entry e;
	int i;
	// 继续往后寻找,直到遇到null结束
	for (i = nextIndex(staleSlot, len);
		 (e = tab[i]) != null;
		 i = nextIndex(i, len)) {
		ThreadLocal<?> k = e.get();
		if (k == null) {
			// 再次发现过期元素,清理掉
			e.value = null;
			tab[i] = null;
			size--;
		} else {
			// 处理重新哈希的逻辑
			int h = k.threadLocalHashCode & (len - 1);
			if (h != i) {
				tab[i] = null;

				// Unlike Knuth 6.4 Algorithm R, we must scan until
				// null because multiple entries could have been stale.
				while (tab[h] != null)
					h = nextIndex(h, len);
				tab[h] = e;
			}
		}
	}
	return i;
}

清理时,并不是只清理掉当前Entry就结束了,而是会往后环形的继续寻找过期的Entry,只要找到了就清理,直到遇到tab[i]==null就结束,清理的过程中还会对元素做一个rehash的操作。

2、get()的清理逻辑 线程调用ThreadLocal.get()时,会从ThreadLocalMap.getEntry(this)去查找,源码如下:

/*
通过Key获取Entry
 */
private Entry getEntry(ThreadLocal<?> key) {
	// 计算下标
	int i = key.threadLocalHashCode & (table.length - 1);
	Entry e = table[i];
	if (e != null && e.get() == key) {
		// 如果对应下标节点不为null,且Key相等,则命中直接返回
		return e;
	} else {
		/*
		否则有两种情况:
		1.Key不存在。
		2.哈希冲突了,需要向后环形查找。
		 */
		return getEntryAfterMiss(key, i, e);
	}
}

如果命中则直接返回,如果没有命中则可能是哈希冲突了、或者Key不存在/已被回收,接着调用getEntryAfterMiss()查找,这里也会进行过期节点的清理:

/*
无法直接命中的查找逻辑
 */
private Entry getEntryAfterMiss(ThreadLocal<?> key, int i, Entry e) {
	Entry[] tab = table;
	int len = tab.length;

	while (e != null) {// e==null说明Key不存在,直接返回null
		ThreadLocal<?> k = e.get();
		if (k == key)
			// 找到了,说明是哈希冲突
			return e;
		if (k == null)
			// Key存在,但是过期了,需要清理掉,并且返回null
			expungeStaleEntry(i);
		else
			// 向后环形查找
			i = nextIndex(i, len);
		e = tab[i];
	}
	return null;
}

3、remove()的清理逻辑 线程调用ThreadLocal.remove()本身就是清理当前节点的,但是为了避免发生「内存泄漏」,ThreadLocal还会检查容器中是否还有其他过期节点,如果发现也会一并清理,主要逻辑在ThreadLocalMap.remove()中:

// 通过Key删除Entry
private void remove(ThreadLocal<?> key) {
	Entry[] tab = table;
	int len = tab.length;
	// 计算下标
	int i = key.threadLocalHashCode & (len-1);
	/*
	删除也是一样,由于存在哈希冲突,不能直接定位到下标后直接删除。
	删除前需要确认Key是否相等,如果不等需要往后环形查找。
	 */
	for (Entry e = tab[i];
		 e != null;
		 e = tab[i = nextIndex(i, len)]) {
		if (e.get() == key) {
			/*
			找到了就清理掉。
			这里并没有直接清理,而是将Key的Reference引用清空了,
			然后再调用expungeStaleEntry()清理过期元素。
			顺便还可以清理后续节点。
			 */
			e.clear();
			expungeStaleEntry(i);
			return;
		}
	}
}

3. 场景演示

至此,分析完毕。下面通过几个场景演示来感受一下ThreadLocal。

1、value内存泄漏演示

public class Test {
	public static void main(String[] args) {
		ThreadLocal local = new ThreadLocal();
		local.set(new Test());
		local = null;
		// 手动触发GC,此时ThreadLocal被回收,那么value是否被回收呢?
		System.gc();
		// GC是异步执行的,主线程Sleep一会,等待对象回收
		ThreadUtil.sleep(1000);
	}

	// 对象被回收时触发
	@Override
	protected void finalize() throws Throwable {
		System.err.println("对象被回收...");
	}
}

结果:控制台无输出,value没有被回收,发生泄漏。

2、主动remove,就不会泄漏。

public class Test {
	public static void main(String[] args) {
		ThreadLocal local = new ThreadLocal();
		local.set(new Test());
		local.remove();//手动删除
		local = null;
		// 手动触发GC,此时ThreadLocal被回收,那么value是否被回收呢?
		System.gc();
		// GC是异步执行的,主线程Sleep一会,等待对象回收
		ThreadUtil.sleep(1000);
	}

	// 对象被回收时触发
	@Override
	protected void finalize() throws Throwable {
		System.err.println("对象被回收...");
	}
}

结果:控制台输出【对象被回收...】,没有泄漏。

3、不主动remove,ThreadLocal也会帮你清理。

public class Test {
	public static void main(String[] args) {
		ThreadLocal local = new ThreadLocal();
		local.set(new Test());
		local = null;
		// 手动触发GC,此时ThreadLocal被回收,那么value是否被回收呢?
		System.gc();
		ThreadUtil.sleep(100);

		// 往ThreadLocalMap多get()几次,因为get()过程会触发清理
		for (int i = 0; i < 10; i++) {
			new ThreadLocal<>().get();
		}
		System.gc();
		ThreadUtil.sleep(1000);
	}

	// 对象被回收时触发
	@Override
	protected void finalize() throws Throwable {
		System.err.println("对象被回收...");
	}
}

我并没有手动remove()!!! 结果:控制台输出【对象被回收...】,ThreadLocal主动清理了,没有泄漏。

4. 总结

ThreadLocalMap的Entry的Key是弱引用,如果外部没有强引用指向Key,Key就会被回收,而value由于Entry强引用指向了它,导致无法被回收,但是value又无法被访问,因此发生内存泄漏。 所以,开发者应该尽量在使用完毕后及时调用remove()删除节点。 如果开发者粗心忘记主动删除了,ThreadLocal也做了很多努力,在get()set()remove()时会自发的检查过期节点,并清理掉他们,最大程度的来避免程序发生「内存泄漏」,还是非常贴心的。