Skip to content

并发编程之 ThreadLocal

ThreadLocal 的使用

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

  • void set(Object value) 设置当前线程的线程局部变量的值。
  • public Object get() 该方法返回当前线程所对应的线程局部变量。
  • public void remove() 将当前线程局部变量的值删除,目的是为了减少内存的占用,该方法是 JDK 5.0 新增的方法。需要指出的是,当线程结束后,对应该线程的局部变量将自动被垃圾回收,所以显式调用该方法清除线程的局部变量并不是必须的操作,但它可以加快内存回收的速度。
  • protected Object initialValue() 返回该线程局部变量的初始值,该方法是一个 protected 的方法,显然是为了让子类覆盖而设计的。这个方法是一个延迟调用方法,在线程第 1 次调用 get() 或 set(Object)时才执行,并且仅执行 1 次。ThreadLocal 中的缺省实现直接返回一个 null。
java
// RESOURCE代表一个能够存放String类型的ThreadLocal对象。 此时不论什么一个线程能够并发访问这个变量,对它进行写入、读取操作,都是 线程安全的。
public final static ThreadLocal<String> RESOURCE = new ThreadLocal<String>();

ThreadLocal 内存泄漏

代码示例

java
package com.mengweijin.learning.basic.thread;

import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

/**
 * 运行时,将堆内存大小设置为 -Xmx256m (设置小一点才可能触发 GC),打开 Java VisualVM.exe 查看内存
 * @author mengweijin
 */
@Slf4j
public class ThreadLocalDemo {

    static ExecutorService executorService = Executors.newFixedThreadPool(5);

    static class LocalVariable{
        // 10M 大小的数组
        private byte[] data = new byte[1024 * 1024 * 10];
    }

    final ThreadLocal<LocalVariable> threadLocal = new ThreadLocal<>();

    @SneakyThrows
    public static void main(String[] args) {
//        test1();
//        test2();
//        test3();
        test4();
    }

    /**
     * 场景一:
     * 不使用 ThreadLocal。任务中不执行任何有意义的代码,当所有的任务提交执行完成后,可以看见,我们这个应用的内存占用基本上为 25M 左右
     */
    @SneakyThrows
    public static void test1(){
        // 5 * 10M = 50M 大小
        for (int i = 0; i < 5; i++) {
            executorService.execute(() -> {
                log.debug("use local variable");
            });
        }

        TimeUnit.SECONDS.sleep(5);
        // 模拟垃圾回收,仅限测试
        System.gc();
    }

    /**
     * 场景二:
     * 不使用 ThreadLocal。只简单的在每个任务中 new 出一个数组,执行完成后我们可以看见,内存占用基本和场景 1 同
     */
    @SneakyThrows
    public static void test2(){
        // 5 * 10M = 50M 大小
        for (int i = 0; i < 5; i++) {
            executorService.execute(() -> {
                new LocalVariable();
                log.debug("use local variable");
            });
        }
        TimeUnit.SECONDS.sleep(5);
        // 模拟垃圾回收,仅限测试
        System.gc();
    }


    /**
     * 场景三:
     * 使用 ThreadLocal。我们可以看见,内存占用大约增加了 50M,且内存一直不回收(线程池中的线程一直存活的),说明发生了内存泄漏。
     */
    @SneakyThrows
    public static void test3(){
        // 5 * 10M = 50M 大小
        for (int i = 0; i < 5; i++) {
            executorService.execute(() -> {
                ThreadLocalDemo demo = new ThreadLocalDemo();
                demo.threadLocal.set(new LocalVariable());
                log.debug("use local variable");
            });
        }

        TimeUnit.SECONDS.sleep(5);
        // 模拟垃圾回收,仅限测试
        System.gc();
    }

    /**
     * 场景四:
     * 使用 ThreadLocal。添加一行代码:demo.threadLocal.remove();
     * 可以看见,内存占用基本和场景 1 同。 这就充分说明,场景 3,当我们启用了 ThreadLocal 以后确实发生了内存泄漏。
     */
    @SneakyThrows
    public static void test4(){
        // 5 * 10M = 50M 大小
        for (int i = 0; i < 5; i++) {
            executorService.execute(() -> {
                ThreadLocalDemo demo = new ThreadLocalDemo();
                demo.threadLocal.set(new LocalVariable());
                log.debug("use local variable");
                demo.threadLocal.remove();
            });
        }

        TimeUnit.SECONDS.sleep(5);
        // 模拟垃圾回收,仅限测试
        System.gc();
    }
}

内存泄漏分析

根据我们前面对 ThreadLocal 的分析,我们可以知道每个 Thread 维护一个 ThreadLocalMap,这个映射表的 key 是 ThreadLocal 实例本身,value 是真正需要存储的 Object,也就是说 ThreadLocal 本身并不存储值,它只是作为一个 key 来让线程从 ThreadLocalMap 获取 value。仔细观察 ThreadLocalMap,这个 map 是使用 ThreadLocal 的弱引用作为 Key 的,弱引用的对象在 GC 时会被回收。 因此使用了 ThreadLocal 后,引用链如图所示

image

这样,当把 threadlocal 变量置为 null 以后,没有任何强引用指向 threadlocal 实例,所以 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()方法,清除数据。

所以回到我们前面的实验场景,场景 3 中,虽然线程池里面的任务执行完毕了,但是线程池里面的 5 个线程会一直存在直到 JVM 退出,我们 set 了线程的 threadLocal 变量后没有调用 threadLocal.remove()方法,导致线程池里面的 5 个 线程的 threadLocals 变量里面的 new LocalVariable() 实例没有被释放。

其实考察 ThreadLocal 的实现,我们可以看见,无论是 get()、set()在某些时候,调用了 expungeStaleEntry 方法用来清除 Entry 中 Key 为 null 的 Value,但是这是不及时的,也不是每次都会执行的,所以一些情况下还是会发生内存泄露。只有 remove()方法中显式调用了 expungeStaleEntry 方法。

ThreadLocal 为什么使用弱引用而不是强引用?

下面我们分两种情况讨论:

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 就会导致内存泄漏,而不是因为弱引用。

总结

  • JVM 利用设置 ThreadLocalMap 的 Key 为弱引用,来避免内存泄露。
  • JVM 利用调用 remove、get、set 方法的时候,回收弱引用。
  • 当 ThreadLocal 存储很多 Key 为 null 的 Entry 的时候,而不再去调用 remove、 get、set 方法,那么将导致内存泄漏。
  • 使用线程池+ ThreadLocal 时要小心,因为这种情况下,线程是一直在不断的 重复运行的,从而也就造成了 value 可能造成累积的情况。

强引用、软引用、弱引用、虚引用

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

ThreadLocal 与 Synchonized 的比较

ThreadLocal 和 Synchonized 都用于解决多线程并发访问。可是 ThreadLocal 与 synchronized 有本质的差别。

synchronized 是利用锁的机制,使变量或代码块在某一时该仅仅能被一个线程访问。而 ThreadLocal 为每个线程都提供了变量的副本,使得每个线程在某一时间访问到的并非同一个对象,这样就隔离了多个线 程对数据的数据共享。

Spring 的事务就借助了 ThreadLocal 类。Spring 会从数据库连接池中获得一个 connection,然会把 connection 放进 ThreadLocal 中,也就和线程绑定了,事务需要提交或者回滚,只要从 ThreadLocal 中拿到 connection 进行操作。

为何 Spring 的事务要借助 ThreadLocal 类?

以 JDBC 为例,正常的事务代码可能如下:

java
dbc = new DataBaseConnection();//第 1 行
Connection con = dbc.getConnection();//第 2 行
con.setAutoCommit(false);// //第 3 行
con.executeUpdate(...);//第 4 行
con.executeUpdate(...);//第 5 行
con.executeUpdate(...);//第 6 行
con.commit();////第 7 行

上述代码,可以分成三个部分:

  • 事务准备阶段:第 1 ~ 3 行
  • 业务处理阶段:第 4 ~ 6 行
  • 事务提交阶段:第 7 行 可以很明显的看到,不管我们开启事务还是执行具体的 sql 都需要一个具体的数据库连接。 现在我们开发应用一般都采用三层结构,如果我们控制事务的代码都放在 DAO(DataAccessObject)对象中,在 DAO 对象的每个方法当中去打开事务和关闭事务,当 Service 对象在调用 DAO 时,如果只调用一个 DAO,那我们这样实现则效果不错,但往往我们的 Service 会调用一系列的 DAO 对数据库进行多次操作, 那么,这个时候我们就无法控制事务的边界了,因为实际应用当中,我们的 Service 调用的 DAO 的个数是不确定的,可根据需求而变化,而且还可能出现 Service 调 用 Service 的情况。

Web 容器中,每个完整的请求周期会由一个线程来处理。因此,如果我们能将一些参数绑定到线程的话,就可以实现在软件架构中跨层次的参数共享(是隐式的共享)。而 JAVA 中恰好提供了绑定的方法--使用 ThreadLocal。 结合使用 Spring 里的 IOC 和 AOP,就可以很好的解决这一点。只要将一个数据库连接放入 ThreadLocal 中,当前线程执行时只要有使用数据库连接的地方就从 ThreadLocal 获得就行了。