文章目录
- Pre
- 导读
- 问题背景
- 问题重现
- 存在Bug的代码
- BUG现象
- 问题分析
- 解决方案
- 修正后的代码
- 修正后的现象
- ThreadLocal 的正确使用
- 小结
Pre
并发编程-11线程安全策略之线程封闭
Spring JDBC-Spring事务管理之ThreadLocal基础知识
每日一博 - ThreadLocal VS InheritableThreadLocal VS TransmittableThreadLocal
Java Review - 线程池中使用ThreadLocal不当导致的内存泄漏案例&源码分析
J.U.C Review - ThreadLocal原理源码分析
Java Review - 并发编程_ThreadLocalRandom实现原理&源码分析
导读
- 问题背景:利用
ThreadLocal
缓存用户信息的代码在生产环境下出现用户信息错乱。 - 问题分析:由于 Tomcat 使用线程池处理请求,导致线程重用时
ThreadLocal
的值未及时清理。 - 关键点:理解线程池如何影响
ThreadLocal
数据隔离,并明确如何在多线程环境下正确使用它。 - 解决方案:通过
finally
块确保在请求结束后移除数据,避免数据污染。
问题背景
使用 Spring Boot
创建一个 Web 应用程序,使用 ThreadLocal
存放一个 Integer
的值,
来暂且代表需要在线程中保存的用户信息,这个值初始是 null
。
问题重现
业务逻辑: 在业务逻辑中,先从ThreadLocal
获取一次值,然后把外部传入的参数设置到 ThreadLocal
中,来模拟从当前上下文获取到用户信息的逻辑,随后再获取一次值,最后输出两次获得的值和线程名称。
存在Bug的代码
private ThreadLocal<Integer> currentUser = ThreadLocal.withInitial(() -> null);
@GetMapping("/badDemo")
public Map badDemo(@RequestParam("userId") Integer userId) {
// 第一次获取ThreadLocal中的用户信息
String before = Thread.currentThread().getName() + ":" + currentUser.get();
// 将当前请求的用户ID设置到ThreadLocal中
currentUser.set(userId);
// 第二次获取ThreadLocal中的用户信息
String after = Thread.currentThread().getName() + ":" + currentUser.get();
Map<String, String> result = new HashMap<>();
result.put("before", before);
result.put("after", after);
return result;
}
为了更快地重现这个问题,在配置文件中设置一下 Tomcat 的参数,把工作线程池最大线程数设置为 1,这样始终是同一个线程在处理请求 server.tomcat.max-threads=1
BUG现象
- 第一次请求(用户1):
before: http-nio-8080-exec-1:null after: http-nio-8080-exec-1:1
- 第二次请求(用户2):
before: http-nio-8080-exec-1:1 after: http-nio-8080-exec-1:2
问题:用户2的请求读取到了用户1的用户ID,说明ThreadLocal
中的数据未被清除。
按理说,在设置用户信息之前第一次获取的值始终应该是 null,但我们要意识到,程序运
行在 Tomcat 中,执行程序的线程是 Tomcat 的工作线程,而 Tomcat 的工作线程是基于
线程池的
线程池会重用固定的几个线程,一旦线程重用,那么很可能首次从 ThreadLocal 获取的值是之前其他用户的请求遗留的值。这时,ThreadLocal 中的用户信息就是其他用户的信息
问题分析
ThreadLocal
的设计初衷是确保每个线程都有自己独立的变量副本。然而,当 ThreadLocal
与线程池结合使用时,需要特别小心线程重用的问题。
- Tomcat线程池:Tomcat 使用固定线程池来处理并发请求,避免频繁创建和销毁线程。线程执行完一次请求后会被重用处理新的请求。
- ThreadLocal 的问题:当线程处理完请求后,如果没有及时调用
remove()
方法清理数据,后续请求重用该线程时,可能会读取到之前请求遗留的数据。
解决方案
在 finally 块中调用 remove()
确保线程重用时不会带有上一次请求的残留数据。
修正后的代码
@GetMapping("right")
public Map right(@RequestParam("userId") Integer userId) {
String before = Thread.currentThread().getName() + ":" + currentUser.get();
currentUser.set(userId);
try {
String after = Thread.currentThread().getName() + ":" + currentUser.get();
Map<String, String> result = new HashMap<>();
result.put("before", before);
result.put("after", after);
return result;
} finally {
// 确保请求结束后清理ThreadLocal中的数据
currentUser.remove();
}
}
修正后的现象
- 第一次请求(用户1):
before: http-nio-8080-exec-1:null after: http-nio-8080-exec-1:1
- 第二次请求(用户2):
before: http-nio-8080-exec-1:null after: http-nio-8080-exec-1:2
ThreadLocal 的正确使用
- 及时清理:在使用
ThreadLocal
时,无论请求是否正常完成,都需要在finally
块中调用remove()
清理数据。 - 线程池环境注意事项:因为线程池中的线程会被多次重用,所以不适合将请求级数据长期存储在
ThreadLocal
中。 - 线程安全容器的使用:
如果确实需要在多个线程之间共享数据,可以使用 Java 并发包中的线程安全容器,如:ConcurrentHashMap
:适用于需要高效读写的场景。CopyOnWriteArrayList
:适合读多写少的场景。
小结
- 问题根因:
ThreadLocal
数据未及时清理,导致线程重用时数据错乱。 - 修正方案:在
finally
块中调用remove()
方法清理数据,确保每次请求都是干净的上下文。 - 最佳实践:在 Web 应用中,谨慎使用
ThreadLocal
,并在需要共享数据时选择合适的线程安全容器。
教训: 只知道使用并发工具,但并不清楚当前线程的来龙去脉,解决多线程问题却不了解线
程。比如,使用ThreadLocal
来缓存数据,以为ThreadLocal
在线程之间做了隔离不会有线程安全问题,没想到线程重用导致数据串了。
请务必记得,在业务逻辑结束之前清理ThreadLocal 中的数据。