浅析并发工具类之ThreadLocal

2023年 10月 6日 37.6k 0

图片.png

前言

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

什么是ThreadLocal

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

ThreadLocal的使用场景

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

线程独享对象

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

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

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

两个线程使用SimpleDateFormat

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 对象,它们之间互不干扰,这段代码是可以正常运转的,运行结果是:

00:01

00:02

十个线程使用SimpleDateFormat

public class ThreadLocalDemo02 {

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

        for (int i = 0; i  {

                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);

    }

代码运行结果:

00:00

00:01

00:02

00:03

00:04

00:05

00:06

00:07

00:08

00:09

一千个线程使用SimpleDateFormat?

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

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对象是否可以?

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 对象的开销,看上去没有问题,我们用图形的方式把这件事情给表示出来:

图片.png

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

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

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

00:04

00:04

00:05

00:04

...

16:15

16:14

16:13

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

加锁

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

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 的调用给上了锁。

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

00:00

...

10:03

10:02

10:04
...

15:56

16:33

16:36

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

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

使用 ThreadLocal

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

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 dateFormatThreadLocal = new ThreadLocal() {

        @Override

        protected SimpleDateFormat initialValue() {

            return new SimpleDateFormat("mm:ss");

        }

    };

}

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

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

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之间的关系

图片.png

我们看到最左下角的 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 方法,源码如下所示:

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 方法,源码如下所示:

ThreadLocalMap getMap(Thread t) {

    return t.threadLocals;

}

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

ThreadLocal.ThreadLocalMap threadLocals = null;

set 方法

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

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 类:

static class ThreadLocalMap {

    static class Entry extends WeakReference> {

    /** 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 的强引用,还是刚才那段代码:

static class Entry extends WeakReference

相关文章

JavaScript2024新功能:Object.groupBy、正则表达式v标志
PHP trim 函数对多字节字符的使用和限制
新函数 json_validate() 、randomizer 类扩展…20 个PHP 8.3 新特性全面解析
使用HTMX为WordPress增效:如何在不使用复杂框架的情况下增强平台功能
为React 19做准备:WordPress 6.6用户指南
如何删除WordPress中的所有评论

发布评论