SpringBoot ThreadLocal Tomcat线程复用导致内存泄露的处理,以及连带后续问题

前言

最近在项目里面引入了ThreadLocal,其实我对这个东西不太熟悉,只是看到在上家公司的项目里面用到了,然后我百度了一下,说是可以将一些数据缓存到线程中,然后先从线程中拿,没有再到redis中或者mysql中拿,可以减轻数据库压力,所以就引入到这个项目了。

系统的当前登录用户,以前每次我都从redis中取的(根据前端传的token),引入ThreadLocal之后,我就优先从ThreadLocal中取,如果ThreadLocal中没有,我再从redis中取,取出来之后缓存到ThreadLocal中,以便在同一个线程,下次拿取。

在执行某个具体功能的时候(说白了就是某个接口被调用的时候),很多地方都会用到当前登录的用户。比如:在具体执行业务之前需要记录日志、在具体业务中的service中、具体业务可能会调用多个service……,这些地方都可能会用到,如果每次都是去redis里面拿,是一笔不小的开销,而且网络连接,会增加这个接口的响应时间。使用ThreadLocal将当前用户缓存到当前线程中,每次取会优先从当前线程中取,能节省不少开销。但是这样做的前提是:从请求到服务器、处理业务逻辑、最终返回响应,都是同一个线程在做,如果期间你开启了多线程去做(不知道可不可以实现)就没法获取到线程缓存的。反正就是,ThreadLocal缓存的数据,只能在同一个线程间获取。而springboot,内嵌了Tomcat,每次调用接口,都是Tomcat开启的线程,然后这个线程从头走到尾,包括在springboot拦截器、aop、controller、service、mybatis拦截器中,全都是同一个线程,所以说线程缓存是有用的。

问题

引入这个ThreadLocal没两天,前端同事在调式的时候发现,他登录的时候明明是用的张三的账号,但是登录进去发现从后端接口获取的初始化信息的时候,返回的是李四的信息。这个问题并不是每次都出现总出现,时不时的出现,给调试带来一些难度。但是经过不懈的努力,终于被抓到了一次。是这样的:

前端在发请求的时候,都会在请求头里面带上token,标志当前登录的用户,没做ThreadLocal之前,我应该按照前端带过来的这个token,去redis里面查询对应的用户信息,这个就是当前登录的用户。但是当我使用ThreadLocal之后,我并没有直接就去redis里面拿,我是先去ThreadLocal里面拿当前登录用户,如果ThreadLocal里面拿不到,再去redis里面拿,拿到之后在缓存到ThreadLocal里面。

被我抓到的那次,我发现前端带过来的token,在redis里面的登录用户信息是张三,但是我拿的时候,没有从redis里面那,直接ThreadLocal拿到了,最关键的是,拿到的不是张三的信息,而是李四的信息。为啥会从ThreadLocal里面拿到李四的信息呢?按道理第一次从ThreadLocal里面拿,会拿不到,再去redis里面拿的。为啥第一次就从ThreadLocal里面拿到了,而且拿到了一个错误的。

经过几轮测试,发现拿到那个错误的登录用户信息,是之前登录的,但是没有正常退出的。我开始有点眉目了,肯定是用ThreadLocal没有处理好什么地方。然后我又想到了,ThreadLocal是缓存在线程上,Tomcat有线程池,会复用线程,那么被复用的那个线程,和上一次还是同一个吗?Tomcat在把某个线程放回线程池后,会清理线程上的缓存吗?然后我开始了测试。

果然有问题。用Thread.CurrentThread.getId来查看,当有两次请求的线程id是一样时,ThreadLocal缓存的数据就混乱了。比如:我某次请求过来,线程id是56,然后在该线程上缓存了这次当前登录用户张三的信息。当后面某一次,我登录的是李四的账号,但是请求过来,线程id也是56(Tomcat复用了线程),这个时候,我去ThreadLocal里面拿当前登录用户信息时,直接就拿到了李四的信息,因为上次缓存了李四的信息。Tomcat在第一个使用了线程56之后并将其放回线程池时,并没有清理线程上缓存的数据,Tomcat应该没没法清理。所以再次使用到56这个线程时,数据就混乱了,这也是为什么前端登录的是张三,拿到的李四的信息,要复用到同一个线程时才会出现这个问题,所以最开始这个问题都没法固定复现,不好调试。

ps:关于Tomcat的工作原理以及线程复用,请自行百度。

分析

现在问题的原因找到了,下一步,思考如何解决这个问题,出现这个问题之后,我去看了一个ThreadLocal,这个类里面的确有一个remove的清理方法,只是我从来就没有调用过,所以出了问题。现在的问题就是我在什么地方、什么时机来调用remove方法清理线程缓存呢?不能清早了,因为前面可能还要用。然后我想到了用Spring的AOP,这里简单说一下,后面补充一篇博客详细说AOP。

 通知的类型及顺序
   前置通知  Before                 方法执行之前

   后置通知  AfterReturning         方法正常返回
   异常通知  AfterThrowing          方法抛出异常
   
   最终通知  After                  无论如何最终
   
   注意:后置和异常是互斥的,所以他们两任一执行,另外,环绕通知顺序好像不是这样

在每个controller方法执行"完"之后进行线程缓存清理,这里的执行完,包括执行出错,所以这里AOP的通知应该用最终通知,因为无论有没有出错,都需要清理线程缓存。或者是在每个controller执行之前,就拿到前端带过来的token,然后去redis里面拿到正确的当前登录用户,然后存储到当前线程里面,后面就不用去redis里面拿了。总结一下,这两种方式,

  • 一种是:在每个线程缓存的变量不再使用时,也就是在接口功能完成时,将线程缓存的变量清除,避免下次再使用到这个线程时,缓存的数据泄露
  • 另一种是:在每个线程开始执行具体业务之前,也就是接口被调用之前,直接将正确的数据缓存到当前线程中。每个线程都是如此,每个线程执行具体业务之前,缓存就被更新了,所以不会造成缓存数据泄露。

这两种方法理论上都可以解决问题,但是我个人更倾向于第一种,就是每次用完清除,而不是每次controller执行之前就强制缓存。因为可能有的业务不需要用到当前登录用户,但是强制缓存,还是会把当前登录的用户缓存到当前线程,而且有的接口不需要登录就能被访问,强制缓存就会出错,这时还要去写切入点表达式,去找到那些需要登录才能访问的接口,然后进行强制缓存,很不爽。所以我采取了第一种解决办法,每次用完就清楚。

解决

问题分析得明明白白,解决思路也想好了,那解决起来也很简单。写一个线程清理AOP,拦截所有的controller,然后在最终通知里面,调用ThreadLocal的remove方法进行线程清理。但是需要注意一点,因为我还有一个记录操作日志的AOP,这个AOP需要用到当前登录用户,所以必须得让日志记录AOP先执行,线程清理AOP后执行,而且不光如此,线程清理AOP必须在所有AOP里最后执行,不然前面执行的AOP会重新去redis里面拿到当前登录用户,然后写入当前线程缓存,清理工作等于没做。

切面的先后顺序,可以用注解org.springframework.core.annotation.Order来控制(springboot项目),

@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE, ElementType.METHOD, ElementType.FIELD})
@Documented
public @interface Order {

	/**
	 * The order value.
	 * <p>Default is {@link Ordered#LOWEST_PRECEDENCE}.
	 * @see Ordered#getOrder()
	 */
	int value() default Ordered.LOWEST_PRECEDENCE;

}

Order有一个int类型的属性,value表示执行的先后顺序,value越小,越先执行(这里需要注意,不然很容易踩坑!),value有个默认值,跟着点进去,会发现这个默认值是int类型的最大值,所以线程清理AOP的Order的value就让它用默认值就行了,最后执行,其他AOP根据具体的需求,来设置value的值。

后续

像上面那样设置了之后,问题看起来被解决了。其实这是一种错觉,因为上面我自己分析得很透彻,我就觉得问题一定解决了,加上这个问题不是那么容易出现,要用到同一个线程,在和前端在调式的时候,很多时候都是他一个人,一个账号在弄,所以同一个线程被复用了,缓存没有清理,如果两次都是同一个账号,也是看不来的。而且有的接口不需要记录日志,日志AOP不会执行,线程缓存就被清理了,前端也不是专门搞测试的。总之,各种因素凑在一起,让这个问题看起来被解决了,但是当第二天前端同事需要用两个账号调试的时候,那个问题又会时不时的出现……

我去,我都搞懵了,我明明分析得那么清楚,解决方案理论上也没问题,怎么还会出现这个问题呢?经过漫长的调式之后,终于被我发现问题了!!!

原来是,在每个线程的缓存被清理之后,日志AOP又用了线程缓存,所以又错redis中拿到当前登录用户,然后被缓存到了线程缓存中。我很纳闷,上面我都分析到这个问题了,而且还使用Order来控制先后顺序,难道是Order的value我搞反了?再仔确认了一遍,没搞错。然后我不由得重新思考了一下AOP,还百度了一下,我去,还真是AOP先后顺序的问题!!!

问题是什么呢?我的解决方案思路没问题,清理线程缓存的AOP需要最后执行,Order值越大越后执行也没问题,但是问题就是,AOP前前后后可是有三个(为什么我会说三个,因为异常返回通知和正常返回通知永远不会同时执行,所以一个AOP最多执行三个通知)通知的。Order的value越小是越先执行,可是越先执行的是他的前置通知,他的返回(包括正常返回和异常返回)通知和最终通知可都是要后执行的,什么意思呢,我给你画个图:

AOP执行顺序
AOP1先执行,仅仅是它的前置通知先执行,返回通知和最终通知都是比AOP2后执行的。

我当时想的线程清理AOP有后执行,所以value给的默认值,也就是线程清理AOP的前置通知是最后一个执行,但是返回和最终通知是第一个执行的。我线程清理的逻辑都是放在线程清理AOP的最终通知里面的(原因前文说过),而记录日志的逻辑是放在日志AOP的正常返回通知里的(原因前文也说过),所以,这样下来,线程清理反而比记录日志先执行,清理了又被重新写入了。所以才造成了后续的问题,然后我将线程清理AOP的Order的Value设置成了1,问题就没在出现了

反思

  • 最开始把ThreadLocal引入的目的是,可以减少去redis里面拿取当前登录用户的次数,可以减少redis的连接,省去了相当一部分的网络数据传输时间。但是这样需要我每个接口都去清理线程缓存,不知道是不是得不偿失。
  • 第一次出现问题,我已经把问题分析得很透彻了,解决方案理论上也没问题,但是对于AOP,我缺忽略了他真正的执行顺序,只想到:只要保证线程清理AOP最后执行就可以了,造成了后续的问题
  • 其实线程清理不止可以用AOP来做,spring的拦截器(Interception)、servlet的过滤器(Filter),都可以做,而且使用servlet过滤器会更好,因为它们三者的顺序是这样的:
    Filter、Interception、AOP顺序
    Filter可以保证在最后执行,只需要保证多个FIlter的先后顺序即可,而如果使用AOP/Interception,不仅需要保证其他AOP/Interceptoion的顺序。还需要保证interception/Filter没有用到线程缓存。我用AOP,可能是我AOP用的比较多,第一时间想到的是AOP,我已经在项目中更换了,使用了一个ThreadClearFilter替换了原来的ThreadClearAspect。
水平有限,如果有什么错误,还请指正。有什么疑问,也可以在评论里提出。
  • 17
    点赞
  • 28
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值