并发编程 | 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. 慕课网
相关推荐
魔道不误砍柴功39 分钟前
Java 中如何巧妙应用 Function 让方法复用性更强
java·开发语言·python
NiNg_1_23439 分钟前
SpringBoot整合SpringSecurity实现密码加密解密、登录认证退出功能
java·spring boot·后端
闲晨42 分钟前
C++ 继承:代码传承的魔法棒,开启奇幻编程之旅
java·c语言·开发语言·c++·经验分享
Chrikk2 小时前
Go-性能调优实战案例
开发语言·后端·golang
幼儿园老大*2 小时前
Go的环境搭建以及GoLand安装教程
开发语言·经验分享·后端·golang·go
canyuemanyue2 小时前
go语言连续监控事件并回调处理
开发语言·后端·golang
杜杜的man2 小时前
【go从零单排】go语言中的指针
开发语言·后端·golang
测开小菜鸟2 小时前
使用python向钉钉群聊发送消息
java·python·钉钉
P.H. Infinity3 小时前
【RabbitMQ】04-发送者可靠性
java·rabbitmq·java-rabbitmq
生命几十年3万天3 小时前
java的threadlocal为何内存泄漏
java