并发编程原理与实战(三十五)ThreadLocal内存泄露问题剖析与典型使用场景举例

上一篇分析了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命令垃圾回收统计指标

指标全称含义
YGCYoung GC Count从程序启动到采样时年轻代GC次数
YGCTYoung GC Time年轻代GC总耗时(秒)
FGCFull GC Count从程序启动到采样时Full GC次数
FGCTFull GC TimeFull GC总耗时(秒)
GCTGC 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

从运行结果可以看出,不再出现不同的时间戳,但是却得到了相同的格式化结果的情况。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

帧栈

您的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值