浅谈 ThreadLocal 的实际运用

6,775 阅读7分钟

ThreadLocal 是 JDK 1.2 提供的一个工具,作者其一也是我们耳熟能详的大佬 Doug Lea

这个工具主要是为了解决多线程下共享资源的问题

接下来我们从 ThreadLocal 的定义以及适用场景一步步扒开它的外衣

适用场景

  • 场景1,ThreadLocal 用作保存每个线程独享的对象,为每个线程都创建一个副本,这样每个线程都可以修改自己所拥有的副本, 而不会影响其他线程的副本,确保了线程安全。

  • 场景2,ThreadLocal 用作每个线程内需要独立保存信息,以便供其他方法更方便地获取该信息的场景。每个线程获取到的信息可能都是不一样的,前面执行的方法保存了信息后,后续方法可以通过 ThreadLocal 直接获取到,避免了传参,类似于全局变量的概念。

场景1

我们去饭店点了一桌子菜,有面条,有炒菜,有卤味。这个饭店的厨师很热情,每个厨师都想下面给你吃,第一个厨师给这个面放了一把盐巴,第二个厨师不知道也给了这个面放了盐巴,第三个厨师不知道也给了这个面放了盐巴,第四个厨师.......

这就好比多线程下,线程不安全的问题了

所以 Doug Lea 说,你们一人负责做一道菜,不要瞎胡闹

接下来我们上下代码来演示一下这个简单的例子(100个线程都要用到 SimpleDateFormat)

public static ExecutorService threadPool = Executors.newFixedThreadPool(16);
static SimpleDateFormat dateFormat = new SimpleDateFormat("mm:ss");

public static void main(String[] args) throws InterruptedException {
    for (int i = 0; i < 100; i++) {
        int finalI = i;
        threadPool.submit(new Runnable() {
            @Override
            public void run() {
                String date = new ThreadLocalDemo01().date(finalI);
                System.out.println(date);
            }
        });
    }
    threadPool.shutdown();
}

public String date(int seconds) {
    Date date = new Date(1000 * seconds);
    return dateFormat.format(date);
}


输出:
00:05
00:07
00:05
00:05
00:06
00:05
00:05
00:11
00:05
00:12
00:10
  

执行上面的代码就会发现,控制台所打印出来的和我们所期待的是不一致的

我们所期待的是打印出来的时间是不重复的,但是可以看出在这里出现了重复,比如第一行和第三行都是 05 秒,这就代表它内部已经出错了。

这时候是不是有机智的同学说,并发问题加锁不就解决了吗,that is good idea

代码改一下变成这样


    public static ExecutorService threadPool = Executors.newFixedThreadPool(16);
    static SimpleDateFormat dateFormat = new SimpleDateFormat("mm:ss");

    public static void main(String[] args) throws InterruptedException {
        for (int i = 0; i < 1000; i++) {
            int finalI = i;
            threadPool.submit(new Runnable() {
                @Override
                public void run() {
                    String date = new ThreadLocalDemo05().date(finalI);
                    System.out.println(date);
                }
            });
        }
        threadPool.shutdown();
    }

    public String date(int seconds) {
        Date date = new Date(1000 * seconds);
        String s = null;
        synchronized (ThreadLocalDemo05.class) {
            s = dateFormat.format(date);
        }
        return s;
    }

这下好了,我们加上 synchronized 是没有重复了,但是效率大大降低了

那么有没有什么既可以吃西瓜又可以捡芝麻的方法呢?

可以让每个线程都拥有一个自己的 simpleDateFormat 对象来达到这个目的,这样就能两全其美了,说干就干


public class ThreadLocalDemo06 {

    public static ExecutorService threadPool = Executors.newFixedThreadPool(16);

    public static void main(String[] args) throws InterruptedException {
        for (int i = 0; i < 1000; i++) {
            int finalI = i;
            threadPool.submit(new Runnable() {
                @Override
                public void run() {
                    String date = new ThreadLocalDemo06().date(finalI);
                    System.out.println(date);
                }
            });
        }
        threadPool.shutdown();
    }

    public String date(int seconds) {
        Date date = new Date(1000 * seconds);
        SimpleDateFormat dateFormat = ThreadSafeFormatter.dateFormatThreadLocal.get();
        return dateFormat.format(date);
    }
}

class ThreadSafeFormatter {
    public static ThreadLocal<SimpleDateFormat> dateFormatThreadLocal = new ThreadLocal<SimpleDateFormat>() {
        @Override
        protected SimpleDateFormat initialValue() {
            return new SimpleDateFormat("mm:ss");
        }
    };
}

场景2

ok 场景2就是我们目前项目中使用到的,利用 ThreadLocal 来控制数据权限

我们想做到的是,每个线程内需要保存类似于全局变量的信息(例如在拦截器中获取的用户信息),可以让不同方法直接使用,避免参数传递的麻烦却不想被多线程共享(因为不同线程获取到的用户信息不一样)。

例如,用 ThreadLocal 保存一些业务内容,比如一个 UserRequest,这个 UserRequest中存放一些这个用户的信息,诸如权限组、编号等信息

在线程生命周期内,都通过这个静态 ThreadLocal 实例的 get() 方法取得自己 set 过的那个对象,避免了将这个request作为参数传递的麻烦

于是我们写了这样的一个工具类

public class AppUserContextUtil {

    private static ThreadLocal<String> userRequest = new ThreadLocal<String>();

    /**
     * 获取userRequest
     *
     * @return
     */
    public static String getUserRequest() {
        return userRequest.get();
    }

    /**
     * 设置userRequest
     *
     * @param param
     */
    public static void setUserRequest(String param) {
        userRequest.set(param);
    }

    /**
     * 移除userRequest
     */
    public static void removeUserRequest() {
        userRequest.remove();
    }

}

那么当一个请求进来的时候,一个线程会负责执行这个请求,无论这个请求经历过多少个类的方法的,都可以直接去 get 出我们的 userRequest 从而进行业务处理或者权限管控

在 Thread 中如何存储

二话不说,上图

一个 Thread 里面只有一个ThreadLocalMap ,而在一个 ThreadLocalMap 里面却可以有很多的 ThreadLocal,每一个 ThreadLocal 都对应一个 value。

因为一个 Thread 是可以调用多个 ThreadLocal 的,所以 Thread 内部就采用了 ThreadLocalMap 这样 Map 的数据结构来存放 ThreadLocal 和 value。

我们一起看下 ThreadLocalMap 这个内部类

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;
        }
    }
   private Entry[] table;
//...
}

ThreadLocalMap 类是每个线程 Thread 类里面的一个成员变量,其中最重要的就是截取出的这段代码中的 Entry 内部类。在 ThreadLocalMap 中会有一个 Entry 类型的数组,名字叫 table。我们可以把 Entry 理解为一个 map,其键值对为:

  • 键,当前的 ThreadLocal;
  • 值,实际需要存储的变量,比如 user 用户对象或者 simpleDateFormat 对象等。

ThreadLocalMap 既然类似于 Map,所以就和 HashMap 一样,也会有包括 set、get、rehash、resize 等一系列标准操作。但是,虽然思路和 HashMap 是类似的,但是具体实现会有一些不同。

比如其中一个不同点就是,我们知道 HashMap 在面对 hash 冲突的时候,采用的是拉链法。

但是 ThreadLocalMap 解决 hash 冲突的方式是不一样的,它采用的是线性探测法。如果发生冲突,并不会用链表的形式往下链,而是会继续寻找下一个空的格子。这是 ThreadLocalMap 和 HashMap 在处理冲突时不一样的点

使用姿势

Key泄漏

我们刚才介绍了 ThreadLocalMap,每一个 ThreadLocal 都有一个 ThreadLocalMap

尽管我们可能会这样操作 ThreadLocal instance = null ,将这个实例设置为 null,以为这样就可以高枕无忧了

然而,经过GC严谨的可达性的分析,尽管我们在业务代码中把 ThreadLocal 实例置为了 null,但是在 Thread 类中依然有这个引用链的存在。

GC 在垃圾回收的时候会进行可达性分析,它会发现这个 ThreadLocal 对象依然是可达的,所以对于这个 ThreadLocal 对象不会进行垃圾回收,这样的话就造成了内存泄漏的情况。从而导致 OOM,从而导致半夜告警,从而导致绩效325,从而辞职送外卖等等一系反应

Doug Lea 考虑到如此危险,所以 ThreadLocalMap 中的 Entry 继承了 WeakReference 弱引用,

static class Entry extends WeakReference<ThreadLocal<?>> {

    /** The value associated with this ThreadLocal. */
    Object value;

    Entry(ThreadLocal<?> k, Object v) {

        super(k);
        value = v;
    }
}

可以看到,这个 Entry 是 extends WeakReference。弱引用的特点是,如果这个对象只被弱引用关联,而没有任何强引用关联,那么这个对象就可以被回收,所以弱引用不会阻止 GC。因此,这个弱引用的机制就避免了 ThreadLocal 的内存泄露问题。

Value泄漏

我们认真思考,ThreadLocalMap 的每个 Entry 都是一个对 key 的弱引用,但是这个 Entry 包含了一个对 value 的强引用

强引用那就意味着在线程生命不结束的时候,我们这个变量永远存在我们的内存里

但是很有可能我们早就不需要这个变量了,Doug Lea 是个暖男,为我们考虑到了这个问题,在执行 ThreadLocal 的 set、remove、rehash 等方法时,它都会扫描 key 为 null 的 Entry,如果发现某个 Entry 的 key 为 null,则代表它所对应的 value 也没有作用了,所以它就会把对应的 value 置为 null,这样,value 对象就可以被正常回收了。

但是假设 ThreadLocal 已经不被使用了,那么实际上 set、remove、rehash 方法也不会被调用,与此同时,如果这个线程又一直存活、不终止的话,那么这个内存永远不会被GC掉,也就导致了 value 的内存泄漏,从而导致 OOM,从而导致半夜告警,从而导致绩效325,从而辞职送外卖等等一系反应

为了避免悲剧的发生,我们在使用完了 ThreadLocal 之后,我们应该手动去调用它的 remove 方法,目的是防止内存泄漏的发生。

public void remove() {
    ThreadLocalMap m = getMap(Thread.currentThread());
    if (m != null)
        m.remove(this);
}

remove 方法中,可以看出,它是先获取到 ThreadLocalMap 这个引用的,并且调用了它的 remove 方法。这里的 remove 方法可以把 key 所对应的 value 给清理掉,这样一来,value 就可以被 GC 回收了

小结

以上就是 《浅谈 ThreadLocal 的实际运用 》的全部内容了,在本文中我们介绍了 ThreadLocal 的适用场景,并且针对场景进行了代码演示;认识了 ThreadLocal 在线程中究竟是如何存储的;也学会了使用 ThreadLocal 的正确姿势。

如果本文对你有帮助,欢迎点赞、关注。🙏


往期推荐

3 分钟简单理解桥接模式 来自大厂的11条异常最佳实践 工厂设计模式,这几个问题你知道吗?