引言

ThreadLocal
在Java多线程编程中扮演着重要的角色,它提供了一种线程局部存储机制,允许每个线程拥有独立的变量副本,从而有效地避免了线程间的数据共享冲突。ThreadLocal的主要用途在于,当需要为每个线程维护一个独立的上下文变量时,比如每个线程的事务ID、用户登录信息、数据库连接等,可以减少对同步机制如
synchronized
关键字或Lock类的依赖,提高系统的执行效率和简化代码逻辑。

但是我们在使用
ThreadLocal
时,经常因为使用不当导致内存泄漏。此时就需要我们去探究一下
ThreadLocal
在哪些场景下会出现内存泄露?哪些场景下不会出现内存泄露?出现内存泄露的根本原因又是什么呢?如何避免内存泄露?

ThreadLocal原理

ThreadLocal
的实现基于每个线程内部维护的一个
ThreadLocalMap

public class Thread implements Runnable {
	 /* ThreadLocal values pertaining to this thread. This map is maintained
     * by the ThreadLocal class. */
    ThreadLocal.ThreadLocalMap threadLocals = null;
}

ThreadLocalMap

ThreadLocal
类的一个静态内部类,
ThreadLocal
本身不能存储数据,它在作用上更像一个工具类,
ThreadLocal
类提供了
set(T value)

get()
等方法来操作
ThreadLocalMap
存储数据。

public class ThreadLocal<T> {
    // ...
    public void set(T value) {
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t);
        if (map != null)
            map.set(this, value);
        else
            createMap(t, value);
    }

    public T get() {
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t);
        if (map != null) {
            ThreadLocalMap.Entry e = map.getEntry(this);
            if (e != null) {
                @SuppressWarnings("unchecked")
                T result = (T)e.value;
                return result;
            }
        }
        return setInitialValue();
    }

    ThreadLocalMap getMap(Thread t) {
        return t.threadLocals;
    }
    // ...
}


ThreadLocalMap
内部维护了一个
Entry
数据,用来存储数据,
Entry
继承了
WeakReference
,所以
Entry
的key是一个弱引用,可以被GC回收。
Entry
数组中的每一个元素都是一个
Entry
对象。每个
Entry
对象中存储着一个
ThreadLocal
对象与其对应的value值。

static class ThreadLocalMap {

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

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

关于弱引用的知识点,请参考:
美团一面:说一说Java中的四种引用类型?


Entry
数组中
Entry
对象的下标位置是通过
ThreadLocal

threadLocalHashCode
计算出来的。

private ThreadLocalMap(ThreadLocalMap parentMap) {
	Entry[] parentTable = parentMap.table;
	int len = parentTable.length;
	setThreshold(len);
	table = new Entry[len];

	for (Entry e : parentTable) {
		if (e != null) {
			@SuppressWarnings("unchecked")
			ThreadLocal<Object> key = (ThreadLocal<Object>) e.get();
			if (key != null) {
				Object value = key.childValue(e.value);
				Entry c = new Entry(key, value);
				// 通过key的threadLocalHashCode计算下标,这个key就是ThreadLocall对象
				int h = key.threadLocalHashCode & (len - 1);
				while (table[h] != null)
					h = nextIndex(h, len);
				table[h] = c;
				size++;
			}
		}
	}
}

而从
Entry
数组中获取对应key即
ThreadLocal
对应的value值时,也是通过key的
threadLocalHashCode
计算下标,从而可以快速的返回对应的
Entry
对象。

private Entry getEntry(ThreadLocal<?> key) {
// 通过key的threadLocalHashCode计算下标,这个key就是ThreadLocall对象
	int i = key.threadLocalHashCode & (table.length - 1);
	Entry e = table[i];
	if (e != null && e.get() == key)
		return e;
	else
		return getEntryAfterMiss(key, i, e);
}


Thread
中,可以存储多个
ThreadLocal
对象。
Thread

ThreadLocal

ThreadLocalMap
以及
Entry
数组的关系如下图:

image.png

ThreadLocal在哪些场景下不会出现内存泄露?

当一个对象失去所有强引用,或者它仅被弱引用、软引用、虚引用关联时,垃圾收集器(GC)通常都能识别并回收这些对象,从而避免内存泄漏的发生。当我们在手动创建线程时,若将变量存储到
ThreadLocal
中,那么在
Thread
线程正常运行的过程中,它会维持对内部
ThreadLocalMap
实例的引用。只要该
Thread
线程持续执行任务,这种引用关系将持续存在,确保
ThreadLocalMap
实例及其中存储的变量不会因无引用而被GC回收。

image.png

当线程执行完任务并正常退出后,线程与内部
ThreadLocalMap
实例之间的强引用关系随之断开,这意味着线程不再持有
ThreadLocalMap
的引用。在这种情况下,失去强引用的
ThreadLocalMap
对象将符合垃圾收集器(GC)的回收条件,进而被自动回收。与此同时,鉴于
ThreadLocalMap
内部的键(
ThreadLocal
对象)是弱引用,一旦
ThreadLocalMap
被回收,若此时没有其他强引用指向这些
ThreadLocal
对象,它们也将被GC一并回收。因此,在线程结束其生命周期后,与之相关的
ThreadLocalMap
及其包含的
ThreadLocal
对象理论上都能够被正确清理,避免了内存泄漏问题。

实际应用中还需关注
ThreadLocalMap
中存储的值(非键)是否为强引用类型,因为即便键(
ThreadLocal
对象)被回收,如果值是强引用且没有其他途径释放,仍可能导致内存泄漏。

ThreadLocal在哪些场景下会出现内存泄露?

在实际项目开发中,如果为每个任务都手动创建线程,这是一件很耗费资源的方式,并且在阿里巴巴的开发规范中也提到,不推荐使用手动创建线程,推荐使用线程池来执行相对应的任务。那么当我们使用线程池时,线程池中的线程跟
ThrealLocalMap
的引用关系如下:

image.png

在使用线程池处理任务时,每一个线程都会关联一个独立的
ThreadLocalMap
对象,用于存储线程本地变量。由于线程池中的核心线程在完成任务后不会被销毁,而是保持活动状态等待接收新的任务,这意味着核心线程与其内部持有的
ThreadLocalMap
对象之间始终保持着强引用关系。因此,只要核心线程存活,其所对应的
ThreadLocal
对象和
ThreadLocalMap
不会被垃圾收集器(GC)自动回收,此时就会存在内存泄露的风险。

关于Java中的线程池参数以及原理,请参考:
Java线程池最全讲解

出现内存泄露的根本原因

由上述
ThreadLocalMap
的结构图以及
ThreadLocalMap
的源码中,我们知道
ThreadLocalMap
中包含一个
Entry
数组,而
Entry
数组中的每一个元素就是
Entry
对象,
Entry
对象中存储的Key就是
ThreadLocal
对象,而value就是要存储的数据。其中,
Entry
对象中的Key属于弱引用。

static class ThreadLocalMap {

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

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

而对于弱引用
WeakReference
,在引用的对象使用完毕之后,即使内存足够,GC也会对其进行回收。

关于弱引用的知识点,请参考:
美团一面:说一说Java中的四种引用类型?

image.png


Entry
对象中的Key被GC自动回收后,对应的
ThreadLocal
被GC回收掉了,变成了null,但是
ThreadLocal
对应的value值依然被
Entry
引用,不能被GC自动回收。这样就造成了内存泄漏的风险。
image.png

在线程池环境下使用
ThreadLocal
存储数据时,内存泄露的风险主要源自于线程生命周期管理及
ThreadLocalMap
内部结构的设计。由于线程池中的核心线程在完成任务后会复用,每个线程都会维持对各自关联的
ThreadLocalMap
对象的强引用,这确保了只要线程持续存在,其对应的
ThreadLocalMap
就无法被垃圾收集器(GC)自动回收。

进一步分析,
ThreadLocalMap
内部采用一个Entry数组来保存键值对,其中每个条目的Key是当前线程中对应
ThreadLocal
实例的弱引用,这意味着当外部不再持有该
ThreadLocal
实例的强引用时,Key部分能够被GC正常回收。然而,关键在于Entry的Value部分,它直接或间接地持有着强引用的对象,即使Key因为弱引用特性被回收,但Value所引用的数据却不会随之释放,除非明确移除或者整个
ThreadLocalMap
随着线程结束而失效。

所以,在线程池中,如果未正确清理不再使用的
ThreadLocal
变量,其所持有的强引用数据将在多个任务执行过程中逐渐积累并驻留在线程的
ThreadLocalMap
中,从而导致潜在的内存泄露风险。

ThreadLocal如何避免内存泄漏

经过上述
ThreadLocal
原理以及发生内存泄漏的分析,我们知道防止内存泄漏,我们一定要在完成线程内的任务后,调用
ThreadLocal

remove()
方法来清除当前线程中
ThreadLocal
所对应的值。其
remove
方法源码如下:

 public void remove() {
	 ThreadLocalMap m = getMap(Thread.currentThread());
	 if (m != null) {
		 m.remove(this);
	 }
 }


remove()
方法中,首先根据当前线程获取
ThreadLocalMap
类型的对象,如果不为空,则直接调用该对象的有参
remove()
方法移除value的值。
ThreadLocalMap

remove
方法源码如下:

private void remove(ThreadLocal<?> key) {
	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)]) {
		if (e.get() == key) {
			e.clear();
			expungeStaleEntry(i);
			return;
		}
	}
}

由上述
ThreadLocalMap
中的
set()
方法知道
ThreadLocal

Entry
下标是通过计算
ThreadLocal

hashCode
获得了,而
remove()
方法要找到需要移除value所在
Entry
数组中的下标时,也时通过当前
ThreadLocal
对象的
hashCode
获的,然后找到它的下标之后,调用
expungeStaleEntry
将其value也置为null。我们继续看一下
expungeStaleEntry
方法的源码:

private int expungeStaleEntry(int staleSlot) {
	Entry[] tab = table;
	int len = tab.length;

	// expunge entry at staleSlot
	tab[staleSlot].value = null;
	tab[staleSlot] = null;
	size--;

	// Rehash until we encounter null
	Entry e;
	int i;
	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;
}


expungeStaleEntry()
方法中,会将
ThreadLocal
为null对应的
value
设置为null,同时会把对应的
Entry
对象也设置为null,并且会将所有
ThreadLocal
对应的value为null的
Entry
对象设置为null,这样就去除了强引用,便于后续的GC进行自动垃圾回收,也就避免了内存泄露的问题。即调用完
remove
方法之后,
ThreadLocalMap
的结构图如下:

image.png


ThreadLocal
中,不仅仅是
remove()
方法会调用
expungeStaleEntry()
方法,在
set()
方法和
get()
方法中也可能会调用
expungeStaleEntry()
方法来清理数据。这种设计确保了即使没有显式调用
remove()
方法,系统也会在必要时自动清理不再使用的
ThreadLocal
变量占用的内存资源。

需要我们特别注意的是,尽管
ThreadLocal
提供了
remove
这种机制来防止内存泄漏,但它并不会自动执行相关的清理操作。所以为了确保资源有效释放并避免潜在的内存泄露问题,我们应当在完成对
ThreadLocal
对象中数据的使用后,及时调用其
remove()
方法。我们最好(也是必须)是在
try-finally
代码块结构中,在
finally
块中明确地执行
remove()
方法,这样即使在处理过程中抛出异常,也能确保
ThreadLocal
关联的数据被清除,从而有利于GC回收不再使用的内存空间,避免内存泄漏。

总结

本文探讨了
ThreadLocal
的工作原理以及其内存泄漏问题及解决策略。
ThreadLocal
通过为每个线程提供独立的变量副本,实现多线程环境下的数据隔离。其内部通过
ThreadLocalMap
与当前线程绑定,利用弱引用管理键值对。但是,如果未及时清理不再使用的
ThreadLocal
变量,可能导致内存泄漏,尤其是在线程池场景下。解决办法包括在完成任务后调用remove方法移除无用数据。正确理解和使用
ThreadLocal
能够有效提升并发编程效率,但务必关注潜在的内存泄漏风险。

本文已收录于我的个人博客:
码农Academy的博客,专注分享Java技术干货,包括Java基础、Spring Boot、Spring Cloud、Mysql、Redis、Elasticsearch、中间件、架构设计、面试题、程序员攻略等

标签: none

添加新评论