掘金 后端 ( ) • 2024-04-18 14:01

需求背景

假设我们正在开发一个基于微服务架构的在线服务平台,该平台提供了用户认证、数据存取、业务处理等功能。在这个平台中,用户通过登录流程获取一个认证Token,该Token用于在后续的请求中验证用户身份。为了保证Token的安全性和隔离性,我们需要一种机制来确保每个用户请求对应的Token只在处理该请求的线程中有效,并且不会被其他线程访问或篡改。

为了解决这个问题,我们可以利用ThreadLocal的特性来创建一个线程封闭的存储空间,专门用于保存每个用户的Token。这篇文章就是介绍一下ThreadLocal的一些知识

解决方法

ThreadLocal 是 Java 中用于线程封闭的类,它允许线程创建私有变量副本,确保线程间互不影响。这一特性对于实现线程安全极为有效,因为每个线程只能访问自己的变量。

ThreadLocal 的常见用途包括:

  • 存储线程相关的数据(例如,在一个 Web 应用程序中存储用户的 ID 或事务 ID)。
  • 避免线程间共享资源的开销,例如使用 SimpleDateFormat 处理日期。

当我们在代码中声明一个 ThreadLocal 变量时,实际上它的值被保存在当前线程的 ThreadLocalMap 中。ThreadLocalMapThreadLocal 的一个内部私有类,可以看作是一个以 ThreadLocal 对象的弱引用为键、以实际存储的用户值为值的映射。

关于ThreadLocalMap

ThreadLocalMap 使用了弱引用作为键(key),,值(value)强引用。这意味着一旦外部没有强引用指向 ThreadLocal 实例,就可能会被垃圾回收器回收。在这种情况下,对应的键值会变成 null。如果 ThreadLocalMap 的条目的键是 null,那么这个条目也就无法访问到了,而与之关联的值则可能继续占用内存,这会导致内存泄漏。

为什么使用弱引用?

在JDK中,ThreadLocalMap使用弱引用作为键的原因是为了解决内存泄漏的问题,特别是在ThreadLocal对象不再被使用时。弱引用提供了一种机制,当ThreadLocal对象不再有其他强引用时,它能够被垃圾回收器回收,即使它仍然被存储在Thread对象的ThreadLocalMap中。

怎么解决内存泄漏的问题?

  1. 手动清除:最直接的方法是不再使用时调用 ThreadLocalremove() 方法来删除对应线程的值。这个方法会从 ThreadLocalMap 中清除当前线程的值。
threadLocal.remove();

这个方法可以让你知道 ThreadLocal 不再需要时调用,比如在 HttpServletRequest 的处理过程结束后。

  1. 使用完毕尽快清理:在使用 ThreadLocal 变量进行线程间操作完毕之后,尽可能地在代码的 finally 块中调用 remove() 方法,避免因为异常导致的没有清理 ThreadLocal 变量。
  2. JVM 自身的清理机制:虽然 Java 虚拟机会在键对象被回收时将键设置为 null,但 ThreadLocalMap 也实现了自己的一套防泄漏机制。ThreadLocalMapget()set()remove() 方法都会清理已经变为 null 的键的条目。这种清除工作是在正常的 ThreadLocal 操作过程中顺带完成的。

然而,如果一个线程生命周期很长,并且不再访问 ThreadLocal,这种清理机制可能无法运作,因为没有进一步的 ThreadLocal 方法调用来触发清理过程,这可能会导致内存泄漏。因此,手动调用 remove() 方法总是一种更安全的做法。

下面是一个 Java 示例代码,演示了如何使用两个 ThreadLocal 变量,以及如何在使用它们时清理资源以防止内存泄漏:

public class ThreadLocalExample {

     // 创建两个 thread-local 变量
    private static final ThreadLocal<String threadLocalVar1 = new ThreadLocal<>();
    private static final ThreadLocal<Integer threadLocalVar2 = new ThreadLocal<>();

    public static void main(String[] args) throws InterruptedException {
         // 模拟多个线程使用 thread-local 变量
        for (int i = 0; i < 3; i++) {
            int finalI = i;
            new Thread(() -> {
                try {
                     // 每个线程设置 thread-local 变量
                    threadLocalVar1.set("Thread-" + finalI + " data for var1");
                    threadLocalVar2.set(finalI);

                     // 使用 thread-local 变量
                    System.out.println(Thread.currentThread().getName()
                                       + " value: " + threadLocalVar1.get()
                                       + " / " + threadLocalVar2.get());
                } finally {
                     // 必须在最后清理 thread-local 变量以避免内存泄漏
                    threadLocalVar1.remove();
                    threadLocalVar2.remove();
                }
            }).start();
        }
    }
}

在这个示例中,我们定义了两个 ThreadLocal 变量:threadLocalVar1threadLocalVar2。每个线程都会设置它们的值,然后输出这些值,以证明每个线程都有自己的独立拷贝。

请注意在 finally 代码块中,我们调用了 remove() 方法。这是非常重要的一步,因为它确保了不再使用的 ThreadLocal 变量能够被垃圾收集器清理,从而防止了潜在的内存泄漏。

每个线程结束后,在 finally 块中调用 remove() 确保 ThreadLocal 存储的值被移除,这样即便 ThreadLocal 的实例被回收,ThreadLocalMap 中也不会留下无用的值。

日期格式化工具 SimpleDateFormat的实践

SimpleDateFormat 类在 Java 中用于日期的解析与格式化,但它不是线程安全的。意味着在多线程环境下直接使用同一个 SimpleDateFormat 实例时,可能会产生并发问题,比如解析错误或者不正确的日期格式。为了避免这个问题,传统的方法是在每次需要的时候创建一个新的 SimpleDateFormat 实例,但这样做在高并发的场景下会造成性能问题,因为对象的创建和销毁是有成本的。

使用 ThreadLocal<SimpleDateFormat> 可以为每个线程创建一个单独的 SimpleDateFormat 实例,这样每个线程都可以安全地使用这个实例而不用担心并发问题。这样,不仅避免了并发问题,还通过减少对象创建和垃圾回收的次数来提高了性能。

下面是如何使用 ThreadLocal<SimpleDateFormat> 的代码示例:

import java.text.SimpleDateFormat;
import java.util.Date;

public class DateFormatExample {

    // 使用 ThreadLocal 为每个线程创建一个 SimpleDateFormat 实例
    private static final ThreadLocal<SimpleDateFormat dateFormatThreadLocal = 
        new ThreadLocal<SimpleDateFormat() {
        @Override
        protected SimpleDateFormat initialValue() {
            // 这个方法将为每个访问这个 ThreadLocal 的线程返回一个新的 SimpleDateFormat 实例
            return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
        }
    };

    // 方法,返回当前线程的日期格式化
    public static String formatDate(Date date) {
        return dateFormatThreadLocal.get().format(date);
    }

    // 线程调用该方法
    public static void main(String[] args) {
        for (int i = 0; i < 5; i++) {
            new Thread(() -> {
                String dateStr = formatDate(new Date());
                System.out.println(Thread.currentThread().getName() + " formatted date: " + dateStr);
            }).start();
        }
    }
}

在这个例子中,我们使用 ThreadLocal 创建了一个 SimpleDateFormat 的线程局部变量,initialValue() 方法保证了每个线程调用 get() 方法时,如果没有自己的 SimpleDateFormat,就会创建一个新的实例。所有的线程都会使用 formatDate() 方法来格式化日期,这会使用到 ThreadLocal 存储的 SimpleDateFormat 实例,确保了线程安全并且减少了对象创建的开销。eadLocalMap对象变得无法访问,从而使内部的SimpleDateFormat` 实例成为垃圾回收的目标。