一文看懂 ThreadLocal

本贴最后更新于 1720 天前,其中的信息可能已经时过境迁

ThreadLocal 是什么?

ThreadLocal 的 JavaDoc 上的描述是这样的...
This class provides thread-local variables. These variables differ from their normal counterparts in that each thread that accesses one (via its {@code get} or {@code set} method) has its own, independently initialized copy of the variable. {@code 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 方法,每个线程可以获取到自己独立的变量。这个变量实例通常是私有且静态的,可以存储与线程相关的信息,如员工 id、事务 id 等。

ThreadLocal 能解决什么问题?

线程并发问题。因为 ThreadLocal 解决了变量共享的问题。ThreadLocal 还有一个隐含的好处,那就是不需要将参数在方法中进行传递,可以直接从线程中获取。
举个栗子:

  • SimpleDateFormat 是线程不安全的,所以很多项目中在使用 SimpleDateFormat 对象的时候都是将其放入 ThreadLocal 中。
  • Shiro 中的 Subject 就是采用 ThreadLocal 实现的~SecurityUtils.getSubject();
  • Spring 中事务信息就是通过 ThreadLocal 进行传递的
@Test
public void testSimpleDateFormat() {
    SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
    for (int i = 0; i < 10; ++i) {
        new Thread(() -> {
            try {
                System.out.println(sdf.parse("2017-12-13 15:17:27"));
            } catch (ParseException e) {
                e.printStackTrace();
            }
        }).start();
    }
}

上述代码运行,就会大概率出现 java.lang.NumberFormatException(如果运气好没出现,可以尝试将线程数量调大一点儿)
SimpleDateFormat 之所以是非线程安全的是因为其内部 Calendar 是线程不安全的。归根结底是因为 Calendar 存放日期数据的变量。

ThreadLocal 是怎么设计的?

ThreadLocal 的类结构图

image.png
ThreadLocal 类主要是用了一个内部 ThreadLocalMap,这个 map 中存储的键值对是 Entry<ThreadLocal,Object>,Entry 类中只有 value 成员变量,key 是 ThreadLocal 对象,Entry 继承了 WeakReference
为什么要使用弱引用呢,起初的设计估计是为了让 GC 自动去清理已经挂掉的线程的相关 value。但是这有个问题,我们待会儿分析。
ThreadLocal 本身并没有持有这个 Map 对象,而是让 Thread 对象持有 Map 对象,大家可以想想这个是为什么。

ThreadLocal & ThreadLocalMap & Thread & Entry 的关系

  • Thread 只能拥有一个 ThreadLocalMap 对象。
  • 一个 ThreadLocalMap 对象存储多个 Entry 对象。
  • Entry 对象的 key 弱引用指向一个 ThreadLocal 对象。
  • 一个 ThreadLocal 对象可以被多个线程共享。
  • ThreadLocal 对象不持有 value 对象,value 由 Entry 对象持有。

ThreadLocal 的灵魂

  • set() 如果不设置值,那么容易引起脏数据问题。(比如上次这个线程被线程池回收,但是没有调用 remove,下文我们会提到这个问题)
  • get() 如果没有 get(),那么我们找不到使用 ThreadLocal 的意义...
  • remove() remove 方法一个是保证,接下来使用的时候不会出现脏数据,另外就是保证弱引用的 Entry 的 value 能被 GC 回收,不然会出现内存泄漏...下面有代码演示

ThreadLocal 应该怎么用?

我们一般用 ThreadLocal 的时候都会在一个类中,声明一个静态对象,让类持有 ThreadLocal。
再调用其 set()、get()方法,来实现赋值和取值。具体可以看如下代码

@Test
public void testThreadLocal() throws InterruptedException {
    for (int i = 0; i < 10; i++) {
        new Thread(Task::new).start();
    }
    for (int i = 0; i < 2; i++) {
        Thread.sleep(2000);
    }
}

static class Task implements Runnable {

    @Override
    public void run() {
        setName(Thread.currentThread().getName());
        try {
            TimeUnit.MILLISECONDS.sleep(500);
        } catch (InterruptedException e) {
            log.error(e.getMessage());
        }
        printName(Thread.currentThread().getName());
 	NameThreadLocal.remove();
    }

    /**
     * threadLocal保存一下线程相关名称
     *
     * @param name
     */
    static void setName(String name) {
        NameThreadLocal.setName(name);
    }

    /**
     * 打印一下 取出ThreadLocal的名称,看是否和当前线程一致
     *
     * @param threadName
     */
    static void printName(String threadName) {
        log.info(threadName + "======" + NameThreadLocal.getName());
    }
}

/**
 * 此类持有ThreadLocal静态对象
 * 此处的Name可以是登录状态也可以是分布式的请求的traceId等等
 */
static class NameThreadLocal {
    private static ThreadLocal<String> threadLocal = new ThreadLocal<>();

    static void setName(String name) {
        threadLocal.set(name);
    }

    static String getName() {
        return threadLocal.get();
    }
    
    static void remove() {
       threadLocal.remove();
    }
}
// 结果如下
//Thread-3======Thread-3
//Thread-8======Thread-8
//Thread-5======Thread-5
//Thread-11======Thread-11
//Thread-7======Thread-7
//Thread-9======Thread-9
//Thread-12======Thread-12
//Thread-10======Thread-10
//Thread-6======Thread-6
//Thread-4======Thread-4

ThreadLocal 使用时有什么坑?

ThreadLocal 的 value 不能放共享变量

假设有一个共享变量 Object。
线程 A 设置 Object 的成员变量 property 为 x,放入了 ThreadLocal。
线程 B 设置 Object 的同一个成员变量 property 为 y,放入了 ThreadLocal。
假设两个线程同时执行,我们无法保证线程 B 再 get 的时候拿到的 property 的值是 x。
我们对上面的 Task 类代码进行改造,如下

static class Task implements Runnable {
        // 一个共享变量
        static StringBuilder sb = new StringBuilder("start");

        static AtomicInteger errorNum = new AtomicInteger(0);

        @Override
        public void run() {
            for (int i = 0; i < 50; i++) {
                try {
                    TimeUnit.MILLISECONDS.sleep(3);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                sb.append(i);
            }
            setName(Thread.currentThread().getName() + sb.toString());
            printName(Thread.currentThread().getName() + sb.toString());
        }

        /**
         * threadLocal保存一下线程相关名称
         *
         * @param name
         */
        static void setName(String name) {
            NameThreadLocal.setName(name);
        }

        /**
         * 打印一下 取出ThreadLocal的名称,看是否和当前线程一致
         *
         * @param threadName
         */
        static void printName(String threadName) {
            if (!threadName.equals(NameThreadLocal.getName())) {
                errorNum.getAndIncrement();
                log.error(errorNum.toString());
            }
        }
    }

经过测试,errorNum 基本上都是大于 0。大家可以自己试一下~

ThreadLocal 遇上线程池

ThreadLocal 遇上线程池的问题,就是容易发生内存泄漏。
我们还是对 Task 类进行改造,再增加一个测试方法。

static class Task implements Runnable {
        // 一个共享变量
        static AtomicInteger errorNum = new AtomicInteger(0);

        @Override
        public void run() {
            // 我们将前两个请求设置名称,后面的不设置
            if (errorNum.getAndIncrement() > 2) {
                setName(Thread.currentThread().getName());
            }
            printName(Thread.currentThread().getName());
        }

        /**
         * threadLocal保存一下线程相关名称
         *
         * @param name
         */
        static void setName(String name) {
            NameThreadLocal.setName(name);
        }

        /**
         * 打印一下 取出ThreadLocal的名称,看是否和当前线程一致
         *
         * @param threadName
         */
        static void printName(String threadName) {
            log.info(threadName + "=======" + NameThreadLocal.getName());
        }
    }
    // 增加一个测试方法
    @Test
    public void testMemoryLeak() {
        ExecutorService executorService = Executors.newFixedThreadPool(3);
        for (int i = 0; i < 10; i++) {
            executorService.submit(new Task());
        }
    }
    // 结果如下
    //pool-1-thread-2=======null
    //pool-1-thread-3=======null
    //pool-1-thread-1=======pool-1-thread-1
    //pool-1-thread-2=======pool-1-thread-2
    //pool-1-thread-1=======pool-1-thread-1
    //pool-1-thread-3=======pool-1-thread-3
    //pool-1-thread-2=======pool-1-thread-2
    //pool-1-thread-1=======pool-1-thread-1
    //pool-1-thread-3=======pool-1-thread-3

通过上面的结果我们看到,即使一个线程执行完成了任务,在没有主动清空 ThreadLocal 的时候,再用之前线程池中的线程还会带有之前的 ThreadLocal 对应的 value.因为线程并没有被回收,ThreadLocal 的键值对并不会被清空,所以也就解答了上文我们提到的问题。这种问题一旦出现,会比较隐蔽。
解决办法就是我们在使用完成之后,需要主动进行释放
ThreadLocal.remove()

Thread 创建了子线程,ThreadLocal 怎么办?

我们可以看到 Thread 类中有一个成员变量是用来处理此情况的。

/*
 * InheritableThreadLocal values pertaining to this thread. This map is
 * maintained by the InheritableThreadLocal class.
 */
ThreadLocal.ThreadLocalMap inheritableThreadLocals = null;

我们可以直接使用 ThreadLocal 的子类**InheritableThreadLocal。但是继承等操作在线程池中的话,可能会因为动态的创建线程而变得非常混乱。所以不是很建议在线程池用 InheritableThreadLocal。**在看代码的时候,可以看一下此方法的调用。
java.lang.Thread#init(java.lang.ThreadGroup, java.lang.Runnable, java.lang.String, long, java.security.AccessControlContext, boolean)

总结

ThreadLocal 虽然有一些问题,但是我们不能因噎废食,否认其优秀。希望大家在使用的时候,注意上面的坑。

  • Java

    Java 是一种可以撰写跨平台应用软件的面向对象的程序设计语言,是由 Sun Microsystems 公司于 1995 年 5 月推出的。Java 技术具有卓越的通用性、高效性、平台移植性和安全性。

    3167 引用 • 8207 回帖
  • 线程
    120 引用 • 111 回帖 • 3 关注

相关帖子

欢迎来到这里!

我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。

注册 关于
请输入回帖内容 ...