項目地址:github.com/didi/booste…
對于開發者來說,線程管理一直是最頭疼的問題之一,尤其是業務復雜的 APP,每個業務模塊都有著幾十甚至上百個線程,而且,作為業務方,都希望本業務的線程優先級最高,能夠在調度的過程中獲得更多的 CPU 時間片,然而,過多的競爭意味著過多的資源浪費在了線程調度上。
如何能有效的解決上述的多線程管理問題呢?大多數人可能想到的是「使用統一的線程管理庫」,當然,這是最理想的情況,而往往現實并非總是盡如人意。隨著業務的高速迭代,積累的技術債也越來越多,面對錯綜復雜的業務邏輯和歷史遺留問題,架構師如何從容應對?
在此之前,我們通過對線程進行埋點監控,發現了以下的現象:
- 在某種場景下會無限制的創建新線程,最終導致 OOM
- 在某一時間應用內的線程數達到數百甚至上千
- 即使在空閑的時候,線程池中的線程一直在 WAITING ,一直不會銷毀
這些現象最終導致的問題是:
- OOM
- 無法分辨出線程所屬的業務線,導致排查問題效率低下
針對這些問題,如果采用上面提到的「統一線程管理庫」的方案,對于業務方來說,任何大范圍的改造都意味著風險和成本,那有沒有低成本的解決方案呢?經過反復思考和論證,最終我們選擇了字節碼注入方案,具體思路是:
-
對線程進行重命名
重命名線程的主要目的是為了區分該線程是由哪個模塊、哪個業務線創建的,這樣,線程監控埋點的聚合能夠做到更加精確
-
對線程池的參數進行調優
- 限制線程池的
minPoolSize
和maxPoolSize
- 允許核心線程在空閑的時候自動銷毀
- 限制線程池的
線程重命名
經過分析發現,APP 中的線程創建主要是通過以下幾種方式:
Thread
及其子類TheadPoolExecutor
及其子類、Executors
、ThreadFactory
實現類AsyncTask
Timer
及其子類
以 Thread 類為例,可以通過以下構造方法進行線程的實例化:
Thread()
Thread(runnable: Runnable)
Thread(group: ThreadGroup, runnable: Runnable)
Thread(name: String)
Thread(group: ThreadGroup, name: String)
Thread(runnable: Runnable, name: String)
Thread(group: ThreadGroup, runnable: Runnable, name: String)
Thread(group: ThreadGroup, runnable: Runnable, name: String, stackSize: long)
我們的目標就是將以上這些方法調用替換成對應的 ShadowThread 的靜態方法:
-
ShadowThread.newThread(prefix: String)
public static Thread newThread(final String prefix) {return new Thread(prefix); } 復制代碼
-
ShadowThread.newThread(target: Runnable, prefix: String)
public static Thread newThread(final Runnable target, final String prefix) {return new Thread(target, prefix); } 復制代碼
-
ShadowThread.newThread(group: ThreadGroup, target: Runnable, prefix: String)
public static Thread newThread(final ThreadGroup group, final Runnable target, final String prefix) {return new Thread(group, target, prefix); } 復制代碼
-
ShadowThread.newThread(name: String, prefix: String)
public static Thread newThread(final String name, final String prefix) {return new Thread(makeThreadName(name, prefix)); } 復制代碼
-
ShadowThread.newThread(group: ThreadGroup, name: String, prefix: String)
public static Thread newThread(final ThreadGroup group, final String name, final String prefix) {return new Thread(group, makeThreadName(name, prefix)); } 復制代碼
-
ShadowThread.newThread(target: Runnable, name: String, prefix: String)
public static Thread newThread(final Runnable target, final String name, final String prefix) {return new Thread(target, makeThreadName(name, prefix)); } 復制代碼
-
ShadowThread.newThread(group: ThreadGroup, target: Runnable, name: String, prefix: String)
public static Thread newThread(final ThreadGroup group, final Runnable target, final String name, final String prefix) {return new Thread(group, target, makeThreadName(name, prefix)); } 復制代碼
-
ShadowThread.newThread(group: ThreadGroup, target: Runnable, name: String, prefix: String)
public static Thread newThread(final ThreadGroup group, final Runnable target, final String name, final long stackSize, final String prefix) {return new Thread(group, target, makeThreadName(name, prefix), stackSize); } 復制代碼
細心的讀者可能會發現,ShadowThread
類的這些靜態方法的參數比替換之前多了一個 prefix
,其實,這個 prefix
就是調用 Thread
的構造方法的類的 className,而這個類名,是在 Transform 的過程中掃描出來的,下面用一個簡單的例子來說明,比如我們有一個 MainActivity 類:
package com.didiglobal.booster.demo;public class MainActivity extends AppCompatActivity {public void onCreate(Bundle savedInstanceState) {new Thread(new Runnable() {public void run() {doSomething();}}).start();}}
復制代碼
在未重命名之前,其創建的線程的命名是 Thread-{N},為了能讓 APM 采集到的名字變成 com.didiglobal.booster.demo.MainActivity#Thread-{N},我們需要給線程的名字加一個前綴來標識,這個前綴就是 ShadowThread
的靜態方法的最后一個參數 prefix
的來歷。
線程池參數優化
理解了線程重命名的實現原理,線程池參數優化也就能理解了,同樣也是將調用 ThreadPoolExecutor
類的構造方法替換為 ShadowThreadPoolExecutor 的靜態方法,如下所示:
public static ThreadPoolExecutor newThreadPoolExecutor(final int corePoolSize, final int maxPoolSize, final long keepAliveTime, final TimeUnit unit, final BlockingQueue<Runnable> workQueue, final String name) {final ThreadPoolExecutor executor = new ThreadPoolExecutor(1, MAX_POOL_SIZE, keepAliveTime, unit, workQueue, new NamedThreadFactory(name));executor.allowCoreThreadTimeOut(keepAliveTime > 0);return executor;
}
復制代碼
以上示例中,將線程池的核心線程數設置為 0
,最大線程數設置為 MAX_POOL_SIZE
[1],并且,允許核心線程在空閑時銷毀,避免空閑線程占用過多的內存資源。
JDK Bug
經過以上對線程池的優化后中,我們信心滿滿的的準備灰度發布,但是,當我們在進行功耗測試時,發現 CPU 負載異常竟然高達 60%以上,經過一步步排查,最終發現問題出在 ScheduledThreadPool
的 minPoolSize
上,竟然命中了 JDK 的兩個 bug,而且這兩個 bug 直到 JDK 9 才修復:
- JDK-8022642
- JDK-8129861
這也就是為什么我們將 ScheduledThreadPool
的 minPoolSize
設置為了 1
的原因。
總結
針對多線程的優化主要是以下兩個關鍵點:
- 將目標方法調用指令替換為注入的靜態方法調用
- 在靜態方法中構造優化過的線程、線程池實例并返回
當然,以上的優化方案比較偏保守,主要是考慮到盡可能降低優化帶來的副作用,這也跟 APP 的應用場景有關,大家可以根據自身的業務需求進行相應的調整。
??