浅析并发工具类之ThreadLocal

前言

不知道你有没有这种感觉,在程序设计这一方面,永远是在时间与空间之间相抵消在提高效率。就比如今天我想介绍的这一个并发工具:ThreadLocal。先来复习一下Synchronized关键字,它利用了的方式,来保证并发安全,确保共享资源只能被一个线程持有。毫无疑问这种串行机制效率比较低。那今天我就来介绍一下ThreadLocal这个工具,它是如何利用空间换时间提高效率的?以及ThreadLocal的使用场景,以及底层实现分析。

什么是ThreadLocal

顾名思义:线程本地。ThreadLocal是每个线程私有的一块内存区域。引用《Java高并发程序设计》书中提到的一个例子,我觉得十分形象生动。如果说锁是第一种保证并发安全的实现,那么ThreadLocal就是第二种。例如,我们都有去公安局办事的经历,我们一进门就需要我们填写一些表单信息,并且桌子上只有一只笔,此时就类似于锁一般,下一位只有等到笔被使用完成之后,才能够继续使用。而ThreadLocal的思路是,既然你是因为一支笔而阻塞住了,那么我直接提供100支笔,这样,效率就提升非常多。而ThreadLocal就是存储这些笔的容器。巧妙地将会出现并发问题的共享资源变成了私有资源。

ThreadLocal的使用场景

在通常的业务开发中,ThreadLocal 有下面两种典型的使用场景。

线程独享对象

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

最典型的例子就是保存一些线程不安全的类:Random、SimpleDateFormat等。

我们以一种渐进的思维看看,为什么会出现问题?

两个线程使用SimpleDateFormat

java 复制代码
public class ThreadLocalDemo01 {

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

        new Thread(() -> {

            String date = new ThreadLocalDemo01().date(1);

            System.out.println(date);

        }).start();

        Thread.sleep(100);

        new Thread(() -> {

            String date = new ThreadLocalDemo01().date(2);

            System.out.println(date);

        }).start();

    }

    public String date(int seconds) {

        Date date = new Date(1000 * seconds);

        SimpleDateFormat simpleDateFormat = new SimpleDateFormat("mm:ss");

        return simpleDateFormat.format(date);

    }

}

这样一来,有两个线程,那么就有两个 SimpleDateFormat 对象,它们之间互不干扰,这段代码是可以正常运转的,运行结果是:

java 复制代码
00:01

00:02

十个线程使用SimpleDateFormat

java 复制代码
public class ThreadLocalDemo02 {

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

        for (int i = 0; i < 10; i++) {

            int finalI = i;

            new Thread(() -> {

                String date = new ThreadLocalDemo02().date(finalI);

                System.out.println(date);

            }).start();

            Thread.sleep(100);

        }

    }

    public String date(int seconds) {

        Date date = new Date(1000 * seconds);

        SimpleDateFormat simpleDateFormat = new SimpleDateFormat("mm:ss");

        return simpleDateFormat.format(date);

    }

代码运行结果:

java 复制代码
00:00

00:01

00:02

00:03

00:04

00:05

00:06

00:07

00:08

00:09

一千个线程使用SimpleDateFormat?

创建一千个线程么?但凡你学过线程池你都不会这么做,线程的创建与销毁都是要开销的。那么此时我们应当使用线程池,来解决这个问题。

java 复制代码
public class ThreadLocalDemo03 {

    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 ThreadLocalDemo03().date(finalI);

                    System.out.println(date);

                }

            });

        }

        threadPool.shutdown();

    }

    public String date(int seconds) {

        Date date = new Date(1000 * seconds);

        SimpleDateFormat dateFormat = new SimpleDateFormat("mm:ss");

        return dateFormat.format(date);

    }

}

可以看出,我们用了一个 16 线程的线程池,并且给这个线程池提交了 1000 次任务。每个任务中它做的事情和之前是一样的,还是去执行 date 方法,并且在这个方法中创建一个 simpleDateFormat 对象。

只创建一个SimpleDateFormat可行?

在上述几个多线程下使用SimpleDateFormat的几种情况,你会发现我都是每个线程都去new 一个SimpleDateFormat对象,这样如果有上千个任务,那么就需要创建上千个SimpleDateFormat对象。那么仅仅用一个SimpleDateFormat对象是否可以?

java 复制代码
public class ThreadLocalDemo04 {

    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 ThreadLocalDemo04().date(finalI);

                    System.out.println(date);

                }

            });

        }

        threadPool.shutdown();

    }

    public String date(int seconds) {

        Date date = new Date(1000 * seconds);

        return dateFormat.format(date);

    }

}

在代码中可以看出,其他的没有变化,变化之处就在于,我们把这个 simpleDateFormat 对象给提取了出来,变成 static 静态变量,需要用的时候直接去获取这个静态对象就可以了。看上去省略掉了创建 1000 个 simpleDateFormat 对象的开销,看上去没有问题,我们用图形的方式把这件事情给表示出来:

从图中可以看出,我们有不同的线程,并且线程会执行它们的任务。但是不同的任务所调用的 simpleDateFormat 对象都是同一个,所以它们所指向的那个对象都是同一个,但是这样一来就会有线程不安全的问题。

线程不安全,出现了并发安全问题

控制台会打印出(多线程下,运行结果不唯一):

java 复制代码
00:04

00:04

00:05

00:04

...

16:15

16:14

16:13

执行上面的代码就会发现,控制台所打印出来的和我们所期待的是不一致的。我们所期待的是打印出来的时间是不重复的,但是可以看出在这里出现了重复,比如第一行和第二行都是 04 秒,这就代表它内部已经出错了。

加锁

出错的原因就在于,simpleDateFormat 这个对象本身不是一个线程安全的对象,不应该被多个线程同时访问。所以我们就想到了一个解决方案,用 synchronized 来加锁。于是代码就修改成下面的样子:

java 复制代码
public class ThreadLocalDemo05 {

    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;

    }

}

可以看出在 date 方法中加入了 synchronized 关键字,把 simpleDateFormat 的调用给上了锁。

运行这段代码的结果(多线程下,运行结果不唯一):

java 复制代码
00:00

...

10:03

10:02

10:04
...

15:56

16:33

16:36

这样的结果是正常的,没有出现重复的时间。但是由于我们使用了 synchronized 关键字,就会陷入一种排队的状态,多个线程不能同时工作,这样一来,整体的效率就被大大降低了。有没有更好的解决方案呢?

我们希望达到的效果是,既不浪费过多的内存,同时又想保证线程安全。经过思考得出,可以让每个线程都拥有一个自己的 simpleDateFormat 对象来达到这个目的,这样就能两全其美了。

使用 ThreadLocal

那么,要想达到这个目的,我们就可以使用 ThreadLocal。示例代码如下所示:

java 复制代码
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");

        }

    };

}

在这段代码中,我们使用了 ThreadLocal 帮每个线程去生成它自己的 simpleDateFormat 对象,对于每个线程而言,这个对象是独享的。但与此同时,这个对象就不会创造过多,一共只有 16 个,因为线程只有 16 个。

代码运行结果(多线程下,运行结果不唯一):

java 复制代码
00:05

00:04

00:01

...

16:37

16:36

16:32

这个结果是正确的,不会出现重复的时间。

以上就是第一种非常典型的适合使用 ThreadLocal 的场景。

上下文信息

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

最典型的例子就是存储一个User对象。

可能在开发的时候你会这么做,首先在拦截器或过滤器中获取当前登录对象,在后续Controller、Service、Dao中就不再需要传参,直接利用ThreadLocal就可以,避免了繁琐的传参。代码变得简洁、优雅!

通过我的描述,你会发现这其实就是类似一个全局变量的意思。 ![[Pasted image 20231005194609.png]]

Thread、ThreadLocal、ThreadLocalMap之间的关系

我们看到最左下角的 Thread 1,这是一个线程,它的箭头指向了 ThreadLocalMap 1,其要表达的意思是,每个 Thread 对象中都持有一个 ThreadLocalMap 类型的成员变量,在这里 Thread 1 所拥有的成员变量就是 ThreadLocalMap 1。

而这个 ThreadLocalMap 自身类似于是一个 Map,里面会有一个个 key value 形式的键值对。那么我们就来看一下它的 key 和 value 分别是什么。可以看到这个表格的左侧是 ThreadLocal 1、ThreadLocal 2...... ThreadLocal n,能看出这里的 key 就是 ThreadLocal 的引用。

而在表格的右侧是一个一个的 value,这就是我们希望 ThreadLocal 存储的内容,例如 user 对象等。

这里需要重点看到它们的数量对应关系:一个 Thread 里面只有一个ThreadLocalMap ,而在一个 ThreadLocalMap 里面却可以有很多的 ThreadLocal,每一个 ThreadLocal 都对应一个 value。因为一个 Thread 是可以调用多个 ThreadLocal 的,所以 Thread 内部就采用了 ThreadLocalMap 这样 Map 的数据结构来存放 ThreadLocal 和 value。

通过这张图片,我们就可以搞清楚 Thread、 ThreadLocal 及 ThreadLocalMap 三者在宏观上的关系了。

源码分析

知道了它们的关系之后,我们再来进行源码分析,来进一步地看到它们内部的实现。

get 方法

首先我们来看一下 get 方法,源码如下所示:

java 复制代码
public T get() {

    //获取到当前线程

    Thread t = Thread.currentThread();

    //获取到当前线程内的 ThreadLocalMap 对象,每个线程内都有一个 ThreadLocalMap 对象

    ThreadLocalMap map = getMap(t);

    if (map != null) {

        //获取 ThreadLocalMap 中的 Entry 对象并拿到 Value

        ThreadLocalMap.Entry e = map.getEntry(this);

        if (e != null) {

            @SuppressWarnings("unchecked")

            T result = (T)e.value;

            return result;

        }

    }

    //如果线程内之前没创建过 ThreadLocalMap,就创建

    return setInitialValue();

}

这是 ThreadLocal 的 get 方法,可以看出它利用了 Thread.currentThread 来获取当前线程的引用,并且把这个引用传入到了 getMap 方法里面,来拿到当前线程的 ThreadLocalMap。

然后就是一个 if ( map != null ) 条件语句,那我们先来看看 if (map == null) 的情况,如果 map == null,则说明之前这个线程中没有创建过 ThreadLocalMap,于是就去调用 setInitialValue 来创建;如果 map != null,我们就应该通过 this 这个引用(也就是当前的 ThreadLocal 对象的引用)来获取它所对应的 Entry,同时再通过这个 Entry 拿到里面的 value,最终作为结果返回。

值得注意的是,这里的 ThreadLocalMap 是保存在线程 Thread 类中的,而不是保存在 ThreadLocal 中的。

getMap 方法

下面我们来看一下 getMap 方法,源码如下所示:

java 复制代码
ThreadLocalMap getMap(Thread t) {

    return t.threadLocals;

}

可以看到,这个方法很清楚地表明了 Thread 和 ThreadLocalMap 的关系,可以看出 ThreadLocalMap 是线程的一个成员变量。这个方法的作用就是获取到当前线程内的 ThreadLocalMap 对象,每个线程都有 ThreadLocalMap 对象,而这个对象的名字就叫作 threadLocals,初始值为 null,代码如下:

java 复制代码
ThreadLocal.ThreadLocalMap threadLocals = null;

set 方法

下面我们再来看一下 set 方法,源码如下所示:

java 复制代码
public void set(T value) {

    Thread t = Thread.currentThread();

    ThreadLocalMap map = getMap(t);

    if (map != null)

        map.set(this, value);

    else

        createMap(t, value);

}

set 方法的作用是把我们想要存储的 value 给保存进去。可以看出,首先,它还是需要获取到当前线程的引用,并且利用这个引用来获取到 ThreadLocalMap ;然后,如果 map == null 则去创建这个 map,而当 map != null 的时候就利用 map.set 方法,把 value 给 set 进去。

可以看出,map.set(this, value) 传入的这两个参数中,第一个参数是 this,就是当前 ThreadLocal 的引用,这也再次体现了,在 ThreadLocalMap 中,它的 key 的类型是 ThreadLocal;而第二个参数就是我们所传入的 value,这样一来就可以把这个键值对保存到 ThreadLocalMap 中去了。

ThreadLocalMap 类,也就是 Thread.threadLocals

下面我们来看一下 ThreadLocalMap 这个类,下面这段代码截取自定义在 ThreadLocal 类中的 ThreadLocalMap 类:

java 复制代码
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,其键值对为:

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

内存安全

关于内存安全方面,我想大家应该经常听到这么两个问题

内存泄露和内存溢出。所以我觉的有必要了解一下这两个名词。

内存溢出

内存溢出 out of memory,是指程序在申请内存时,没有足够的内存空间供其使用,出现out of memory;比如你申请了一个存放Int的大小的内存,但是你偏要去存一个long大小的数据,此时就会out of memory。

内存泄露

内存泄露 memory leak,是指程序在申请内存后,无法释放已申请的内存空间。通常情况下,如果一个对象不再有用,那么我们的垃圾回收器 GC,就应该把这部分内存给清理掉。这样的话,就可以让这部分内存后续重新分配到其他的地方去使用;否则,如果对象没有用,但一直不能被回收,这样的垃圾对象如果积累的越来越多,则会导致我们可用的内存越来越少,最后发生内存不够用的 OOM 错误。

ThreadLocal 的内存泄露问题

下面我们来分析一下,在 ThreadLocal 中这样的内存泄漏是如何发生的。

Key 的泄漏

在上一讲中,我们分析了 ThreadLocal 的内部结构,知道了每一个 Thread 都有一个 ThreadLocal.ThreadLocalMap 这样的类型变量,该变量的名字叫作 threadLocals。线程在访问了 ThreadLocal 之后,都会在它的 ThreadLocalMap 里面的 Entry 中去维护该 ThreadLocal 变量与具体实例的映射。

我们可能会在业务代码中执行了 ThreadLocal instance = null 操作,想清理掉这个 ThreadLocal 实例,但是假设我们在 ThreadLocalMap 的 Entry 中强引用了 ThreadLocal 实例,那么,虽然在业务代码中把 ThreadLocal 实例置为了 null,但是在 Thread 类中依然有这个引用链的存在。

GC 在垃圾回收的时候会进行可达性分析,它会发现这个 ThreadLocal 对象依然是可达的,所以对于这个 ThreadLocal 对象不会进行垃圾回收,这样的话就造成了内存泄漏的情况。

JDK 开发者考虑到了这一点,所以 ThreadLocalMap 中的 Entry 继承了 WeakReference 弱引用,代码如下所示:

java 复制代码
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 的内存泄露问题。

这就是为什么 Entry 的 key 要使用弱引用的原因。

Value 的泄漏

可是,如果我们继续研究的话会发现,虽然 ThreadLocalMap 的每个 Entry 都是一个对 key 的弱引用,但是这个 Entry 包含了一个对 value 的强引用,还是刚才那段代码:

java 复制代码
static class Entry extends WeakReference<ThreadLocal<?>> {

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

    Object value;

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

        super(k);

        value = v;

    }

}

可以看到,value = v 这行代码就代表了强引用的发生。

正常情况下,当线程终止,key 所对应的 value 是可以被正常垃圾回收的,因为没有任何强引用存在了。但是有时线程的生命周期是很长的,如果线程迟迟不会终止,那么可能 ThreadLocal 以及它所对应的 value 早就不再有用了。在这种情况下,我们应该保证它们都能够被正常的回收。

为了更好地分析这个问题,我们用下面这张图来看一下具体的引用链路(实线代表强引用,虚线代表弱引用):

可以看到,左侧是引用栈,栈里面有一个 ThreadLocal 的引用和一个线程的引用,右侧是我们的堆,在堆中是对象的实例。

我们重点看一下下面这条链路:Thread Ref → Current Thread → ThreadLocalMap → Entry → Value → 可能泄漏的value实例。

这条链路是随着线程的存在而一直存在的,如果线程执行耗时任务而不停止,那么当垃圾回收进行可达性分析的时候,这个 Value 就是可达的,所以不会被回收。但是与此同时可能我们已经完成了业务逻辑处理,不再需要这个 Value 了,此时也就发生了内存泄漏问题。

JDK 同样也考虑到了这个问题,在执行 ThreadLocal 的 set、remove、rehash 等方法时,它都会扫描 key 为 null 的 Entry,如果发现某个 Entry 的 key 为 null,则代表它所对应的 value 也没有作用了,所以它就会把对应的 value 置为 null,这样,value 对象就可以被正常回收了。

但是假设 ThreadLocal 已经不被使用了,那么实际上 set、remove、rehash 方法也不会被调用,与此同时,如果这个线程又一直存活、不终止的话,那么刚才的那个调用链就一直存在,也就导致了 value 的内存泄漏。 如何避免内存泄露

分析完这个问题之后,该如何解决呢?解决方法就是我们本课时的标题:调用 ThreadLocal 的 remove 方法。调用这个方法就可以删除对应的 value 对象,可以避免内存泄漏。

我们来看一下 remove 方法的源码:

java 复制代码
public void remove() {

    ThreadLocalMap m = getMap(Thread.currentThread());

    if (m != null)

        m.remove(this);

}

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

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

总结

  • ThreadLocal 是一块每个线程私有的内存区域,相当于一个容器。
  • ThreadLocal常用来保存线程不安全的类如Random、SimpleDateFormat,以及用来提高代码简洁度减少重复传参。
  • ThreadLocal存在内存泄露问是因为存储的对象使用过后未清除,所以记得使用完成之后调用remove方法。
相关推荐
Trouvaille ~1 分钟前
【Java篇】存在即不变:深刻解读String类不变的艺术
java·开发语言·javase·stringbuilder·stringbuffer·string类·字符串常量池
lemon_sjdk1 分钟前
java学习——枚举类
java·开发语言·学习
2022.11.7始学前端19 分钟前
n8n第九节 使用LangChain与Gemini构建带对话记忆的AI助手
java·人工智能·n8n
LYFlied36 分钟前
在AI时代,前端开发者如何构建全栈开发视野与核心竞争力
前端·人工智能·后端·ai·全栈
用户47949283569151 小时前
我只是给Typescript提个 typo PR,为什么还要签协议?
前端·后端·开源
Surpass余sheng军1 小时前
AI 时代下的网关技术选型
人工智能·经验分享·分布式·后端·学习·架构
JosieBook1 小时前
【Spring Boot】Spring Boot调用 WebService 接口的两种方式:动态调用 vs 静态调用 亲测有效
java·spring boot·后端
a程序小傲1 小时前
京东Java面试被问:Spring拦截器和过滤器区别
java·面试·京东云·java八股文
喵个咪2 小时前
开箱即用的 GoWind Admin|风行,企业级前后端一体中后台框架:基于 GORM 从零实现新服务
后端·go·orm
2401_871260022 小时前
Java学习笔记(二)面向对象
java·python·学习