从源码角度分析导致 JVM 内存泄露的 ThreadLocal

发布于:2025-08-04 ⋅ 阅读:(10) ⋅ 点赞:(0)


1. 为什么需要ThreadLocal

我们从Java操作实现MySQL事务的角度去看

不难发现,如果通过上面的步骤,那么开启事务的连接和执行操作的连接不是同一个,势必会导致事务的失效

那么怎么解决上面的问题呢?

一种简单直接的方法就是,执行操作的时候将开启事务的连接作为方法参数传递给实际操作的方法

但是我们平常在使用Mybatis的时候从来没有这样传递过参数

也就是说spring自动帮我们解决了这个问题

那么它是怎么做到的?? Spring 是使用一个 ThreadLocal 来实现“绑定连接到线程”的。

ThreadLocal:

此类提供线程局部变量。这些变量与普通对应变量的不同之处在于, 访问一 个变量的每个线程(通过其 get 或 set 方法)都有自己独立初始化的变量副本。 ThreadLocal 实例通常是希望将状态与线程(例如, 用户 ID 或事务 ID)相关联 的类中的私有静态字段。也就是说 ThreadLocal 为每个线程都提供了变量的副本,使得每个线程在某 一时间访问到的并非同一个对象,这样就隔离了多个线程对数据的数据共享。

ThreadLocal 的一大应用场景就是跨方法进行参数传递,比如 Web 容器中, 每个完整的请求周期会由一个线程来处理。 结合 ThreadLocal 再使用 Spring 里的 IOC 和 AOP,就可以很好的解决我们上面的事务的问题。只要将一个数据库连接 放入 ThreadLocal 中, 当前线程执行时只要有使用数据库连接的地方就从ThreadLocal 获得就行了。

ThreadLocal 类接口很简单,只有 4 个方法,我们先来了解一下:

• void set(Object value)

设置当前线程的线程局部变量的值。

• public Object get()

该方法返回当前线程所对应的线程局部变量。

• public void remove()

将当前线程局部变量的值删除, 目的是为了减少内存的占用, 该方法是 JDK 5.0 新增的方法。

• protected Object inialValue()

返回该线程局部变量的初始值,该方法是一个 protected 的方法, 显然是为 了让子类覆盖而设计的。这个方法是一个延迟调用方法, 在线程第 1 次调用 get() 或 set(Object)时才执行,并且仅执行 1次。ThreadLocal 中的缺省实现直接返回一 个 null。

2. ThreadLocal的实现解析

1.1 实现分析

怎么实现 ThreadLocal,既然说让每个线程都拥有自己变量的副本,最容易 的方式就是用一个Map 将线程的副本存放起来, Map 里 key 就是每个线程的唯 一性标识,比如线程 ID ,value 就是副本值, 实现起来也很简单:

public class MyThreadLocal<T> {

    private Map<Thread, T> threadMap = new HashMap<>();

    public synchronized T get() {
        return threadMap.get(Thread.currentThread());
    }


    public synchronized void set(T t) {
        threadMap.put(Thread.currentThread(), t);
    }
}

考虑到并发安全性, 对数据的存取用 synchronize 关键字加锁, 但是 DougLee 在《并发编程实战》中为我们做过性能测试

可以看到 ThreadLocal 的性能远超类似 synchronize 的锁实现 ReentrantLock, 比AtomicInteger 也要快很多,即使我们把 Map 的实现更换为Java 中专为并发设计的 ConcurrentHashMap也不太可能达到这么高的性能。

怎么样设计可以让 ThreadLocal 达到这么高的性能呢?最好的办法则是让变 量副本跟随着线程本身, 而不是将变量副本放在一个地方保存, 这样就可以在存 取时避开线程之间的竞争。

同时,因为每个线程所拥有的变量的副本数是不定的, 有些线程可能有一个, 有些线程可能有 2个甚至更多, 则线程内部存放变量副本需要一个容器, 而且容器要支持快速存取, 所以在每个线程内部都可以持有一个 Map 来支持多个变量副本,这个 Map 被称为 ThreadLocalMap

1.2 具体实现

上面说到的ThreadLocalMap,实际上就实现了让变量副本跟随着线程

可以看到,这个ThreadLocalMap实际上就是一个Map,以ThreadLocal作为键,用户数据作为值

虽然这个类定义在ThreadLocal类中,但是它是声明在Thread中的

这样就能让变量副本跟随者线程

我们再来看ThreadLocalget方法

面先取到当前线程,然后调用 getMap 方法获取对应的 ThreadLocalMap,再从ThreadLocalMap中获取当前ThreadLocal对应的值

从而实现一个线程能保存多份变量副本

1.3 ThreadLocalMap中Hash冲突的解决

1.3.1 Hash冲突解决的几种方法

1.3.1.1 开放定值法

基本思想是,出现冲突后按照一定算法查找一个空位置存放,根据算法的不 同又可以分为线性探测再散列、二次探测再散列、伪随机探测再散列。

  • 线性探测再散列,即依次向后查找
  • 二次探测再散列, 即依次向前后查找, 增 量为 1 、2 、3 的二次方
  • 伪随机,顾名思义就是随机产生一个增量位移
1.3.1.2 链地址法

这种方法的基本思想是将所有哈希地址为 i 的元素构成一个称为同义词链的单链表, 并将单链表的头指针存在哈希表的第 i 个单元中, 因而查找、插入和删 除主要在同义词链中进行。链地址法适用于经常进行插入和删除的情况。 Java里的 HashMap 用的就是链地址法,为了避免 hash 洪水攻击,1.8 版本开始还引 入了红黑树

1.3.1.3再哈希法:

这种方法是同时构造多个不同的哈希函数: Hi=RH1(key) i=1 ,2 ,… ,k 当哈希地址 Hi=RH1(key)发生冲突时,再计算 Hi=RH2(key)……,直到冲突 不再产生。这种方法不易产生聚集,但增加了计算时间

1.3.1.4 建立公共溢出区

这种方法的基本思想是: 将哈希表分为基本表和溢出表两部分, 凡是和基本 表发生冲突的元

素, 一律填入溢出表。

1.3.2 ThreadLocal解决Hash冲突的方法

ThreadLocal 里用的是线性探测再散列

3. ThreadLocal引发的内存泄漏分析

3.1 强引用、软应用、弱引用和虚引用

  • 强引用就是指在程序代码之中普遍存在的, 类似“Object obj=new Object()” 这类的引用, 只要强引用还存在, 垃圾收集器永远不会回收掉被引用的对象实例。
  • 软引用是用来描述一些还有用但并非必需的对象。对于软引用关联着的对象, 在系统将要发生内存溢出异常之前, 将会把这些对象实例列进回收范围之中进行 第二次回收。如果这次回收还没有足够的内存, 才会抛出内存溢出异常。在 JDK 1.2 之后,提供了 SoReference 类来实现软引用。
  • 弱引用也是用来描述非必需对象的, 但是它的强度比软引用更弱一些,被弱引用关联的对象实例只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象实例。在 JDK 1.2 之 后,提供了WeakReference 类来实现弱引用。
  • 虚引用也称为幽灵引用或者幻影引用, 它是最弱的一种引用关系。一个对象实例是否有虚引用的存在, 完全不会对其生存时间构成影响, 也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象实例被收集器回收时收到一个系统通知。在 JDK 1.2 之后,提供了PhantomReference 类来实现虚引用。

3.2 内存泄露的现象


public class ThreadLocalMemoryLeakTest {
    // 创建固定大小的线程池,核心线程和最大线程数都是5
    final static ThreadPoolExecutor poolExecutor = new ThreadPoolExecutor(
            5, 5, 1, TimeUnit.MINUTES, new LinkedBlockingDeque<>()
    );

    // 定义一个占用5M内存的本地变量类
    static class LocalVariable {
        // 大约5M的字节数组
        byte[] bytes = new byte[1024 * 1024 * 5];
    }

    // ThreadLocal变量,用于存储LocalVariable
    static ThreadLocal<LocalVariable> threadLocal = new ThreadLocal<>();

    public static void main(String[] args) throws InterruptedException {
        testCase1();
    }

3.2.1 第一组测试

仅从线程池获取线程,不做任何操作

/**
     * 测试场景1:仅从线程池获取线程,不做任何操作
     */
    public static void testCase1() throws InterruptedException {
        System.out.println("执行测试场景1:仅使用线程池,不做任何操作");

        while (true) {
            poolExecutor.execute(() -> {
                System.out.println("线程" + Thread.currentThread().getId() + "执行完毕");
                try {
                    // 短暂休眠,模拟任务执行时间
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            });

            // 控制任务提交速度
            Thread.sleep(50);
        }
    }

通过visualvm工具可以看到内存占用很小

3.2.2 第二组测试

创建LocalVariable但不使用ThreadLocal

/**
 * 测试场景2:创建LocalVariable但不使用ThreadLocal
 */
public static void testCase2() throws InterruptedException {
System.out.println("执行测试场景2:创建LocalVariable但不使用ThreadLocal");

while (true) {
    poolExecutor.execute(() -> {
        // 创建本地变量,但不存储到ThreadLocal
        LocalVariable var = new LocalVariable();
        System.out.println("线程" + Thread.currentThread().getId() + "创建了LocalVariable");

        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        // 方法结束后,var会被GC回收
    });

    Thread.sleep(50);
}
}

在GC的作用下,无用的对象会被回收,内存占用会下降到正常水平

3.2.3 第三组测试

使用ThreadLocal存储LocalVariable但不清理

 /**
     * 测试场景3:使用ThreadLocal存储LocalVariable但不清理
     */
    public static void testCase3() throws InterruptedException {
        System.out.println("执行测试场景3:使用ThreadLocal存储但不清理");

        while (true) {
            poolExecutor.execute(() -> {
                // 存储变量到ThreadLocal,但不调用remove()
                threadLocal.set(new LocalVariable());
                System.out.println("线程" + Thread.currentThread().getId() + "向ThreadLocal存储了变量");

                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }

                // 不清理ThreadLocal,可能导致内存泄露
            });

            Thread.sleep(50);
        }
    }

可以看到,与2不同的是,尽管有GC的作用,但是占用的内存还是超出预料范围

3.2.4 第四组测试

使用ThreadLocal存储并正确清理

 /**
     * 测试场景4:使用ThreadLocal存储并正确清理
     */
    public static void testCase4() throws InterruptedException {
        System.out.println("执行测试场景4:使用ThreadLocal存储并清理");

        while (true) {
            poolExecutor.execute(() -> {
                try {
                    // 存储变量到ThreadLocal
                    threadLocal.set(new LocalVariable());
                    System.out.println("线程" + Thread.currentThread().getId() + "向ThreadLocal存储了变量并清理");

                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                } finally {
                    // 手动清理ThreadLocal,避免内存泄露
                    threadLocal.remove();
                }
            });

            Thread.sleep(50);
        }
    }

可以看到,在GC的作用下,现象与第二组测试基本一致

3.3 内存泄露的分析

根据我们前面对 ThreadLocal 的分析,我们可以知道每个 Thread 维护一个 ThreadLocalMap,这个映射表的 key 是 ThreadLocal 实例本身, value 是真正需 要存储的 Object,也就是说 ThreadLocal 本身并不存储值, 它只是作为一个 key 来让线程从 ThreadLocalMap 获取 value。

仔细观察ThreadLocalMap,这个 map 是使用 ThreadLocal 的弱引用作为 Key 的,弱引用的对象在 GC 时会被回收

那么此时的引用链路关系就是:

图中的虚线表示弱引用

这 样 , 当 把 threadlocal 变 量 置 为 null 以 后 , 没有任何强引用指向threadlocal实例 ,只存在key的弱引用, 所 以Threadlocal() 将会被 gc 回收。

这样一来, ThreadLocalMap 中就会出现 key 为 null 的 Entry,就没有办法访问这些 key 为 null 的 Entry 的 value,如果当前 线程再迟迟不结束的话(如线程池),这些 key 为 null 的Entry 的 value 就会一直存在一条强 引用链: Thread Ref -> Thread -> ThreaLocalMap -> Entry -> value ,而这块 value 永 远不会被访问到了,所以存在着内存泄露

只有当前 thread 结束以后, current thread 就不会存在栈中,强引用断开, Current Thread 、Map

value 将全部被 GC 回收。

最好的做法是不在需要使用ThreadLocal 变量后,都调用它的 remove()方法,清除数据。

从表面上看内存泄漏的根源在于使用了弱引用, 但是另一个问题也同样值得 思考:为什么使用弱引用而不是强引用?

我们分两种情况讨论:

key 使用强引用: 对 ThreadLocal 对象实例的引用被置为 null 了,但是ThreadLocalMap 还持有这个 ThreadLocal 对象实例的强引用, 如果没有手动删除, ThreadLocal 的对象实例不会被回收,导致 Entry 内存泄漏。

key 使用弱引用: 对 ThreadLocal 对象实例的引用被被置为 null 了,由于ThreadLocalMap 持有 ThreadLocal 的弱引用, 即使没有手动删除, ThreadLocal 的 对象实例也会被回收。value 在下一次 ThreadLocalMap 调用 set,get ,remove 都 有机会被回收。

比较两种情况,我们可以发现:由于 ThreadLocalMap 的生命周期跟 Thread 一样长, 如果都没有手动删除对应 key,都会导致内存泄漏, 但是使用弱引用可以多一层保障。

因此, ThreadLocal 内存泄漏的根源是:由于 ThreadLocalMap 的生命周期跟 Thread 一样长, 如果没有手动删除对应 key 就会导致内存泄漏, 而不是因为弱引用。

3.4 总结

JVM 利用设置 ThreadLocalMap 的 Key 为弱引用,来避免内存泄露。 JVM 利用调用 remove 、get、set 方法的时候,回收弱引用。

当 ThreadLocal 存储很多 Key 为 null 的 Entry 的时候,而不再去调用 remove、 get 、set 方法,那

么将导致内存泄漏。

使用线程池+ ThreadLocal 时要小心, 因为这种情况下, 线程是一直在不断的 重复运行的,从而也就造成了 value 可能造成累积的情况。


网站公告

今日签到

点亮在社区的每一天
去签到