线程池使用InheritableThreadLocal出现数据脏乱分析和解决方案
背景
在测试环境上遇到一个诡异的问题,部分业务逻辑会记录用户ID到数据库,用户ID是通过用户登录后从下游获取到用户信息后通过InheritableThreadLocal存放,当需要获取用户信息的直接通过InheritableThreadLocal获取
问题
测试环境运行一段时间后,发现用户ID错乱, 记录到到其他用户的ID,疑似数据串来串去,在本地不管如何测试都是正常;刚开始以为是测试环境cookie错乱,后来排查代码后,发现罪魁祸首是由于记录操作日志的时候开启了线程池导致
排查过程
1.通过临时打印日志,确认整个链路中用户ID是否一致
2.确认写日志方法是否有被修改,最后确认是写日志这块开了线程池后导致问题
思考
1.为什么在之前测试过程没有复现
之前依赖的日志记录二方jar包进行过一次升级,原先的版本在记录日志没有线程池去异步记录,后面升级版本后,引入了线程池,而线程池是一直复用core 数量的线程的,处理完任务之后并不会回收,而InheritableThreadLocal是和线程绑在一起的,所以下一个任务复用线程池的时候用 threadLocal.get() 方法拿到的是还是老的变量
2.分析为什么用了InheritableThreadLocal还会导致这个数据错乱(覆盖)
ThreadLocal 在 ThreadLocalMap 中是以一个弱引用身份被Entry中的Key引用的,因此如果ThreadLocal没有外部强引用来引用它,那么ThreadLocal会在下次JVM垃圾收集时被回收。这个时候就会出现Entry中Key已经被回收,出现一个null Key的情况,外部读取ThreadLocalMap中的元素是无法通过null Key来找到Value的。因此如果当前线程的生命周期很长(在本篇案例中,由于线程池的核心线程没有被回收,一直存在),那么其内部的ThreadLocalMap对象也一直生存下来,这些null key就存在一条强引用链的关系一直存在:Thread –> ThreadLocalMap–>Entry–>Value,这条强引用链会导致Entry不会回收,Value也不会回收
3.本地求证
为此我写了一个demo在本地去复现这个问题,为了能模拟测试环境,我设置了2个核心线程,并且调用4次
根据上面的运行日志结果,可以发现第二次的设值并没有真正改变,所以本地模拟成功,接下来就是分析为什么会出现这个原因,以及如何解决方案
分析线程池的核心线程是如何不被回收的,源码分析如下(主要分析重点在于核心线程创建后是如何保证不被回收)
1.跟进execute主入口分析
public void execute(Runnable command) {
if (command == null)
throw new NullPointerException();
int c = ctl.get();
if (workerCountOf(c) < corePoolSize) {
if (addWorker(command, true)) //未超过核心线程数,则新增 Worker 对象,true表示核心线程
return;
c = ctl.get();
}
if (isRunning(c) && workQueue.offer(command)) {
int recheck = ctl.get();
if (! isRunning(recheck) && remove(command))
reject(command);
else if (workerCountOf(recheck) == 0)
addWorker(null, false);
}
else if (!addWorker(command, false))
reject(command);
}