baeldung 2025-02-15
1. 概述
在本教程中,我们将学习 java.lang 包中的 ThreadLocal 构造。它使我们能够为当前线程单独存储数据,并将其封装在一个特殊类型的对象中。
2. ThreadLocal API
ThreadLocal 构造允许我们存储仅由特定线程访问的数据。
假设我们希望有一个与特定线程绑定的 Integer 值:
ThreadLocal<Integer> threadLocalValue = new ThreadLocal<>();
当我们想从某个线程中使用该值时,只需调用 get() 或 set() 方法即可。简单来说,我们可以将 ThreadLocal 想象成一个以线程为键(key)的内部 Map。
因此,当我们对 threadLocalValue 调用 get() 方法时,会获取到当前请求线程对应的 Integer 值:
threadLocalValue.set(1);
Integer result = threadLocalValue.get();
我们也可以通过 withInitial() 静态方法并传入一个 Supplier 来构造 ThreadLocal 实例:
ThreadLocal<Integer> threadLocal = ThreadLocal.withInitial(() -> 1);
要从 ThreadLocal 中移除值,可以调用 remove() 方法:
threadLocal.remove();
为了更好地理解如何正确使用 ThreadLocal,我们首先看一个不使用 ThreadLocal 的示例,然后将其重写为利用 ThreadLocal 的版本。
3. 在 Map 中存储用户数据
考虑一个需要为每个用户 ID 存储特定用户上下文(Context)数据的程序:
public class Context {
private String userName;
public Context(String userName) {
this.userName = userName;
}
}
我们希望每个用户 ID 对应一个线程。为此,我们创建一个实现了 Runnable 接口的 SharedMapWithUserContext 类。其 run() 方法通过 UserRepository 类调用数据库,根据给定的 userId 返回一个 Context 对象。
然后,我们将该上下文存储在一个以 userId 为键的 ConcurrentHashMap 中:
public class SharedMapWithUserContext implements Runnable {
public static Map<Integer, Context> userContextPerUserId
= new ConcurrentHashMap<>();
private Integer userId;
private UserRepository userRepository = new UserRepository();
@Override
public void run() {
String userName = userRepository.getUserNameForUserId(userId);
userContextPerUserId.put(userId, new Context(userName));
}
// 标准构造函数
}
我们可以通过创建并启动两个不同 userId 的线程来轻松测试这段代码,并断言 userContextPerUserId 映射中有两条记录:
SharedMapWithUserContext firstUser = new SharedMapWithUserContext(1);
SharedMapWithUserContext secondUser = new SharedMapWithUserContext(2);
new Thread(firstUser).start();
new Thread(secondUser).start();
assertEquals(SharedMapWithUserContext.userContextPerUserId.size(), 2);
4. 在 ThreadLocal 中存储用户数据
我们可以使用共享的 ThreadLocal 实例重写上述示例。每个线程都会在 ThreadLocal 对象中拥有自己的 Context。
使用 ThreadLocal 时必须格外小心,因为存储在其中的每个对象都与特定线程相关联。在我们的示例中,每个 userId 都有我们自己创建的专用线程,因此我们对其拥有完全控制权。
run() 方法将获取用户上下文,并使用 set() 方法将其存入 ThreadLocal 变量中:
public class ThreadLocalWithUserContext implements Runnable {
private static ThreadLocal<Context> userContext
= new ThreadLocal<>();
private Integer userId;
private UserRepository userRepository = new UserRepository();
@Override
public void run() {
String userName = userRepository.getUserNameForUserId(userId);
userContext.set(new Context(userName));
System.out.println("thread context for given userId: "
+ userId + " is: " + userContext.get());
}
// 标准构造函数
}
我们可以通过启动两个线程分别执行对应 userId 的操作来测试它:
ThreadLocalWithUserContext firstUser
= new ThreadLocalWithUserContext(1);
ThreadLocalWithUserContext secondUser
= new ThreadLocalWithUserContext(2);
new Thread(firstUser).start();
new Thread(secondUser).start();
运行此代码后,标准输出将显示每个线程都设置了各自的 ThreadLocal:
thread context for given userId: 1 is: Context{userNameSecret='18a78f8e-24d2-4abf-91d6-79eaa198123f'}
thread context for given userId: 2 is: Context{userNameSecret='e19f6a0a-253e-423e-8b2b-bca1f471ae5c'}
可以看到,每个用户都有其独立的 Context。
5. ThreadLocal 与线程池
ThreadLocal 提供了一个易于使用的 API,用于将某些值限定在每个线程内。这是在 Java 中实现线程安全的一种合理方式。然而,当我们将 ThreadLocal 与线程池一起使用时,必须格外小心。
为了更好地理解这一潜在问题,考虑以下场景:
- 应用程序从线程池中借出一个线程。
- 将某些线程专属的值存入当前线程的
ThreadLocal。 - 当前任务执行完毕后,应用程序将线程归还给线程池。
- 一段时间后,应用程序再次借出同一个线程处理另一个请求。
- 由于上次未执行必要的清理操作,新请求可能会复用旧的
ThreadLocal数据。
这在高并发应用中可能导致意外后果。
一种解决方案是在使用完 ThreadLocal 后手动调用 remove() 方法。但由于这种方法依赖严格的代码审查,容易出错。
5.1 扩展 ThreadPoolExecutor
实际上,我们可以扩展 ThreadPoolExecutor 类,并为 beforeExecute() 和 afterExecute() 方法提供自定义钩子实现。线程池会在使用借出的线程执行任务前调用 beforeExecute() 方法,在执行完我们的逻辑后调用 afterExecute() 方法。
因此,我们可以在 afterExecute() 方法中清除 ThreadLocal 数据:
public class ThreadLocalAwareThreadPool extends ThreadPoolExecutor {
@Override
protected void afterExecute(Runnable r, Throwable t) {
// 对每个 ThreadLocal 调用 remove()
}
}
如果我们向这个 ExecutorService 的实现提交任务,就可以确保 ThreadLocal 与线程池结合使用时不会引入安全隐患。
6. 结论
在本文中,我们探讨了 ThreadLocal 构造。我们首先实现了一个使用 ConcurrentHashMap 在多个线程之间共享、并按特定 userId 存储上下文的逻辑;随后,我们将其重写为使用 ThreadLocal 来存储与特定 userId 和特定线程关联的数据。