并发编程 | ThreadLocal - 线程的私有存储

引言

在处理复杂的并发问题时,我们经常需要面对多线程环境中数据一致性和线程安全的挑战。其中一种常见的解决方式就是使用锁或者其他同步机制,但是这往往会导致性能的下降。那么有没有一种方法,可以在保证线程安全的同时,又不会降低程序的性能呢?答案就是Java中的ThreadLocal。ThreadLocal为每个线程都提供了一份独立的变量副本,使得每个线程在使用该变量时,实际上都在操作自己的局部变量。这样既解决了多线程环境下的线程安全问题,又避免了同步带来的性能开销。在本篇博客中,我们将一起探索ThreadLocal的内部工作原理,以及如何在实际应用中正确地使用ThreadLocal来提高我们程序的性能和稳定性。


正如我们在 并发编程 | 线程安全-编写零错误代码 - 掘金 (juejin.cn) 所探讨的,为了在编程实践中更为安全、高效地利用并发技术,通常需要在设计和编码过程中纳入以下三种策略来进行考虑:互斥同步、非阻塞同步以及无同步。

我们来重新回顾下这三个方案:

  1. 互斥同步的方法主要通过使用锁或者其他同步机制,来保证任何时刻只有一个线程可以访问共享数据,从而避免了数据竞态和不一致性的问题。
  2. 非阻塞同步方案则主要利用了原子操作,使得我们能在无需使用互斥锁的情况下实现线程间的同步,进而提高了系统的整体性能。
  3. 无同步方案是最极端的一种,它适用于那些可以容忍一定程度的数据不一致性的场景,通过完全避免同步操作来最大化性能。

带着这个问题往下思考,ThreadLocal属于哪个方案?

基础 | 理解ThreadLocal

什么是ThreadLocal

ThreadLocal是Java中的一个类,用于创建线程本地变量。每个线程都会创建一个独立的变量副本,每个副本对其他线程都是隔离的。这样,数据在每个线程间都不会互相影响,从而实现了线程安全的数据共享。

ThreadLocal的基本使用

在我开始详细解释ThreadLocal的基本使用方法之前,我先为你展示一段相关的代码,具体如下:

java 复制代码
    public static ExecutorService threadPool = Executors.newFixedThreadPool(10);
    static SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");

    public static void main(String[] args) throws InterruptedException {
        long start = System.currentTimeMillis();
        for (int i = 0; i < 1000; i++) {
            int finalI = i;
            threadPool.execute(() -> {
                String date = date(finalI);
                System.out.println(date);
            });
        }
        threadPool.shutdown();
        try {
            if (!threadPool.awaitTermination(60, TimeUnit.SECONDS)) {
                threadPool.shutdownNow();
            }
        } catch (InterruptedException e) {
            threadPool.shutdownNow();
            Thread.currentThread().interrupt();
        }
        long end = System.currentTimeMillis();
        System.out.println("花费:" + (end - start) + "ms");
    }

    public static String date(int seconds) {
        //参数的单位是毫秒,从1970.1.1 00:00:00 GMT计时
        Date date = new Date(1000 * seconds);
        return dateFormat.format(date);
    }

我创建了一个线程池,并提交了1000个任务,让其对时间+I。现在暂停思考一下,你觉得会出现什么问题?好,我们来公布答案:

java 复制代码
Connected to the target VM, address: '127.0.0.1:7250', transport: 'socket'
1970-01-01 08:00:03
1970-01-01 08:00:03
1970-01-01 08:00:03
1970-01-01 08:00:04
1970-01-01 08:00:05
....
花费:30ms

数据重复了,为什么?这个问题的原因是,SimpleDateFormat 内部有一个共享的 Calendar 对象,当调用 format() parse() 方法时,它会更改这个对象的状态。如果同时有多个线程修改这个状态,就可能会得到错误的结果。现在,我们来解决这个问题。首先想到的解决办法就是,既然SimpleDateFormat线程不安全 ,那我们把它弄成局部变量不就好了。我们改下代码,把全局变量dateFormat放到date()方法里面,代码如下:

java 复制代码
    public static String date(int seconds) {
        //参数的单位是毫秒,从1970.1.1 00:00:00 GMT计时
        Date date = new Date(1000 * seconds);
        SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
        return dateFormat.format(date);
    }

好,运行,结果如下:

java 复制代码
Connected to the target VM, address: '127.0.0.1:7950', transport: 'socket'
1970-01-01 08:00:01
1970-01-01 08:00:05
1970-01-01 08:00:04
1970-01-01 08:00:09
...
花费:57ms

果然没问题。但是慢了一倍,原因是啥? 因为我们把dateFormat放到date()里面,等于说1000个线程创建了1000次对象,自然就慢了。还有没有别的办法?有,JDK8引入了DateTimeFormatter,是线程安全的,而且功能更强大,易用性更好。我们来看下代码:

java 复制代码
    static DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
	// ...略
    public static String date(int seconds) {
        return Instant.ofEpochSecond(seconds).atZone(ZoneId.systemDefault()).format(dateTimeFormatter);
    }

我们来执行一下:

java 复制代码
Connected to the target VM, address: '127.0.0.1:9637', transport: 'socket'
1970-01-01 08:00:04
1970-01-01 08:00:01
1970-01-01 08:00:07
1970-01-01 08:00:02
...
花费:65ms

问题倒是没问题....就是更慢了。还有没有别的方案?这时候就请出我们今天的主角ThreadLocal。代码如下:

java 复制代码
    static ThreadLocal<SimpleDateFormat> dateFormatThreadLocal = ThreadLocal.withInitial(() ->
            new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));
    // ...略
    
    public static String date(int seconds) {
        //参数的单位是毫秒,从1970.1.1 00:00:00 GMT计时
        Date date = new Date(1000 * seconds);
        SimpleDateFormat dateFormat = dateFormatThreadLocal.get();
        return dateFormat.format(date);
    }

执行!

java 复制代码
Connected to the target VM, address: '127.0.0.1:9070', transport: 'socket'
1970-01-01 08:00:02
1970-01-01 08:00:05
1970-01-01 08:00:08
1970-01-01 08:00:04
...
花费:31ms

时间又回到30ms左右了。至此圆满结束。

好了,回到上面的问题,你是不是已经有答案了?我们来公布答案。ThreadLocal是一种避免共享的设计模式,它是一种无同步方案。其实把dateFormat写在date()里面也是无同步方案,只不过,它叫另一种名字------栈封闭

工作场景总不会用的这么简单吧?那我宁愿使用Java8的新特性。别急,我们接着往下看

ThreadLocal的常见使用场景

  1. 在复杂的链式调用中传递参数:工作中可能会遇到很长的调用链,例如:在A->B->C->D的调用链中,D需要使用A的参数,而为了避免一层层传递参数,可以使用ThreadLocal将参数存储在A中,然后在D中取出使用。
  2. 为每个线程生成独立的随机数或者其他类似的资源:在多线程编程中,可能需要为每个线程生成独享的对象,避免多线程下的竞争条件。在上面的例子中,就是给每个线程携带dateFormat。

你现在是不是有掌握工具的喜悦感?保持兴奋感,我们接着往下看

进阶 | 熟练ThreadLocal

ThreadLocal的内部实现机制

掌握一个工具的秘密,就要深入其内部。让我们一起揭开ThreadLocal的工作原理,让你编程更得心应手。

首先,我们先来回答两个关键的问题:

  1. 为什么ThreadLocal要用ThreadLocalMap来存储ThreadLocal对象?:因为一个Thread可能持有多个ThreadLocal。
  2. 为什么ThreadLocalMap由Thread持有?:在 Java 的实现方案里面,ThreadLocal 仅仅是一个代理工具类,内部并不持有任何与线程相关的数据,所有和线程相关的数据都存储在 Thread 里面,这样的设计容易理解。而从数据的亲缘性上来讲,ThreadLocalMap 属于 Thread 也更加合理。当然,主要目的是实现线程间数据的隔离,保证线程安全。每个线程拥有自己的ThreadLocalMap,互不干扰。这样的设计也便于线程结束后,垃圾回收器对ThreadLocalMap的回收,防止内存泄漏。

带着这两个问题,我们继续往下看

ThreadLocal的一些重要方法

首先是initialValue()方法:

java 复制代码
    protected T initialValue() {
        return null;
    }
  1. 这个方法会返回当前线程的初始值,它是一个延迟加载的方法,只有调用get()才会触发。
  2. 当线程第一次使用get()方法访问变量时,将调用此方法,除非线程先前调用了set方法,在这种情况下,不会为线程调用本initialValue方法。
  3. 通常,每个线程最多调用一次此方法,但如果已经调用了remove()后,再调用get(),则可以再次调用此方法。
  4. 如果不重写本方法,这个方法会返回null。一般使用匿名内部类的方法来重写initialValue0方法,以便在后续使用中可以初始化副本对象。

接下来我们来看set()方法。这个方法相对直接,其主要功能就是向内部赋予value值。然后,我们回过头来重新审视get()方法。我们前面已经提到,get()方法在执行之前会先调用initialValue()方法。最后,我们讨论remove()方法,这个方法的主要职责是清除ThreadLocal对应的value值。

接下来我们来看下源码

高级 | 掌握ThreadLocal

ThreadLocal源码分析

首先是get()方法:

java 复制代码
public T get() {
    // 获取当前执行的线程
    Thread t = Thread.currentThread();

    // 从当前线程中获取其持有的ThreadLocalMap
    ThreadLocalMap map = getMap(t);

    // 检查当前线程是否已有ThreadLocalMap
    if (map != null) {
        // 从ThreadLocalMap中获取与当前ThreadLocal关联的Entry
        ThreadLocalMap.Entry e = map.getEntry(this);

        // 检查Entry是否存在
        if (e != null) {
            // 存在则返回Entry中的值
            @SuppressWarnings("unchecked")
            T result = (T)e.value;
            return result;
        }
    }

    // 如果当前线程的ThreadLocalMap不存在或者没有当前ThreadLocal的值,初始化一个值并返回
    return setInitialValue();
}

代码已经附有详细注释,如果你对此感兴趣,可以进行阅读。同时,remove()set()函数的实现也已展示,具体代码如下:

java 复制代码
public void set(T value) {
    // 获取当前执行的线程
    Thread t = Thread.currentThread();

    // 从当前线程中获取其持有的ThreadLocalMap
    ThreadLocalMap map = getMap(t);

    // 检查当前线程是否已有ThreadLocalMap
    if (map != null) {
        // 如果存在,将传入的值存入与当前ThreadLocal关联的Entry中
        map.set(this, value);
    } else {
        // 如果不存在,创建一个新的ThreadLocalMap,并存入当前线程与传入值的映射关系
        createMap(t, value);
    }
}


public void remove() {
    // 获取当前执行的线程
    Thread t = Thread.currentThread();

    // 从当前线程中获取其持有的ThreadLocalMap
    ThreadLocalMap m = getMap(t);

    // 检查当前线程是否已有ThreadLocalMap
    if (m != null) {
        // 如果存在,从ThreadLocalMap中移除与当前ThreadLocal关联的Entry
        m.remove(this);
    }
}

代码分析也告一段落了,是不是很简单。接下来我们根据所掌握的代码分析下面两个问题。

使用ThreadLocal的陷阱和注意事项

内存泄露问题

ThreadLocal是一个容器,它存储了线程局部变量,但是这个存储的线程局部变量对于线程来说是长期存在的,除非这个线程被销毁,否则这个变量将一直存在。在长时间运行的线程中,如果ThreadLocal被大量使用,而又没有被正确清理,就可能导致内存泄露问题。 在Java中,ThreadLocal的实现用的是线程的弱引用,而不是强引用,所以理论上不会出现内存泄漏。然而,如果我们在ThreadLocal中放入的对象是强引用对象,并且我们没有手动调用ThreadLocal的remove方法,那么ThreadLocal中的这个对象就有可能一直存在,即使ThreadLocal本身已经不存在了。这就可能导致内存泄露。

空指针异常

ThreadLocal为每个线程都保存了一个独立的变量副本,如果我们试图获取一个没有初始化的ThreadLocal变量,就可能引发空指针异常。在使用ThreadLocal时,我们通常会在ThreadLocal变量首次使用前调用ThreadLocal的initialValue()方法进行初始化,但如果我们忘记了这个步骤,或者在初始化后误删了变量,再试图访问它,就可能抛出NullPointerException。

注意事项总结

  1. 尽量在不使用ThreadLocal变量时调用其remove()方法,将其清除,避免可能的内存泄露。
  2. 在使用ThreadLocal变量前,确保已经进行了初始化,以避免空指针异常。
  3. 适当的设计和使用ThreadLocal变量,避免在不必要的情况下长期存储大对象,以降低内存压力。

结论

好,我们来做个总结。本章,我带你了解了究竟什么是ThreadLocal,并通过一个案例动手实践优化了线程不安全的代码,最终性能优于syncronized关键字。紧接着我为你讲解了其工作机制。并通过源码带你深入了解其背后的运行机制。最后基于源码回答了ThreadLocal最经常出现的两个问题。

附录:相关资源和进一步阅读

  1. Java并发编程实战
  2. 慕课网
相关推荐
一只叫煤球的猫2 小时前
写代码很6,面试秒变菜鸟?不卖课,面试官视角走心探讨
前端·后端·面试
bobz9652 小时前
tcp/ip 中的多路复用
后端
bobz9652 小时前
tls ingress 简单记录
后端
皮皮林5514 小时前
IDEA 源码阅读利器,你居然还不会?
java·intellij idea
你的人类朋友4 小时前
什么是OpenSSL
后端·安全·程序员
bobz9654 小时前
mcp 直接操作浏览器
后端
前端小张同学6 小时前
服务器部署 gitlab 占用空间太大怎么办,优化思路。
后端
databook6 小时前
Manim实现闪光轨迹特效
后端·python·动效
武子康7 小时前
大数据-98 Spark 从 DStream 到 Structured Streaming:Spark 实时计算的演进
大数据·后端·spark
该用户已不存在7 小时前
6个值得收藏的.NET ORM 框架
前端·后端·.net