InheritableThreadLocal 从入门到放弃
作者:达达秒送 田超辉
背景:
一个上线了很久但是请求量很低(平均每天一两次)的历史功能突然出现空指针报错:
我们翻开代码定位到对应的报错代码:
结合堆栈和代码可以确定是由于 bdIdJobMap 的值为 null 导致往 bdIdEmployeeJobMap 这个 map 中 putAll 的时候空指针了。
而 bdIdJobMap 又取自 employeeJobMapThread.get(); 那么这个 employeeJobMapThread 又是何物?
哦豁,employeeJobMapThread 居然是个 InheritableThreadLocal。
梳理一下报错代码的上下文逻辑如下:
1.首先在当前主线程中对 InheritableThreadLocal 类型变量 employeeJobMapThread 进行赋值
2.把耗时操作提交到线程池中异步执行,在异步任务中去获取 employeeJobMapThread 的值(其中线程池配置的 coreSize/maxSize 均为 4,queue 大小为 3000)
3.在主线程中执行 employeeJobMapThread.remove(),在异步任务完成之后没有执行 employeeJobMapThread.remove()
4.最后在异步任务中通过 employeeJobMapThread 获取到的值为 null 导致后续操作空指针
是否和最近的上线有关?
相信大家都有这样的共识:线上出现报错,首先怀疑是否和最近的上线有关系。
我们做的第一件事情也是排查了近期的上线功能,从上线的功能点和相关代码上来看都和这次报错的代码没什么关系,
因此初步排除了这个原因。所以接下来只能进一步了解代码来排查原因了。
要搞清楚当前报错的根因,毫无疑问肯定是要翻过 InheritableThreadLocal 这座小山啦。
简单聊下 InheritableThreadLocal:
提起 ThreadLocal,大家应该相对都比较熟悉了,比如存放登录用户信息到 ThreadLocal 变量中,然后在接口层可以比较方便的获取登录用户,帮助开发提效。
但是对于 InheritableThreadLocal,有不少同学都不太了解。
挑重点来说,InheritableThreadLocal 相比 ThreadLocal 多一个能力:在创建子线程 Thread 时,子线程 Thread 会自动继承父线程的 InheritableThreadLocal 信息到子线程中,进而实现在在子线程获取父线程的 InheritableThreadLocal 值的目的。
举个简单的栗子对比下 InheritableThreadLocal 和 ThreadLocal:
执行结果:
可以看到子线程中可以获取到父线程设置的 inheritableThreadLocal 值,但不能获取到父线程设置的 threadLocal 值。
为什么 InheritableThreadLocal 能够做到这点呢?
是因为在父线程创建子线程 Thread 的时候,Thread 的构造器内部会自动继承父线程的 InheritableThreadLocal 到子线程。
Thread 源码这两处地方解释了原因:
init 方法内部实现:
通过这个简单的介绍可以帮助对于 InheritableThreadLocal 不了解的同学有一个初步的了解,本文不是专门介绍 InheritableThreadLocal 的深入原理,所以就不展开聊了,大家感兴趣可以自己进一步探索。
验证 InheritableThreadLocal+线程池:
前面介绍了 InheritableThreadLocal 可以自动把父线程的 InheritableThreadLocal 信息继承到子线程 Thread 中。
但是在业务项目中真正需要用到子线程的时候正经人谁自己 new Thread,咱可是用线程池的。
当然了,就像文章开头说明的,这次报错的代码里面也用线程池来执行异步任务。
那么 InheritableThreadLocal+线程池的组合会摩擦出什么样的火花呢?
我把这次报错的代码精简之后得到下面的示例(实际代码中往 InheritableThreadLocal 赋的值类型不是字符串,后面会提到):
执行结果如图所示,可以看到在线程池里面也可以获取到父线程设置的 inheritableThreadLocal 值。
接下来我们来分析下 InheritableThreadLocal+线程池的执行过程:
也就说只有在以下这两个场景下才会继承父线程的 InheritableThreadLocal:
1.线程池当前线程数 < 核心线程数
2.线程池当前线程数 >= 核心线程数 && 队列已满 && 线程数 < 最大线程数(本次线上报错的代码使用的线程池设置的 coreSize 和 maxSize 一致,所以走不到该场景)
其他情况都是在复用线程池现有的 Thread,自然也就不会继承父线程的 InheritableThreadLocal。
我们提交多个异步任务到线程池来验证下:
执行结果用表格形式展示如下:
可以看到 InheritableThreadLocal+线程池的组合,会面临 InheritableThreadLocal 污染的问题,即异步任务可能取到其他父线程设置的 InheritableThreadLocal 值。
有同学会提到我们不是在代码里面加了 inheritableThreadLocal.remove()来清除 inheritableThreadLocal 的吗?为什么没有清除掉呢?
这是因为此时我们清除的只是父线程的 inheritableThreadLocal,而没有清除子线程的 inheritableThreadLocal 的缘故。
为什么 InheritableThreadLocal 污染对线上没有产生影响?
既然 InheritableThreadLocal+线程池的组合,会存在 InheritableThreadLocal 污染的问题,那岂不是线上报错的这段代码也存在这个问题?
再次检查代码,确认历史代码的确存在这个问题,
但是,这个代码上线 2 年多为啥一直稳定运行且没有用户反馈过功能有问题?只有最近突然出现报错?一时之间脑袋懵懵的。
仔细检代码之后发现:
这段代码在父子线程之间通过 InheritableThreadLocal 类型变量 employeeJobMapThread 传递的值是【全量的<人员 Id, 该人员基本信息>结构的 map】,可以近乎看做是一个不变的常量,所以虽然异步任务会拿到污染的数据,也是正常可以用的,没有产生业务影响。
这种感觉怎么说呢,只能说感叹前人的智慧,把几乎不可能做到了可能~
好了,到这里我们解释了为什么这段代码上线这么久一直没问题,因为代码确实有 InheritableThreadLocal 污染问题,但被污染了也不影响使用。。。所以从最终结果来看确实可以正常运行。
什么原因导致子线程获取到的 InheritableThreadLocal 值是 null?
但是。。。说了这么多,还是不能解释为什么线上代码获取到的 inheritableThreadLocal 值会是 null。
1.难道父线程设置的 inheritableThreadLocal 值可能会是 null?
检查代码发现父线程设置的 inheritableThreadLocal 不可能为 null,顶多会是空集合:
2.难道是线程池创建之后通过 prestartAllCoreThreads 初始化了核心线程,在执行异步任务的时候,都是复用的已有线程导致的?
检查了对应线程池的初始化代码,发现并没有初始化核心线程,也排除了这个可能。而且如果真的是该原因 22 年上线之后功能一定是有问题的,前面说过,该功能上线之后没人反馈过异常,所以也可以排除该原因。
•该功能 22 年上线之间 2 年多一直没人反馈,大概率该功能之前很长时间是正常的,近期由于某个原因导致功能异常
•虽然历史代码的用法存在子任务中获取到的 InheritableThreadLocal 被污染的问题,但是被污染的值也能用,不影响正确性
•只要线程池中的线程初始化的时候继承了正确的 InheritableThreadLocal 值,后续就不会被清除掉,也就可以正常运行功能
从这些已知的信息来推断,可以推断出这段历史代码写法虽然有隐患,但是不是引发当前空指针的的原因。
3.剩下的只有一种可能:存在线程池的共用。
在执行这个报错的异步任务的时候,复用了某个已有的线程 A,并且当时创建该线程 A 的时候,没有继承 InheritableThreadLocal,进而导致后面复用该线程的时候,从 InheritableThreadLocal 获取到的值为 null。
而只要是通过这段历史代码创建的线程一定是没问题的,所以一定是存在其他业务共用了这个线程池,并且这个业务优先执行进而初始化了线程池的线程,导致线程池的线程没有继承 InheritableThreadLocal。
如下代码示例:
执行结果:
在项目里面搜一下看看,果真如此,有 2 出地方在用这个线程池,并且另外的一处代码中提交的异步任务不涉及 inheritableThreadLocal:
示意图如下:
逻辑执行顺序为:创建线程池 - 执行功能 A - 执行功能 A - 执行功能 B
其中:
功能 A 全流程均不涉及 InheritableThreadLocal
功能 B 对应报错的代码,主线程设置 InheritableThreadLocal 并且在子线程使用
至此,线上报错的根因确定了:就是因为 InheritableThreadLocal + 线程池共用导致。
扩展一下:
假如执行顺序是这样呢:创建线程池 - 执行功能 B - 执行功能 B - 执行功能 A
结局居然是一切安好。
假如执行顺序是这样呢:创建线程池 - 执行功能 B - 执行功能 A - 执行功能 B
发现了吗?如果应用启动之后功能 B 先执行并且初始化了线程池所有核心线程,那么一切正常,否则就可能报错。
也就是说功能 B 是否正常还看运气的,运气好就正常执行,运气不好就报空指针的错。
这你敢信?
小插曲:
这个问题的排查当中还遇到了 2 个小插曲:
插曲 1:
最初怀疑是线程池复用导致的,但是在 IDEA 里面搜代码的时候粗心大意没有看到其他地方在复用线程池。
因此期间一度自我怀疑见鬼了,导致本来可以一两个小时确定根因的,结果饶了弯路多花了两个小时才确定根因。
所以说排查问题的时候每一步都要保持细心,得出的每一个结论都应该是证据确凿,理由充分,
否则会让自己兜兜转转浪费宝贵的时间。
插曲 2:
排查代码的时候发现异步任务代码没有做任何的异常处理,这其实是很坑的。
有经验的同学应该知道,线程池里面提交异步任务如果没有做异常处理,出现异常的话不会有任何的日志信息。
本地运行的时候会打印到控制台,但是线上控制台的信息可不会记录到日志里面。
所以经常遇到异步任务执行结果不符合预期,但是线上没有任何相关日志就是这个原因。
我们这里有日志是因为使用的线程池是二次封装过的,里面对异步任务做了兜底的异常记录。
总结:
前面分析到了导致空指针的原因是线程池共用导致的老代码报错,而共用这个线程池的代码是新上线的功能引入的。
这就打脸了开头我们检查了上线的功能与此无关,实则有关。
只是我们评估复用线程池的影响时,很难想到会有这样的影响,通常我们会考虑:
1.是否会影响共用该线程池的老功能响应时间边长
2.是否存在父子任务共用线程池导致可能产生死锁
针对 InheritableThreadLocal,我个人的建议是:
1.InheritableThreadLocal(其实 ThreadLocal 也一样)不适合应用于业务代码中,因为他们都是隐式的参数传递,而业务系统中好维护的代码应当是显式的参数传递(我们这个线上问题就采用该方式)
2.框架类代码才是 InheritableThreadLocal 和 ThreadLocal 主要发光发热的地方,因为对应的研发水平通常较高,且代码经过严格测试验证,并且较少变动。而业务系统研发水平参差不齐,且经常会发生同步操作变异步等
3.虽然 InheritableThreadLocal 不建议在业务代码中使用,但是我们还是需要掌握它,不为别的,只有掌握它的优缺点才能告诉自己和他人为什么应该在业务代码中放弃使用它
针对如何有效的应对业务研发遇到的一些“疑难杂症”,我的建议是:
1.大胆提出合理的假设,小心谨慎进行验证
2.没有充足理由,不要轻易下结论
3.没有头绪时,休息一下,或找合适的人一起探讨,给自己打开新的思路
最后,愿天下没有故障,没有线上问题,没有 bug。
版权声明: 本文为 InfoQ 作者【京东零售技术】的原创文章。
原文链接:【http://xie.infoq.cn/article/fd2c232e15aed5738c66814fe】。文章转载请联系作者。
评论