ThreadLocal中的3个大坑,内存泄露都是小儿科!
我在參加Code Review的時候不止一次聽到有同學說:我寫的這個上下文工具沒問題,在線上跑了好久了。其實這種想法是有問題的,ThreadLocal寫錯難,但是用錯就很容易,本文將會詳細總結ThreadLocal容易用錯的三個坑:
內存泄露
線程池中線程上下文丟失
并行流中線程上下文丟失
內存泄露
由于ThreadLocal的key是弱引用,因此如果使用后不調用remove清理的話會導致對應的value內存泄露。
@Test public?void?testThreadLocalMemoryLeaks()?{ThreadLocal<List<Integer>>?localCache?=?new?ThreadLocal<>();List<Integer>?cacheInstance?=?new?ArrayList<>(10000);localCache.set(cacheInstance);localCache?=?new?ThreadLocal<>(); }當localCache的值被重置之后cacheInstance被ThreadLocalMap中的value引用,無法被GC,但是其key對ThreadLocal實例的引用是一個弱引用,本來ThreadLocal的實例被localCache和ThreadLocalMap的key同時引用,但是當localCache的引用被重置之后,則ThreadLocal的實例只有ThreadLocalMap的key這樣一個弱引用了,此時這個實例在GC的時候能夠被清理。
其實看過ThreadLocal源碼的同學會知道,ThreadLocal本身對于key為null的Entity有自清理的過程,但是這個過程是依賴于后續對ThreadLocal的繼續使用,假如上面的這段代碼是處于一個秒殺場景下,會有一個瞬間的流量峰值,這個流量峰值也會將集群的內存打到高位(或者運氣不好的話直接將集群內存打滿導致故障),后面由于峰值流量已過,對ThreadLocal的調用也下降,會使得ThreadLocal的自清理能力下降,造成內存泄露。ThreadLocal的自清理是錦上添花,千萬不要指望他雪中送碳。
相比于ThreadLocal中存儲的value對象泄露,ThreadLocal用在web容器中時更需要注意其引起的ClassLoader泄露。
Tomcat官網對在web容器中使用ThreadLocal引起的內存泄露做了一個總結,詳見:https://cwiki.apache.org/confluence/display/tomcat/MemoryLeakProtection,這里我們列舉其中的一個例子。
熟悉Tomcat的同學知道,Tomcat中的web應用由Webapp Classloader這個類加載器的,并且Webapp Classloader是破壞雙親委派機制實現的,即所有的web應用先由Webapp classloader加載,這樣的好處就是可以讓同一個容器中的web應用以及依賴隔離。
下面我們看具體的內存泄露的例子:
public?class?MyCounter?{private?int?count?=?0;public?void?increment()?{count++;}public?int?getCount()?{return?count;} }public?class?MyThreadLocal?extends?ThreadLocal<MyCounter>?{ }public?class?LeakingServlet?extends?HttpServlet?{private?static?MyThreadLocal?myThreadLocal?=?new?MyThreadLocal();protected?void?doGet(HttpServletRequest?request,HttpServletResponse?response)?throws?ServletException,?IOException?{MyCounter?counter?=?myThreadLocal.get();if?(counter?==?null)?{counter?=?new?MyCounter();myThreadLocal.set(counter);}response.getWriter().println("The?current?thread?served?this?servlet?"?+?counter.getCount()+?"?times");counter.increment();} }需要注意這個例子中的兩個非常關鍵的點:
MyCounter以及MyThreadLocal必須放到web應用的路徑中,保被Webapp Classloader加載
ThreadLocal類一定得是ThreadLocal的繼承類,比如例子中的MyThreadLocal,因為ThreadLocal本來被Common Classloader加載,其生命周期與Tomcat容器一致。ThreadLocal的繼承類包括比較常見的NamedThreadLocal,注意不要踩坑。
假如LeakingServlet所在的Web應用啟動,MyThreadLocal類也會被Webapp Classloader加載,如果此時web應用下線,而線程的生命周期未結束(比如為LeakingServlet提供服務的線程是一個線程池中的線程),那會導致myThreadLocal的實例仍然被這個線程引用,而不能被GC,期初看來這個帶來的問題也不大,因為myThreadLocal所引用的對象占用的內存空間不太多,問題在于myThreadLocal間接持有加載web應用的webapp classloader的引用(通過myThreadLocal.getClass().getClassLoader()可以引用到),而加載web應用的webapp classloader有持有它加載的所有類的引用,這就引起了Classloader泄露,它泄露的內存就非常可觀了。
線程池中線程上下文丟失
ThreadLocal不能在父子線程中傳遞,因此最常見的做法是把父線程中的ThreadLocal值拷貝到子線程中,因此大家會經常看到類似下面的這段代碼:
for(value?in?valueList){Future<?>?taskResult?=?threadPool.submit(new?BizTask(ContextHolder.get()));//提交任務,并設置拷貝Context到子線程results.add(taskResult); } for(result?in?results){result.get();//阻塞等待任務執行完成 }提交的任務定義長這樣:
class?BizTask<T>?implements?Callable<T>??{private?String?session?=?null;public?BizTask(String?session)?{this.session?=?session;}@Overridepublic?T?call(){try?{ContextHolder.set(this.session);//?執行業務邏輯}?catch(Exception?e){//log?error}?finally?{ContextHolder.remove();?//?清理?ThreadLocal?的上下文,避免線程復用時context互串}return?null;} }對應的線程上下文管理類為:
class?ContextHolder?{private?static?ThreadLocal<String>?localThreadCache?=?new?ThreadLocal<>();public?static?void?set(String?cacheValue)?{localThreadCache.set(cacheValue);}public?static?String?get()?{return?localThreadCache.get();}public?static?void?remove()?{localThreadCache.remove();}}這么寫倒也沒有問題,我們再看看線程池的設置:
ThreadPoolExecutor?executorPool?=?new?ThreadPoolExecutor(20,?40,?30,?TimeUnit.SECONDS,?new?LinkedBlockingQueue<Runnable>(40),?new?XXXThreadFactory(),?ThreadPoolExecutor.CallerRunsPolicy);其中最后一個參數控制著當線程池滿時,該如何處理提交的任務,內置有4種策略
ThreadPoolExecutor.AbortPolicy?//直接拋出異常 ThreadPoolExecutor.DiscardPolicy?//丟棄當前任務 ThreadPoolExecutor.DiscardOldestPolicy?//丟棄工作隊列頭部的任務 ThreadPoolExecutor.CallerRunsPolicy?//轉串行執行可以看到,我們初始化線程池的時候指定如果線程池滿,則新提交的任務轉為串行執行,那我們之前的寫法就會有問題了,串行執行的時候調用ContextHolder.remove();會將主線程的上下文也清理,即使后面線程池繼續并行工作,傳給子線程的上下文也已經是null了,而且這樣的問題很難在預發測試的時候發現。
并行流中線程上下文丟失
如果ThreadLocal碰到并行流,也會有很多有意思的事情發生,比如有下面的代碼:
class?ParallelProcessor<T>?{public?void?process(List<T>?dataList)?{//?先校驗參數,篇幅限制先省略不寫dataList.parallelStream().forEach(entry?->?{doIt();});}private?void?doIt()?{String?session?=?ContextHolder.get();//?do?something} }這段代碼很容易在線下測試的過程中發現不能按照預期工作,因為并行流底層的實現也是一個ForkJoin線程池,既然是線程池,那ContextHolder.get()可能取出來的就是一個null。我們順著這個思路把代碼再改一下:
class?ParallelProcessor<T>?{private?String?session;public?ParallelProcessor(String?session)?{this.session?=?session;}public?void?process(List<T>?dataList)?{//?先校驗參數,篇幅限制先省略不寫dataList.parallelStream().forEach(entry?->?{try?{ContextHolder.set(session);//?業務處理doIt();}?catch?(Exception?e)?{//?log?it}?finally?{ContextHolder.remove();}});}private?void?doIt()?{String?session?=?ContextHolder.get();//?do?something} }修改完后的這段代碼可以工作嗎?如果運氣好,你會發現這樣改又有問題,運氣不好,這段代碼在線下運行良好,這段代碼就順利上線了。不久你就會發現系統中會有一些其他很詭異的bug。原因在于并行流的設計比較特殊,父線程也有可能參與到并行流線程池的調度,那如果上面的process方法被父線程執行,那么父線程的上下文會被清理。導致后續拷貝到子線程的上下文都為null,同樣產生丟失上下文的問題。
往期推薦額!Java中用戶線程和守護線程區別這么大?
線程的故事:我的3位母親成就了優秀的我!
Semaphore自白:限流器用我就對了!
CyclicBarrier:人齊了,老司機就發車了!
總結
以上是生活随笔為你收集整理的ThreadLocal中的3个大坑,内存泄露都是小儿科!的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: ruby 将字符转数字计算_Ruby程序
- 下一篇: notepad++ 偶数行_C ++程序