并发工具类库加持,线程安全真的万无一失?
开发过程中,当你满心欢喜地引入并发工具类库,以为就此给程序的线程安全上了 “双保险”,却在上线后遭遇诡异的线程错误,是不是会怀疑人生?
你本以为用 ConcurrentHashMap 完美替代了 HashMap,多线程下数据读写就能高枕无忧;用 ThreadLocal 来代替普通变量,轻轻松松就能实现线程间的数据隔离。
但现实却给你沉重一击,程序在高并发时状况百出。并发工具类库加持下,线程安全真的就万无一失了吗?
今天,咱们就来好好聊聊这背后隐藏的门道。
1. ThreadLocal 翻车现场:你的数据被“共享”了!
你以为用了 ThreadLocal,数据就安全了?Too young, too simple!
问题复现
前几天,同事接到了一个需求,在测试阶段,程序偶尔会出现用户会话信息错乱的情况。起初,我们都以为是代码逻辑出了问题,但仔细排查后才发现,原来是 ThreadLocal 和线程池“打架”了。
代码大概是这样的:
@RestController
@RequestMapping("/threadlocal")
public class ThreadLocalController {
private static final ThreadLocal<Integer> currentUser = ThreadLocal.withInitial(() -> null);
@GetMapping("/wrong")
public Map<String, String> wrong(@RequestParam("userId") Integer userId) {
// 设置用户信息之前先查询一次 ThreadLocal 中的用户信息
String before = Thread.currentThread().getName() + ":" + currentUser.get();
// 设置用户信息到 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;
}
}
按理说,在设置用户信息之前,第一次获取的值始终应该是 null,因为我们在 ThreadLocal 的初始化时已经明确指定了初始值为 null。
然而,我们却看到了意外的结果。问题的关键在于:程序运行在 Tomcat 中,而 Tomcat 的工作线程是基于线程池的。
为快速复现问题,我在配置文件里设置 Tomcat 参数,将工作线程池最大线程数设为 1,这样请求始终由同一线程处理。
server.tomcat.max-threads=1
我们可以通过以下步骤复现这个问题:
- 用户 1 请求接口:
- 用户 1 的 userId 是 1。
- 第一次获取 ThreadLocal 的值:null(因为线程是第一次被使用)。
- 设置 ThreadLocal 的值为 1。
- 第二次获取 ThreadLocal 的值:1。
用户 1 的返回结果:
2. 用户 2 请求接口:
- 用户 2 的 userId 是 2。
- 第一次获取 ThreadLocal 的值:1(因为线程复用了用户 1 的数据)。
- 设置 ThreadLocal 的值为 2。
- 第二次获取 ThreadLocal 的值:2。
用户 2 的返回结果:
问题的根本原因
问题的根本原因在于:线程池中的线程被复用时,ThreadLocal 中的数据没有被清理。
ThreadLocal 的设计初衷是为每个线程提供独立的数据副本,但它不会自动清理数据。如果线程被复用,而 ThreadLocal 中的数据没有被清理,后续请求就会读取到之前请求遗留的数据。
解决方案:清理 ThreadLocal 数据
为了避免这种问题,我们需要在每次请求结束时清理 ThreadLocal 中的数据。可以通过在 finally 块中调用 remove() 方法来实现。
修改后的代码如下:
@GetMapping("/right")
public Map<String, String> right(@RequestParam("userId") Integer userId) {
// 设置用户信息之前先查询一次 ThreadLocal 中的用户信息
String before = Thread.currentThread().getName() + ":" + currentUser.get();
// 设置用户信息到 ThreadLocal
currentUser.set(userId);
try {
// 设置用户信息之后再查询一次 ThreadLocal 中的用户信息
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();
}
}
重新运行程序可以验证,再也不会出现第一次查询用户信息查询到之前用户请求的 Bug:
小结
从这个例子能看出,咱们写业务代码,得先搞清楚代码会在啥线程上跑。
好多人可能会吐槽学多线程没啥用,觉得自己代码里又没主动开启多线程。但实际上,在 Tomcat 这种 Web 服务器里跑的业务代码,本来就是在多线程环境下运行的(不然接口咋支持高并发呢)。
所以啊,别以为没显式开启多线程,就不会有线程安全问题。
要知道,创建线程挺费资源的,所以 Web 服务器一般会用线程池来处理请求,这就导致线程会被重复使用。咱们用 ThreadLocal 存数据的时候,可得多留个心眼,代码跑完后,一定要显式地把存的数据清空。要是代码里用了自定义线程池,也会碰到同样的问题。
2. ConcurrentHashMap 的坑:你以为的线程安全并不安全
很多人认为,只要把 HashMap 换成 ConcurrentHashMap,就能解决所有的线程安全问题。
然而,事实并非如此。ConcurrentHashMap 虽然提供了线程安全的操作,但它并不能保证多个操作的组合是线程安全的。
问题场景
假设我们有一个需求:统计用户访问次数。我们使用 ConcurrentHashMap 来存储用户的访问次数,代码如下:
public class UserVisitCounter {
private static final ConcurrentHashMap<String, Integer> visitCountMap = new ConcurrentHashMap<>();
public void visit(String userId) {
Integer count = visitCountMap.get(userId);
if (count == null) {
visitCountMap.put(userId, 1);
} else {
visitCountMap.put(userId, count + 1);
}
}
public int getVisitCount(String userId) {
return visitCountMap.getOrDefault(userId, 0);
}
public static void main(String[] args) throws InterruptedException {
UserVisitCounter counter = new UserVisitCounter();
String userId = "user1";
// 创建 100 个线程,每个线程访问一次
ExecutorService executor = Executors.newFixedThreadPool(100);
for (int i = 0; i < 100; i++) {
executor.submit(() -> counter.visit(userId));
}
executor.shutdown();
executor.awaitTermination(1, TimeUnit.MINUTES);
// 输出访问次数
System.out.println("访问次数: " + counter.getVisitCount(userId));
}
}
- 预期结果:访问次数应该是 100。
- 实际结果:访问次数可能小于 100,比如 98、99 等等。
问题的根本原因
- 因为多个线程可能同时执行 get,拿到相同的 count 值,然后分别执行 put,导致最终的计数结果丢失。
- count + 1 不是原子指令。
解决方案
方案一:使用 AtomicInteger
我们可以用 ConcurrentHashMap<String, AtomicInteger>
来存储访问次数,利用 AtomicInteger 的原子操作来保证线程安全。
public class UserVisitCounter {
private static final ConcurrentHashMap<String, AtomicInteger> visitCountMap = new ConcurrentHashMap<>();
public void visit(String userId) {
visitCountMap.computeIfAbsent(userId, k -> new AtomicInteger(0)).incrementAndGet();
}
public int getVisitCount(String userId) {
return visitCountMap.getOrDefault(userId, new AtomicInteger(0)).get();
}
}
方案二:使用 compute 方法
ConcurrentHashMap 提供了 compute 方法,可以原子地更新值。
public class UserVisitCounter {
private static final ConcurrentHashMap<String, Integer> visitCountMap = new ConcurrentHashMap<>();
public void visit(String userId) {
visitCountMap.compute(userId, (k, v) -> v == null ? 1 : v + 1);
}
public int getVisitCount(String userId) {
return visitCountMap.getOrDefault(userId, 0);
}
}
小结
- ConcurrentHashMap 并不能解决所有线程安全问题:它只能保证单个操作的线程安全,无法保证多个操作的组合是线程安全的。
- 多操作组合需要额外的同步机制:比如使用 AtomicInteger 或 compute 方法。
记住: 线程安全无小事,谨慎使用并发工具类库!
最后,我最近弄了一个Java技术交流群,讨论面试、后端等领域知识,如果你感兴趣,欢迎关注我公众号回复【1】,我拉你入群哈~(我的公众号:程序员徐述)
目前已经有近 100 人加入。如果你已经在群里,请忽略~
综上,希望今天这篇文章对你帮助!