当前位置: 首页 > 科技观察

细数ThreadLocal三大坑,内存泄露仅是小儿科

时间:2023-03-17 22:44:17 科技观察

细数ThreadLocal的三大坑,内存泄露只是小儿科。其实,这种想法是有问题的。ThreadLocal很难写错,但是很容易用错。本文将详细总结ThreadLocal容易被误用的三个陷阱:内存泄漏线程池中线程上下文丢失和并行流中线程上下文内存丢失泄漏由于ThreadLocal的key是弱引用,如果不调用使用后移除清理,会导致对应值内存泄漏。当localCache的值被重置后,cacheInstance被ThreadLocalMap中的值引用,不能被GC,但是它的key对ThreadLocal实例的引用是弱引用。本来ThreadLocal的实例是同时被localCache和ThreadLocalMap的key引用的,但是当localCache的引用被reset后,ThreadLocal实例就只有ThreadLocalMapkey的弱引用,这个实例可以被清理掉在GC期间。其实看过ThreadLocal源码的同学就会知道,ThreadLocal本身对于key为null的Entity有一个自清理的过程,但是这个过程要依赖ThreadLocal以后的继续使用。上面的代码如果是秒杀场景,会出现一个瞬间的流量高峰,这个流量高峰也会把集群的内存打的很高(或者运气不好的话,会直接把集群内存占满,导致失败)。后来因为高峰期过去了,ThreadLocal的调用也掉线了。会降低ThreadLocal的自清理能力,造成内存泄漏。ThreadLocal的自洁是锦上添花,别指望他雪中送碳。相对于ThreadLocal中存储的值对象的泄露,在web容器中使用ThreadLocal时,更需要注意的是其引起的ClassLoader的泄露。Tomcat官网对web容器中使用ThreadLocal导致的内存泄露做了总结。详情参见:https://cwiki.apache.org/confluence/display/tomcat/MemoryLeakProtection,这里我们举个例子。熟悉Tomcat的同学都知道,Tomcat中的web应用都是通过类加载器WebappClassloader来实现的,而WebappClassloader是通过破坏双亲委派机制来实现的,即所有的web应用都是先通过Webappclassloader来加载的。这样做的好处是相同的Web应用程序在容器中和依赖性隔离。下面我们看具体的内存泄露的例子:publicclassMyCounter{privateintcount=0;publicvoidincrement(){count++;}publicintgetCount(){returncount;}}publicclassMyThreadLocalextendsThreadLocal{}publicclassLeakingServletextendsHttpServlet{privatestaticMyThreadLocalmyThreadLocal=newMyThreadLocal();protectedvoiddoGet(HttpServletRequestrequest,HttpServletResponseresponse)throwsServletException,IOException{MyCountercounter=myThreadLocal.get();if(counter==null){counter=newMyCounter();myThreadLocal.set(counter);}response.getWriter().println("Thecurrentthreadservedthisservlet"+counter.getCount()+"times");counter.increment();}}这个例子需要注意两个很关键的点:MyCounter和MyThreadLocal必须放在web应用的路径中,ThreadLocal类必须是WebappClassloader加载的是ThreadLocal的继承类,比如例子中的MyThreadLocal,因为ThreadLocal最初是由CommonClassloader加载的,它的生命周期是一致的与Tomcat容器的帐篷。ThreadLocal的继承类包括比较常见的NamedThreadLocal,注意不要踩坑。如果启动了LeakingServlet所在的web应用,MyThreadLocal类也会被WebappClassloader加载。如果此时web应用处于离线状态,线程的生命周期还没有结束(比如为LeakingServlet提供服务的线程是线程池中的线程),那么myThreadLocal的实例仍然会被this引用线程而不是GC。一开始,这似乎不是什么大问题,因为myThreadLocal引用的对象并没有占用多少内存空间。问题是myThreadLocal间接持有并加载了web应用的webapp类加载器的引用(可以通过myThreadLocal.getClass().getClassLoader()来引用),而加载web应用的webapp类加载器拥有它所有类的引用加载,这会导致类加载器泄漏。它泄漏的内存非常可观。线程池中的线程上下文丢失。ThreadLocal不能在父子线程之间传递,所以最常见的方式就是将父线程中的ThreadLocal值复制到子线程中,所以你会经常看到类似下面这样的代码:for(valueinvalueList){FuturetaskResult=threadPool.submit(newBizTask(ContextHolder.get()));//提交任务,设置复制Context到子线程results.add(taskResult);}for(resultinresults){result.get();//阻塞并等待任务执行完成}提交的任务定义如下所示:classBizTaskimplementsCallable{privateStringsession=null;publicBizTask(Stringsession){this.session=session;}@OverridepublicTcall(){try{ContextHolder.set(this.session);//执行业务逻辑}catch(Exceptione){//logerror}finally{ContextHolder.remove();//清理ThreadLocal的上下文,避免线程复用时上下文串化}returnnull;}}对应的线程上下文管理类为:classContextHolder{privatestaticThreadLocallocalThreadCache=newThreadLocal<>();publicstaticvoidset(StringcacheValue){localThreadCache.set(cacheValue);}publicstaticStringget(){returnlocalThreadCache.get();}publicstaticvoidremove(){localThreadCache.remove();}}这样写没有问题,我们看看设置线程池的:ThreadPoolExecutorexecutorPool=newThreadPoolExecutor(20,40,30,TimeUnit.SECONDS,newLinkedBlockingQueue(40),newXXXThreadFactory(),ThreadPoolExecutor.CallerRunsPolicy);其中最后一个参数控制当线程池满时如何处理提交的任务,内置4种策略ThreadPoolExecutor.AbortPolicy//直接抛出异常ThreadPoolExecutor.DiscardPolicy//丢弃当前任务ThreadPoolExecutor.DiscardOldestPolicy//丢弃当前任务工作队列的头部ThreadPoolExecutor.CallerRunsPolicy//串行执行可以看到我们在初始化线程池的时候指定如果线程池满了,新提交的任务会转为串行执行,那么我们之前的写法就会有问题。串行执行时调用ContextHolder.remove()也会清理主线程的上下文。即使后面线程池继续并行工作,传递给子线程的上下文已经是null,预发布测试时很难发现并行流中的线程上下文丢失这样的问题。如果ThreadLocal遇到并行流,会有很多有趣的事情发生,例如下面的代码:不写dataList.parallelStream().forEach(entry->{doIt();});}privatevoiddoIt(){Stringsession=ContextHolder.get();//dosomething}}这段代码在离线测试的时候很容易发现并没有达到预期的效果,因为并行流的底层实现也是一个ForkJoin线程池,既然是线程池,那么ContextHolder.get()可能会取出一个null。我们沿着这个思路改一下代码:classParallelProcessor{privateStringsession;publicParallelProcessor(Stringsession){this.session=session;}publicvoidprocess(ListdataList){//先检查参数,省略空格limit先不写dataList.parallelStream().forEach(entry->{try{ContextHolder.set(session);//业务处理doIt();}catch(Exceptione){//logit}finally{ContextHolder.remove();}});}privatevoiddoIt(){Stringsession=ContextHolder.get();//dosomething}}这段代码修改后能用吗?运气好的话,你会发现这个修改有问题。如果运气不好,这段代码离线运行的很好,这段代码上线也很顺利。不久你就会在系统中发现一些其他奇怪的错误。原因是并行流的设计比较特殊,父线程也可能参与并行流线程池的调度。如果上面的process方法被父线程执行,父线程的上下文就会被清除。结果复制到子线程的context为null,也造成丢失context的问题。并行流的实现可以参考文章What?并行流甚至更慢。