Java线程池 - ThreadLocal底层原理与内存泄漏OOM
一、ThreadLocal 的底层原理
1. JDK8 官方文档解释
根据Oracle官方文档JDK8里的描述:
This class provides thread-local variables. These variables differ from their normal counterparts in that each thread that accesses one (via its get
or set
method) has its own, independently initialized copy of the variable. ThreadLocal
instances are typically private static fields in classes that wish to associate state with a thread (e.g., a user ID or Transaction ID).
此类提供线程局部变量。这些变量与它们的普通对应物(普通变量)不同,因为每个访问该变量的线程(通过其 get
或 set'
方法)都有自己独立初始化的变量副本。ThreadLocal
实例通常是类中的 private
static
字段,希望将状态与线程(例如,用户 ID 或事务 ID)相关联。
构造函数:
构造函数 | 描述 |
---|---|
ThreadLocal() | 创建一个线程本地变量。 |
静态方法:
修饰符和类型 | 方法 | 描述 |
---|---|---|
static <S> ThreadLocal<S> | withInitial(Supplier<? extends S> supplier) | 创建一个线程本地变量,其初始值由调用Supplier.get() 方法确定。 |
实例方法:
修饰符和类型 | 方法 | 描述 |
---|---|---|
T | get() | 返回当前线程在此线程本地变量中的值。 |
protected T | initialValue() | 返回此线程本地变量的当前线程初始值。 |
void | remove() | 移除当前线程在此线程本地变量中的值。 |
void | set(T value) | 设置当前线程在此线程本地变量中的值为指定值。 |
我的理解是:每一个 ThreadLocal 对象,对于不同的 Thread 在其中存储的值(包括对其的操作),彼此之间是隔离的,互不干扰。而且由于是存线程私有的东西,所以一般是作为 static 变量来用,因为这样设计的话 ThreadLocal 的引用会一直存在,各个线程也可以直接 Xxx.threadLocal 直接拿到这个“容器”,不要进行各种传递操作。
2. JDK8 源码解析实现原理
每个线程在往 ThreadLocal
里放值的时候,是调用 set
方法。
/**
* Sets the current thread's copy of this thread-local variable
* to the specified value. Most subclasses will have no need to
* override this method, relying solely on the {@link #initialValue}
* method to set the values of thread-locals.
*
* @param value the value to be stored in the current thread's copy of
* this thread-local.
*/
public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) {
map.set(this, value);
} else {
createMap(t, value);
}
}
/**
* Get the map associated with a ThreadLocal. Overridden in
* InheritableThreadLocal.
*
* @param t the current thread
* @return the map
*/
ThreadLocalMap getMap(Thread t) {
return t.threadLocals;
}
从以上 set 方法可以看出,其实就是拿了 Thread.currentThread()
当前线程的内部的 ThreadLocal.ThreadLocalMap threadLocals
,然后以当前操作的 ThreadLocal
(this)作为 key,放置 value。
/**
* Returns the value in the current thread's copy of this
* thread-local variable. If the variable has no value for the
* current thread, it is first initialized to the value returned
* by an invocation of the {@link #initialValue} method.
*
* @return the current thread's value of this thread-local
*/
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();
}
get 方法其实也是一样的,拿了 Thread.currentThread()
当前线程的内部的 ThreadLocal.ThreadLocalMap threadLocals
,然后以当前操作的 ThreadLocal
作为 key,获取 value。
其实这里就讲完了,别怀疑,你已经可以知道 ThreadLocal
是如何实现线程隔离的了,不过你可能还是没转过弯来,毕竟是有点绕,绕的原因是 JDK 的 ThreadLocal 数据结构设计,其实是“反着来”的,为什么这么说呢?
JDK 其实可以这样设计的:在 threadLocal.set
的时候,把 threadLocal
内部的 ThreadLocal.ThreadLocalMap
(假设设计成其内部变量)拿出来,然后以 Thread.currentThread()
作为 key,去拿到 value。
这样其实比较直观,那JDK为什么不这样设计呢?这里就涉及到很多考虑因素了,我们后续单独开一期讲~
接下来我们有个疑问,ThreadLocal.ThreadLocalMap
是如何实现 Map 的功能的?
/**
* ThreadLocalMap is a customized hash map suitable only for
* maintaining thread local values. No operations are exported
* outside of the ThreadLocal class. The class is package private to
* allow declaration of fields in class Thread. To help deal with
* very large and long-lived usages, the hash table entries use
* WeakReferences for keys. However, since reference queues are not
* used, stale entries are guaranteed to be removed only when
* the table starts running out of space.
*/
static class ThreadLocalMap {
/**
* The entries in this hash map extend WeakReference, using
* its main ref field as the key (which is always a
* ThreadLocal object). Note that null keys (i.e. entry.get()
* == null) mean that the key is no longer referenced, so the
* entry can be expunged from table. Such entries are referred to
* as "stale entries" in the code that follows.
*/
static class Entry extends WeakReference<ThreadLocal<?>> {
/** The value associated with this ThreadLocal. */
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}
/**
* The table, resized as necessary.
* table.length MUST always be a power of two.
*/
private Entry[] table;
用 Entry[] table
数组作为映射关系表 ,是一种简化设计,后续应该是 key 映射到某个数组下标,进行键值对的保存。
而这里的 Entry 的 key,是其继承的父类 WeakReference<ThreadLocal<?>>
的祖先 Reference<T>
的内部变量 private T referent
,也就是 ThreadLocal 的弱引用是 key。
我们记得 threadLocal.set
的时候,调用了 threadLocalMap.set
/**
* Set the value associated with key.
*
* @param key the thread local object
* @param value the value to be 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();
}
/**
* Increment i modulo len.
*/
private static int nextIndex(int i, int len) {
return ((i + 1 < len) ? i + 1 : 0);
}
我们可以看到,key 转换下标的逻辑,是 int i = key.threadLocalHashCode & (table.length - 1)
,其实非常容易理解,就是 key 拿到线程 threadLocalHashCode
再对数组大小取模,是典型的 map 压缩存储的算法。
哈希冲突的解决方法,是从计算来的数组下标开始, nextIndex 往右循环遍历非空(e != null
)的位置,对比一致(或者 threadLocal.get
空了)则替换,不成功则继续循环,直到为空的位置出现,插入,然后判断达到阈值后进行 rehash
操作。
之所以循环条件不会死循环,是因为会及时进行 rehash
操作,使得数组永远有空余可以插入来兜底。
我们记得 threadLocal.get
的时候,调用了 threadLocalMap.getEntry
/**
* Get the entry associated with key. This method
* itself handles only the fast path: a direct hit of existing
* key. It otherwise relays to getEntryAfterMiss. This is
* designed to maximize performance for direct hits, in part
* by making this method readily inlinable.
*
* @param key the thread local object
* @return the entry associated with key, or null if no such
*/
private Entry getEntry(ThreadLocal<?> key) {
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);
}
/**
* Version of getEntry method for use when key is not found in
* its direct hash slot.
*
* @param key the thread local object
* @param i the table index for key's hash code
* @param e the entry at table[i]
* @return the entry associated with key, or null if no such
*/
private Entry getEntryAfterMiss(ThreadLocal<?> key, int i, Entry e) {
Entry[] tab = table;
int len = tab.length;
while (e != null) {
ThreadLocal<?> k = e.get();
if (k == key)
return e;
if (k == null)
expungeStaleEntry(i);
else
i = nextIndex(i, len);
e = tab[i];
}
return null;
}
也是和 set 差不多的逻辑,从计算来的数组下标开始, nextIndex 往右循环遍历非空(e != null
)的位置,对比一致则返回(忽略 expungeStaleEntry 逻辑),不成功则继续循环,直到为空,返回 null。
如果你看到了这里,你就能看懂这个设计图:
二、内存泄漏的直观表现
1. 现象:线程池场景下OOM
子线程 OOM:
main 线程 OOM:
控制台输出:
java.lang.OutOfMemoryError: Java heap space
2. 特征:Key为null的Entry残留
在 MAT 中可以发现,最大的对象都是 Entry 里的 value 大对象。
进去看,这些 Entry
的 referent
在 Heap 中是不存在了,也就是 referent == null。而 referent
是 ThreadLocal<?>
类型的,所以也就是说,ThreadLocal 的 WeakReference 弱引用被清除了。
根据这个语句,查询出所有 referent 为 null 的 java.lang.ThreadLocal$ThreadLocalMap$Entry
类型的 Heap 内对象
SELECT * FROM java.lang.ThreadLocal$ThreadLocalMap$Entry WHERE key.get() = null
上面这个查询语句,说明下,因为上面说到,Entry 是继承了 WeakReference,key 就是祖先的 referent,Reference 类的 get 就是获取其内部 referent 变量。
/**
* Returns this reference object's referent. If this reference object has
* been cleared, either by the program or by the garbage collector, then
* this method returns <code>null</code>.
*
* @return The object to which this reference refers, or
* <code>null</code> if this reference object has been cleared
*/
public T get() {
return this.referent;
}
结论:非常多的 Key为null的Entry残留
三、根源深度剖析
1. 数据结构设计缺陷:引用链的致命缺陷
根据 JDK 官方文档对 WeakReference
的描述,简单来讲:垃圾回收器在某一时刻确定某个对象处于弱可达状态(即仅通过弱引用链可达)时,会清除所有指向该对象的弱引用,垃圾回收会回收这部分资源。
一个 Thread 内的 ThreadLocalMap 的 Entry[]
数组,存放着所有 Entry 的强引用,所以如果 Thread 不被 GC 回收,Entry 是不会被 GC 回收的。而 Entry 对 ThreadLocal 的引用是 弱引用
当其中一个 ThreadLocal 运行结束,只剩 Entry 对其的弱引用,所以会在后续的 GC 周期被回收,referent 变为 null,但是 value 其实还是不会被回收的,毕竟有整条强引用链。
但是我们有个疑问,那么等 Thread 被回收不就可以回收 ThreadLocalMap 了,也就可以回收 Entry[],Entry 也会被回收了?
是的,所以一般等 Thread 生命周期结束,其实 value 的内存也会被回收,也就不会OOM了。
2. 线程生命周期错配
在线程池场景,没有配置核心线程超时回收(配置项为 allowCoreThreadTimeOut
)的情况下,核心线程在线程池中是会一直存在的,线程执行完 firstTask
任务,会一直循环去 taskQueue
中阻塞式获取任务(taskQueue.take()
),生命周期就没有结束,内存也就不会被回收,那么 ThreadLocalMap 自然也不会被回收,Entry[] 不会被回收,Entry 也就不会被回收了。
// 线程复用逻辑(Worker.run())
final void runWorker(Worker w) {
while (task != null || (task = getTask()) != null) { // 循环获取任务
try {
task.run(); // 执行任务(ThreadLocal.set()在此处调用)
} finally {
task = null;
}
}
}
也就是说,ThreadLocal 的生命周期是和 Thread 的生命周期匹配,但是线程池里的核心线程一般不会走到销毁阶段,也就导致了 ThreadLocal 里的存储内容一直存在。
四、解决方案
方案1:remove()的精准使用
ThreadLocal 中提供了 remove 方法:
/**
* Removes the current thread's value for this thread-local
* variable. If this thread-local variable is subsequently
* {@linkplain #get read} by the current thread, its value will be
* reinitialized by invoking its {@link #initialValue} method,
* unless its value is {@linkplain #set set} by the current thread
* in the interim. This may result in multiple invocations of the
* {@code initialValue} method in the current thread.
*
* @since 1.5
*/
public void remove() {
ThreadLocalMap m = getMap(Thread.currentThread());
if (m != null) {
m.remove(this);
}
}
可以将当前线程在这个 ThreadLocal 的存储 value 进行移除,底层是用 value = null
语句切断对 value 的强引用,然后再 tab[i] = null
切断对 Entry 的强引用,让 GC 自动去回收。
方案2:FastThreadLocal的降维打击
用 FastThreadLocal + FastThreadLocalThread + 线程池 的组合,就可以避免这种 OOM,为什么呢?
用例简化代码如下,也就是用 FastThreadLocal 取代 ThreadLocal,用 FastThreadLocalThread 取代普通的 Thread,然后其余是一样的,但是执行的结果就是没有 OOM。
private static final FastThreadLocal<BigObject> fastThreadLocal = new FastThreadLocal<>();
public static void main(String[] args) {
ExecutorService executor = Executors.newFixedThreadPool(80);
// 提交大量任务
for (int i = 0; i < 100; i++) {
Runnable voidFunc0 = () -> {
System.gc(); // 理论上GC就能清除足够的空间了
fastThreadLocal.set(new BigObject()); // 设置大对象到 ThreadLocal
};
executor.submit(new FastThreadLocalThread(voidFunc0)); // 包装一层 FastThreadLocalThread
}
executor.shutdown();
}
我们看看源码,这几个都是 io.netty.util.concurrent
包下的内容
首先,是 fastThreadLocal.set
做了什么事情?其实也就是 FastThreadLocal 体系下的,存储当前线程的值,这里不深入探讨。
重点看 new FastThreadLocalThread(voidFunc0)
做了什么?其实源码里就是调用 FastThreadLocalRunnable.wrap
对 Runnable
任务进行 wrap,也就是包装操作,包装后的 run 方法对原先的 run 方法进行了增强,在其执行后会调用 FastThreadLocal.removeAll
进行 FastThreadLocal
的移除,这样就一定程度避免了线程池场景下的 OOM,毕竟一旦任务跑完就会清楚所有无用的线程本地变量。
// FastThreadLocalThread 包装的简化版
public class FastThreadLocalThread extends Thread {
public FastThreadLocalThread(Runnable target) {
super(FastThreadLocalRunnable.wrap(target));
}
}
import io.netty.util.internal.ObjectUtil;
final class FastThreadLocalRunnable implements Runnable {
private final Runnable runnable;
private FastThreadLocalRunnable(Runnable runnable) {
this.runnable = (Runnable)ObjectUtil.checkNotNull(runnable, "runnable");
}
public void run() {
try {
this.runnable.run(); // 被包装的任务的 run
} finally {
FastThreadLocal.removeAll(); // 增强执行移除 FastThreadLocal
}
}
// 包装器模式
static Runnable wrap(Runnable runnable) {
return (Runnable)(runnable instanceof FastThreadLocalRunnable ? runnable : new FastThreadLocalRunnable(runnable));
}
}
FastThreadLocal.removeAll
的具体执行底层逻辑比较复杂,简单来讲,就是获取当前线程的 threadLocalMap
,然后遍历当前线程所有的 FastThreadLocal
,然后进行 remove
操作。
方案3:包装WeakReference的防御策略
在某些允许数据丢失的场景,例如临时缓存的场景,或者作为紧急解决的方案,我们可以考虑将 value 对象包装成 WeakReference,这样至少解决了 OOM,不过如果 value 还有强引用时就还是会失效,而且 WeakReference 的清理是有延迟的,短时间内内存也会释放得比较慢,还有最大的问题就是数据丢失了,所以这个方案这里不展开讨论。
五、实战验证
1. 内存泄漏复现
我们在一个 Main 方法中执行,减少复线成本,并且没有用到 JDK 外的包,可以直接运行。
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class ThreadLocalLeakDemo {
// 模拟大对象(更容易触发OOM)
public static class BigObject {
private final byte[] data = new byte[1024 * 1024]; // 1MB
}
// 未正确清理的 ThreadLocal
private static final ThreadLocal<BigObject> threadLocal = new ThreadLocal<>();
public static void main(String[] args) {
try {
ExecutorService executor = Executors.newFixedThreadPool(80);
// 提交大量任务
for (int i = 0; i < 100; i++) {
System.out.println("Thread: " + i + " submit");
executor.submit(() -> {
System.out.println("thread: " + Thread.currentThread().getName() + "(id=" + Thread.currentThread().getId() + ")" + " init");
System.gc(); // 理论上GC就能清除足够的空间了
threadLocal.set(new BigObject()); // 设置大对象到 ThreadLocal
// 模拟业务逻辑(未调用 threadLocal.remove())
System.out.println("thread: " + Thread.currentThread().getName() + "(id=" + Thread.currentThread().getId() + ")" + " running");
// threadLocal.remove();
});
Thread.sleep(10); // 控制任务提交速度
System.out.println("Thread: " + i + " submit success");
}
executor.shutdown();
// 触发主线程 OOM
threadLocal.set(new BigObject());
} catch (Throwable e) {
e.printStackTrace();
}
}
}
Java 运行脚本如下,其中也可以用 IDEA 的 Add Vm option 选项去添加以下几个参数:
-Xmx64m
:最大运行内存 64MB-XX:+HeapDumpOnOutOfMemoryError
:OOM触发后 dump 一下堆信息-XX:HeapDumpPath=./threadlocal_leak.hprof
:保存至 当前执行目录下 threadlocal_leak.hprof 文件
java -Xmx64m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=./threadlocal_leak.hprof ThreadLocalLeakDemo.java
子线程 OOM:
main 线程 OOM:
2. MAT内存分析截图
用 MAT 工具分析:
我们 1024 * 1024 = 1,048,576 字节 = 1MB,加上一些必要的变量,每个线程占用了 1048992 字节,1MB 多一些些,符合我们的用例的情况。
3. JProfiler 分析
我们通过 JProfiler 去分析,发现内存 100%,其中占用最多的是 byte[] 数组,也就是我们大对象的内容。
我们分析某个 BigObject 的 GcRoot 路径,发现是因为 Thread 没有销毁,导致的大对象没有销毁。
4. 解决方案实验
我们用 FastThreadLocal + FastThreadLocalThread 进行验证,发现OOM消失了。
import io.netty.util.concurrent.FastThreadLocal;
import io.netty.util.concurrent.FastThreadLocalThread;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class ThreadLocalLeakDemo2 {
// 模拟大对象(更容易触发OOM)
public static class BigObject {
private final byte[] data = new byte[1024 * 1024]; // 1MB
}
// 未正确清理的 ThreadLocal
private static final FastThreadLocal<BigObject> fastThreadLocal = new FastThreadLocal<>();
public static void main(String[] args) {
try {
ExecutorService executor = Executors.newFixedThreadPool(80);
// 提交大量任务
for (int i = 0; i < 100; i++) {
System.out.println("Thread: " + i + " submit");
Runnable voidFunc0 = () -> {
System.out.println("thread: " + Thread.currentThread().getName() + "(id=" + Thread.currentThread().getId() + ")" + " init");
System.gc(); // 理论上GC就能清除足够的空间了
fastThreadLocal.set(new BigObject()); // 设置大对象到 ThreadLocal
// 模拟业务逻辑(未调用 threadLocal.remove())
System.out.println("thread: " + Thread.currentThread().getName() + "(id=" + Thread.currentThread().getId() + ")" + " running");
// threadLocal.remove();
};
executor.submit(new FastThreadLocalThread(voidFunc0));
Thread.sleep(10); // 控制任务提交速度
System.out.println("Thread: " + i + " submit success");
}
executor.shutdown();
// 触发主线程 OOM
fastThreadLocal.set(new BigObject());
} catch (Throwable e) {
e.printStackTrace();
}
}
}
用 remove 的方案,实验下,也是解决了 OOM 问题。
package org.springframework.boot.launchscript.threadpool;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class ThreadLocalLeakDemo {
// 模拟大对象(更容易触发OOM)
public static class BigObject {
private final byte[] data = new byte[1024 * 1024]; // 1MB
}
// 未正确清理的 ThreadLocal
private static final ThreadLocal<BigObject> threadLocal = new ThreadLocal<>();
public static void main(String[] args) {
try {
ExecutorService executor = Executors.newFixedThreadPool(80);
// 提交大量任务
for (int i = 0; i < 100; i++) {
System.out.println("Thread: " + i + " submit");
executor.submit(() -> {
System.out.println("thread: " + Thread.currentThread().getName() + "(id=" + Thread.currentThread().getId() + ")" + " init");
System.gc(); // 理论上GC就能清除足够的空间了
threadLocal.set(new BigObject()); // 设置大对象到 ThreadLocal
// 模拟业务逻辑(未调用 threadLocal.remove())
System.out.println("thread: " + Thread.currentThread().getName() + "(id=" + Thread.currentThread().getId() + ")" + " running");
threadLocal.remove();
});
Thread.sleep(10); // 控制任务提交速度
System.out.println("Thread: " + i + " submit success");
}
executor.shutdown();
// 触发主线程 OOM
threadLocal.set(new BigObject());
} catch (Throwable e) {
e.printStackTrace();
}
}
}
六、参考资料
https://docs.oracle.com/javase/8/docs/api/java/lang/ThreadLocal.html
https://cloud.tencent.com/developer/article/2355282
https://www.bilibili.com/video/BV1BsqHYdEun
https://javaguide.cn/java/concurrent/threadlocal.html#threadlocal%E4%BB%A3%E7%A0%81%E6%BC%94%E7%A4%BA
https://www.bilibili.com/video/BV1N741127FH
JDK 1.8 源码
博主的其他文章推荐,感兴趣可以点击翻阅:
《Java线程池 - 深入解析ThreadPoolExecutor的底层原理(源码全面讲解一篇就够)》
《Spring源码 - Spring IOC 如何解决循环依赖》
《Spring源码 - 这才是Spring Bean生命周期》
《Spring源码 - 深度解析@Resource依赖注入的执行逻辑》
《Spring源码 - Spring AOP底层逻辑详解(万字长文)》
其中 《Java线程池 - 深入解析ThreadPoolExecutor的底层原理(源码全面讲解一篇就够)》跟本文的契合度很高,极力推荐!
更多内容,可以关注公众号 源码启示录,创作者 CSDN:chugyoyo
创作不易,希望得到您的 点赞、收藏 和 关注~