上一篇分析了ThreadLocal的核心API,讲到ThreadLocal就绕不开内存泄露问题,本来就来分析ThreadLocal内存泄漏产生背景以及防护策略。
内存泄漏产生的原因
引用类型
什么是内存泄露?java内存泄漏是指程序中已动态分配的堆内存由于某种原因未能释放,造成内存浪费,最终可能导致程序性能下降或崩溃。java是具有垃圾回收机制的编程语言,内存泄漏通常表现为无意中持有无用对象的引用。
说到无引用的对象,这又得从java的引用开始说起了。Java从1.2版本开始引入了四种引用类型,它们按强度从强到弱依次为:强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference) 和 虚引用(Phantom Reference)。
(1)强引用是Java程序中最常见、默认的引用类型。当您使用new关键字创建对象时,所获得的引用就是强引用。
- 特点:被强引用关联的对象永远不会被垃圾回收器回收,即使系统内存不足,JVM宁愿抛出OutOfMemoryError终止程序,也不会回收这些对象。
- 回收条件:只有当强引用被显式地设置为null,或者超出其作用域时,对象才会变得可回收。
(2)软引用用来描述一些有用但非必需的对象。
- 特点:在内存充足时,软引用对象不会被回收;只有当JVM认为内存不足时(在抛出OutOfMemoryError之前),才会回收这些对象。通过java.lang.ref.SoftReference类实现。
(3)弱引用的强度比软引用更弱。
- 特点:无论当前内存是否充足,只要发生垃圾回收,弱引用对象就会被回收。通过java.lang.ref.WeakReference类实现。
- 使用场景:主要用于防止内存泄漏。
(4)虚引用是最弱的一种引用关系,完全不会影响对象的生命周期。
- 特点:无法通过虚引用获取对象实例(get()方法总是返回null),它唯一的用途是跟踪对象被垃圾回收的状态。通过java.lang.ref.PhantomReference类实现,必须与ReferenceQueue联合使用。
ThreadLocal内存泄露分析
前面我们已经知道ThreadLocal内部的哈希表ThreadLocalMap采用Entry数组存储数据,每个Entry使用弱引用指向ThreadLocal对象作为key,实际存储的变量值作为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;
}
}
...
}
了解了引用类型的相关概念以及作用后,这里用弱引用类WeakReference指向ThreadLocal,即ThreadLocal对象(Entry的key)设计为弱引用的目的是防止内存泄露,Entry继承弱引用类WeakReference主要用于创建具有弱引用特性的自定义引用类,也就是Entry类具有弱引用的特性,使得Entry对象的key在内存不足的时候可以被垃圾回收,但是Entry对象的value却是一个强引用类型。当key由于是弱引用会被垃圾回收变为null,对应的value因强引用依然存在且无法被访问,但系统长时间运行未及时清理ThreadLocal变量时,就可能导致内存泄漏问题。
内存泄漏示例分析
从前文我们已经知道,ThreadLocal的remove()方法可以从ThreadLocalMap中移除当前线程中此线程局部变量的值,如果程序没有调用该方法及时清除线程的局部变量,就有可能造成内存泄漏。下面我们通过一个例子来重现内存泄露问题
public class MemoryLeakDemo {
static class LargeObject {
// 申请内存空间1G
private byte[] data = new byte[1024 * 1024 * 1024];
}
public static void main(String[] args) throws InterruptedException {
ExecutorService executor = Executors.newFixedThreadPool(1);
for (; ; ) {
// 提交任务
executor.submit(() -> {
// 创建ThreadLocal变量并赋值
ThreadLocal<LargeObject> threadLocal = new ThreadLocal<>();
threadLocal.set(new LargeObject());
// 清理threadLocal变量
//threadLocal.remove();
});
Thread.sleep(1000);
System.out.println("main thread running...");
}
}
}
上面的示例代码中,创建一个只有一个线程的线程池,每秒钟往线程池里面提交一个任务,任务就是创建ThreadLocal变量并赋值,每次赋值需要申请1G的内存空间。运行程序后,用JDK自带的Java进程信息输出工具和JVM统计监控工具jstat ,实时监控Java虚拟机的垃圾回收情况和堆内存使用状况。
C:\Program Files\Java\jdk-21\bin>jps -l
27264 org.jetbrains.jps.cmdline.Launcher
14424 com.demo.concurrent.MemoryLeakDemo
18728 jdk.jcmd/sun.tools.jps.Jps
20668 org.jetbrains.jps.cmdline.Launcher
9420 com.intellij.idea.Main
-l选项:输出主类或JAR文件的完整包名/路径。
#监控Java虚拟机
>jstat -gcutil [进程ID] 1000
-gcutil:监控选项,表示显示垃圾回收统计信息
[进程ID]:目标Java进程的虚拟机会话ID
1000:采样间隔时间,单位为毫秒
默认会持续输出,直到手动停止
不执行remove()方法时,Java虚拟机的垃圾回收情况和堆内存使用状况如下:

然后执行remove()方法时Java虚拟机的垃圾回收情况和堆内存使用状况如下:

jstat命令内存使用百分比指标的详细含义:
| 指标 | 含义 | 正常范围建议 |
|---|---|---|
| S0 | 年轻代中第一个Survivor区已使用容量百分比 | 0-100% |
| S1 | 年轻代中第二个Survivor区已使用容量百分比 | 0-100% |
| E | 年轻代中Eden区已使用容量百分比 | <90% |
| O | 老年代已使用容量百分比 | <85% |
| M | 元空间(Metaspace)已使用容量百分比 | <90% |
| CCS | 压缩类空间使用比例 | <90% |
jstat命令垃圾回收统计指标
| 指标 | 全称 | 含义 |
|---|---|---|
| YGC | Young GC Count | 从程序启动到采样时年轻代GC次数 |
| YGCT | Young GC Time | 年轻代GC总耗时(秒) |
| FGC | Full GC Count | 从程序启动到采样时Full GC次数 |
| FGCT | Full GC Time | Full GC总耗时(秒) |
| GCT | GC Time | 垃圾回收总时间(秒) |
当出现内存泄漏时,通常会有以下特征:
- 老年代使用率(O) 持续上升,即使经过Full GC也不明显下降
- Full GC次数(FGC) 和 Full GC时间(FGCT) 频繁增加
- 年轻代GC次数(YGC) 异常频繁
而上面不执行remove()方法和执行remove()方法时垃圾回收统计指标最明显的差异是FGC、FGCT这个两个指标,不执行remove()方法时由于不断的内存申请,jvm垃圾回收的次数和时间明显增多,而执行remove()方法时由于自动清理线程局部变量则没有触发jvm垃圾回收。
内存泄漏防护策略
下面来了解下有哪些内存泄漏防护策略。
(1)显示调用remove()方法
在使用完ThreadLocal后,必须手动调用remove()方法来清理当前线程的ThreadLocalMap中的Entry
,推荐使用try-finally块确保清理操作一定会执行。
(2)使用static final修饰ThreadLocal变量。
将ThreadLocal变量声明为static final可以避免重复创建ThreadLocal实例。
(3)给ThreadLocal变量设置合理的初始值。
所以上面的例子,我们可以这样改造。
public class MemoryLeakDemo {
//ThreadLocal 变量
private static final ThreadLocal<LargeObject> threadLocal = ThreadLocal.withInitial(() -> new LargeObject(10));
static class LargeObject {
// 申请内存空间
byte[] data;
protected LargeObject(int size) {
data = new byte[size];
}
}
public static void main(String[] args) throws InterruptedException {
ExecutorService executor = Executors.newFixedThreadPool(1);
for (int i = 0; i < 20; i++) {
// 提交任务
executor.submit(() -> {
try {
// 创建ThreadLocal变量并赋值
threadLocal.set(new LargeObject(10));
} catch (Exception e) {
e.printStackTrace();
} finally {
// 清理threadLocal变量
threadLocal.remove();
}
});
Thread.sleep(1000);
System.out.println("main thread running...");
}
executor.shutdown();
System.out.println("executor shutdown...");
}
}
ThreadLocal典型应用场景举例
ThreadLocal常见的典型应用场景,比如对线程不安全工具类的封装(如对SimpleDateFormat的线程安全封装),Web框架中的用户身份、语言环境等上下文传递等。下面以对SimpleDateFormat的线程安全封装为例说明ThreadLocal的应用。
我们知道SimpleDateFormat 不是线程安全的,主要原因是其内部使用了一个共享的 Calendar 对象。当多个线程同时调用 format 或 parse 方法时,一个线程可能会覆盖另一个线程设置的日期值,从而导致错误结果或抛出 NumberFormatException。
以下代码模拟了多个线程同时使用同一个 SimpleDateFormat 实例进行日期格式化操作:
public class SimpleDateFormatUnsafeExample {
//共享SimpleDateFormat实例
private static final SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
public static void main(String[] args) {
// 提交10个格式化任务
for (int i = 0; i < 10; i++) {
final int index = i;
new Thread(() -> {
try {
Date date = new Date(System.currentTimeMillis() + index * 1000);
// 共享实例,线程不安全
String result = sdf.format(date);
System.out.println("Thread " + Thread.currentThread().getName() + ": " + date.getTime() + " -> " + result);
} catch (Exception e) {
System.err.println("Thread " + Thread.currentThread().getName() + " error: " + e.getMessage());
}
}).start();
}
}
}
运行结果:
Thread Thread-0: 1762161139684 -> 2025-11-03 17:12:19
Thread Thread-8: 1762161147686 -> 2025-11-03 17:12:27
Thread Thread-9: 1762161148686 -> 2025-11-03 17:12:28
Thread Thread-7: 1762161146686 -> 2025-11-03 17:12:26
Thread Thread-6: 1762161145685 -> 2025-11-03 17:12:25
Thread Thread-5: 1762161144685 -> 2025-11-03 17:12:24
Thread Thread-4: 1762161143685 -> 2025-11-03 17:12:21
Thread Thread-2: 1762161141684 -> 2025-11-03 17:12:21
Thread Thread-3: 1762161142684 -> 2025-11-03 17:12:22
Thread Thread-1: 1762161140684 -> 2025-11-03 17:12:20
从运行结果可以看出,Thread-4和Thread-2传入不同的时间戳,但是却得到了相同的格式化结果,这就是共享SimpleDateFormat对象线程不安全导致的。那么如何改进这问题呢?有以下几种方案:
(1)推荐使用 Java 8 的 DateTimeFormatter。
(2)每次使用时都创建新的 SimpleDateFormat 实例,避免共享。
(3)通过对共享的 SimpleDateFormat 实例加锁,确保同一时刻只有一个线程使用它。
(4)采用ThreadLocal存储SimpleDateFormat 实例,为每个线程提供独立的 SimpleDateFormat 实例,既保证了线程安全,又避免了频繁创建对象。
以采用ThreadLocal存储SimpleDateFormat 实例,对上述代码进行改造:
public class SimpleDateFormatUnsafeExample {
//线程SimpleDateFormat实例
private static final ThreadLocal<SimpleDateFormat> simpleDateFormatThreadLocal =
ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));
public static void main(String[] args) {
// 提交10个格式化任务
for (int i = 0; i < 10; i++) {
final int index = i;
new Thread(() -> {
try {
Date date = new Date(System.currentTimeMillis() + index * 1000);
// 线程SimpleDateFormat实例,线程安全
String result = simpleDateFormatThreadLocal.get().format(date);
System.out.println("Thread " + Thread.currentThread().getName() + ": " + date.getTime() + " -> " + result);
} catch (Exception e) {
System.err.println("Thread " + Thread.currentThread().getName() + " error: " + e.getMessage());
}finally {
//内存清理
simpleDateFormatThreadLocal.remove();
}
}).start();
}
}
}
运行结果:
Thread Thread-6: 1762161768972 -> 2025-11-03 17:22:48
Thread Thread-0: 1762161762971 -> 2025-11-03 17:22:42
Thread Thread-5: 1762161767972 -> 2025-11-03 17:22:47
Thread Thread-1: 1762161763972 -> 2025-11-03 17:22:43
Thread Thread-7: 1762161769973 -> 2025-11-03 17:22:49
Thread Thread-3: 1762161765972 -> 2025-11-03 17:22:45
Thread Thread-9: 1762161771973 -> 2025-11-03 17:22:51
Thread Thread-8: 1762161770973 -> 2025-11-03 17:22:50
Thread Thread-4: 1762161766972 -> 2025-11-03 17:22:46
Thread Thread-2: 1762161764972 -> 2025-11-03 17:22:44
从运行结果可以看出,不再出现不同的时间戳,但是却得到了相同的格式化结果的情况。

被折叠的 条评论
为什么被折叠?



