线程重用导致用户信息错乱
Java并发包的引入,可以非常方便的帮助开发者避免一些多线程情况下的线程安全问题。那我们在使用Java并发工具时,就真的可以高枕无忧了吗?
然而现实总是残酷的,我们依旧会遇到一些坑。这里将带领大家一起扒开这些坑去看一看……
线程重用导致用户信息错乱
我们常常使用ThreadLocal来实现同一线程内的变量共享,通常这些资源是比较昂贵的,比如说用户信息。但美好设想背后可能也会有意想不到的陷阱。
下面我们采用一个具体的示例来看看这个陷阱。
下面这段代码使用了一个ThreadLocal存放Integer类型的用户id,它的初始值为null。业务逻辑是先从当前线程变量中获取值连同线程id一并记录下来,然后再修改变量再记录,最后将记录返回放到一个map中返回。
private static final ThreadLocal<Integer> currentUser = ThreadLocal.withInitial(() -> null);
public Map<String, Object> exec(Integer userId) {
// 设置用户信息之前先查询一次ThreadLocal中的用户信息
String before = Thread.currentThread().getName() + ":" + currentUser.get();
// 设置用户信息到ThreadLocal
currentUser.set(userId);
// 设置用户信息之后再查询一次ThreadLocal中的用户信息
String after = Thread.currentThread().getName() + ":" + currentUser.get();
// 汇总输出两次查询结果
Map<String, Object> result = new HashMap<>();
result.put("before", before);
result.put("after", after);
return result;
}
如果是不同线程访问这段程序,通常来讲第一次取到的id一定是null值。但如果我们使用线程池来访问呢?
大家都了解,线程池一般会设定核心线程和最大线程,通常有几个固定线程资源。那一旦发生线程复用,后面的访问可能取到的ThreadLocal的值就不是我们预想的那样了。
接下来我们编写一个测试demo来看看:
@Test
public void exec() {
// 创建一个固定线程数为1的线程池
ExecutorService threadPool = Executors.newFixedThreadPool(1);
List<Object> result = new ArrayList<>(2);
for (int i = 0; i < 2; i++) {
threadPool.execute(() -> {
ThreadReuse threadReuse = new ThreadReuse();
Map<String, Object> res = threadReuse.exec(1);
result.add(res);
});
}
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
System.out.println(e.getMessage());
}
result.forEach(System.out::println);
threadPool.shutdown();
}
执行上面程序得到结果为:
{before=pool-1-thread-1:null, after=pool-1-thread-1:1}
{before=pool-1-thread-1:1, after=pool-1-thread-1:1}
最终验证发现,线程复用的情况下,从共享变量里取到的值就是上一个业务遗留的。同样的,我们在使用Tomcat时,由于tomcat内部也是线程复用的,所以也会存在同样的情况。
那针对这个问题,我们的解决方案通常是,及时释放资源。修正后的代码为:
private static final ThreadLocal<Integer> currentUser = ThreadLocal.withInitial(() -> null);
public Map<String, Object> exec(Integer userId) {
// 设置用户信息之前先查询一次ThreadLocal中的用户信息
String before = Thread.currentThread().getName() + ":" + currentUser.get();
// 设置用户信息到ThreadLocal
currentUser.set(userId);
//设置用户信息之后再查询一次ThreadLocal中的用户信息
String after = Thread.currentThread().getName() + ":" + currentUser.get();
try {
// 通常是执行具体业务逻辑... 省略
// 汇总输出两次查询结果
Map<String, Object> result = new HashMap<>();
result.put("before", before);
result.put("after", after);
return result;
} finally {
// 释放ThreadLocal中的用户信息
currentUser.remove();
}
}
此时再继续执行我们的测试程序,得到的结果就是预期的了。
{before=pool-1-thread-1:null, after=pool-1-thread-1:1}
{before=pool-1-thread-1:null, after=pool-1-thread-1:1}
这个例子我们,在编写程序的时候,一定要清晰的理解我们的业务逻辑和所使用的工具原理,避免在复杂的场景中出现不可预期的结果。