扒一扒抖音是如何做線程優化的

語言: CN / TW / HK

背景

最近在對一些大廠App進行研究學習,在對某音App進行研究時,發現其在線程方面做了一些優化工作,並且其解決的問題也是之前我在做線上卡頓優化時遇到的,因此對其具體實現方案做了深入分析。本文是對其相關源碼的研究加上個人理解的一個小結。

問題

創建線程卡頓

在Java中,真正的內核線程被創建是在執行 start函數的時候, nativeCreate的具體流程可以參考我之前的一篇分析文章 Android虛擬機線程啟動過程解析 。這裏假設你已經瞭解了,我們可以可以知道 start()函數底層涉及到一系列的操作,包括 棧內存空間分配、內核線程創建 等操作,這些操作在某些情況下可能出現長耗時現象,比如由於linux系統中,所有系統線程的創建在內核層是由一個專門的線程排隊實現,那麼是否可能由於隊列較長同時內核調度出現問題而出現長耗時問題? 具體的原因因為沒有在線下復現過此類問題,因此只能大膽猜測,不過在線上確實收集到一些case, 以下是線上收集到一個阻塞現場樣本:

那麼是不是不要直接在主線程創建其他線程,而是直接使用線程池調度任務就沒有問題? 讓我們看下 ThreadPoolExecutor.execute(Runnable command)的源碼實現

從文檔中可以知道,execute函數的執行在很多情況下會創建(JavaThread)線程,並且跟蹤其內部實現後可以發現創建Java線程對象後,也會立即在當前線程執行start函數。

來看一下線上收集到的一個在主線程使用線程池調度任務依舊發生卡頓的現場。

線程數過多的問題

在ART虛擬機中,每創建一個線程都需要為其分配獨立的Java棧空間,當Java層未顯示設置棧空間大小時,native層會在FixStackSize函數會分配默認的棧空間大小.

從這個實現中,可以看出每個線程至少會佔用1M的虛擬內存大小,而在32位系統上,由於每個進程可分配的用户用户空間虛擬內存大小隻有3G,如果一個應用的線程數過多,而當進程虛擬內存空間不足時,創建線程的動作就可能導致OOM問題.

另一個問題是某些廠商的應用所能創建的線程數相比原生Android系統有更嚴格的限制,比如某些華為的機型限制了每個進程所能創建的線程數為500, 因此即使是64位機型,線程數不做控制也可能出現因為線程數過多導致的OOM問題。

優化思路

線程收斂

首先在一個Android App中存在以下幾種情況會使用到線程

  • 通過 Thread類 直接創建使用線程
  • 通過 ThreadPoolExecutor 使用線程
  • 通過 ThreadTimer 使用線程
  • 通過 AsyncTask 使用線程
  • 通過 HandlerThread 使用線程

線程收斂的大致思路是, 我們會預先創建上述幾個類的實現類,並在自己的實現類中做修改, 之後通過編譯期的字節碼修改,將App中上述使用線程的地方都替換為我們的實現類。

使用以上線程相關類一般有幾種方式:

  1. 直接通過 new 原生類 創建相關實例
  2. 繼承原生類,之後在代碼中 使用 new 指令創建自己的繼承類實例

因此這裏的替換包括:

  • 修改類的繼承關係,比如 將所有 繼承 Thread類的地方,替換為 我們實現 的 PThread
  • 修改上述幾種類直接創建實例的地方,比如將代碼中存在 new ThreadPoolExecutor(..) 調用的地方替換為 我們實現的 PThreadPoolExecutor

通過字碼碼修改,將代碼中所有使用線程的地方替換為我們的實現類後,就可以在我們的實現類做一些線程收斂的操作。

Thread類 線程收斂

在Java虛擬機中,每個Java Thread 都對應一個內核線程,並且線程的創建實際上是在調用 start()函數才開始創建的,那麼我們其實可以修改start()函數的實現,將其任務調度到指定的一個線程池做執行, 示例代碼如下

class ThreadProxy : Thread() { override fun start() { SuperThreadPoolExecutor.execute({ [email protected]() }, priority = priority) } }

線程池 線程收斂

由於每個ThreadPoolExecutor實例內部都有獨立的線程緩存池,不同ThreadPoolExecutor實例之間的緩存互不干擾,在一個大型App中可能存在非常多的線程池,所有的線程池加起來導致應用的最低線程數不容小視。

另外也因為線程池是獨立的,線程的創建和回收也都是獨立的,不能從整個App的任務角度來調度。舉個例子: 比如A線程池因為空閒正在釋放某個線程,同時B線程池確可能正因為可工作線程數不足正在創建線程,如果可以把所有的線程池合併成 一個統一的大線程池,就可以避免類似的場景。

核心的實現思路為:

  1. 首先將所有直接繼承 ThreadPoolExecutor的類替換為 繼承 ThreadPoolExecutorProxy,以及代碼中所有new ThreadPoolExecutor(..)類 替換為 new ThreadPoolExecutorProxy(...)
  2. ThreadPoolExecutorProxy 持有一個 大線程池實例 BigThreadPool ,該線程池實例為應用中所有線程池共用,因此其核心線程數可以根據應用當前實際情況做調整,比如如果你的應用當前線程數平均是200,你可以將BigThreadPool 核心線程設置為150後,再觀察其調度情況。
  3. 在 ThreadPoolExecutorProxy 的 addWorker 函數中,將任務調度到 BigThreadPool中執行

AsyncTask 線程收斂

對於AsyncTask也可以用同樣的方式實現,在execute1函數中調度到一個統一的線程池執行

```

public abstract class AsyncTaskProxy extends AsyncTask{

private static final Executor THREAD_POOL_EXECUTOR = new PThreadPoolExecutor(0,20,
        3, TimeUnit.MILLISECONDS,
        new SynchronousQueue<>(),new DefaultThreadFactory("PThreadAsyncTask"));


public static void execute(Runnable runnable){
    THREAD_POOL_EXECUTOR.execute(runnable);
}

/**
 * TODO 使用插樁 將所有 execute 函數調用替換為 execute1
 * @param params  The parameters of the task.
 * @return This instance of AsyncTask.
 */
public AsyncTask<Params, Progress, Result> execute1(Params... params) {
    return executeOnExecutor(THREAD_POOL_EXECUTOR,params);
}

} ```

Timer類

Timer類一般項目中使用的地方並不多,並且由於Timer一般對任務間隔準確性有比較高的要求,如果收斂到線程池執行,如果某些Timer類執行的task比較耗時,可能會影響原業務,因此暫不做收斂。

卡頓優化

針對在主線程執行線程創建可能會出現的阻塞問題,可以判斷下當前線程,如果是主線程則調度到一個專門負責創建線程的線程進行工作。

``` private val asyncExecuteHandler by lazy { val worker = HandlerThread("asyncExecuteWorker") worker.start() return@lazy Handler(worker.looper) }

fun execute(runnable: Runnable, priority: Int) {
    if (Looper.getMainLooper().thread == Thread.currentThread() && asyncExecute
    ){
        //異步執行
        asyncExecuteHandler.post {
            mExecutor.execute(runnable,priority)
        }
    }else{
        mExecutor.execute(runnable, priority)
    }

}

```

32位系統線程棧空間優化

在問題分析中的環節中,我們已經知道 每個線程至少需要佔用 1M的虛擬內存,而32位應用的虛擬內存空間又有限,如果希望在線程這裏擠出一點虛擬內存空間來,可以參考微信的一個方案, 其利用PLT hook需改了創建線程時的棧空間大小。

而在另一篇 http://juejin.cn/post/7209306358582853688#heading-3 技術文章中,也介紹了另一個取巧的方案 :在Java層直接配置一個 負值,從而起到一樣的效果

OOM了? 我還能再搶救下!

針對在創建線程時由於內存空間不足或線程數限制拋出的OOM問題,可以做一些兜底處理, 比如將任務調度到一個預先創建的線程池進行排隊處理, 而這個線程池核心線程和最大線程是一致的 因此不會出現創建線程的動作,也就不會出現OOM異常了。

另外由於一個應用可能會存在非常多的線程池,每個線程池都會設置一些核心線程數,要知道默認情況下核心線程是不會被回收的,即使一直處於空閒狀態,該特性是由線程池的 allowCoreThreadTimeOut控制。

該參數值可通過 allowCoreThreadTimeOut(value) 函數修改

從具體實現中可以看出,當value值和當前值不同 且 value 為true時 會觸發 interruptIdleWorkers()函數, 在該函數中,會對空閒Worker 調用 interrupt來中斷對應線程

因此當創建線程出現OOM時,可以嘗試通過調用線程池的 allowCoreThreadTimeOut 來觸發 interruptIdleWorkers 實現空閒線程的回收。 具體實現代碼如下:

因此我們可以在每個線程池創建後,將這些線程池用弱引用隊列保存起來,當線程start 或者某個線程池execute 出現OOM異常時,通過這種方式來實現線程回收。

線程定位

線程定位 主要是指在進行問題分析時,希望直接從線程名中定位到創建該線程的業務,關於此類優化的文章網上已經介紹的比較多了,基本實現是通過ASM 修改調用函數,將當前類的類名或類名+函數名作為兜底線程名設置。這裏就不詳細介紹了,感興趣的可以看 booster 中的實現

字節碼修改工具

前文講了一些優化方式,其中涉及到一個必要的操作是進行字節碼修改,這些需求可以概括為如下

  • 替換類的繼承關係,比如將 所有繼承於 java.lang.Thread的類,替換為我們自己實現的 ProxyThread
  • 替換 new 指令的實例類型,比如將代碼中 所有 new Thread(..) 的調用替換為 new ProxyThread(...)

針對這些通用的修改,沒必要每次遇到類似需求時都 進行插件的單獨開發,因此我將這種修改能力集成到 LanceX插件中,我們可以通過以下 註解方便實現上述功能。

替換 new 指令

``` @Weaver @Group("threadOptimize") public class ThreadOptimize {

@ReplaceNewInvoke(beforeType = "java.lang.Thread",
afterType = "com.knightboost.lancetx.ProxyThread")
public static void replaceNewThread(){
}

} ```

這裏的 beforeType表示原類型,afterType 表示替換後的類型,使用該插件在項目編譯後,項目中的如下源碼

會被自動替換為

替換類的繼承關係

``` @Weaver @Group("threadOptimize") public class ThreadOptimize {

@ChangeClassExtends(
        beforeExtends = "java.lang.Thread",
        afterExtends = "com.knightboost.lancetx.ProxyThread"
)
public void changeExtendThread(){};

} ```

這裏的beforeExtends表示 原繼承父類,afterExtends表示修改後的繼承父類,在項目編譯後,如下源碼

會被自動替換為

總結

本文主要介紹了有關線程的幾個方面的優化

  • 主線程創建線程耗時優化
  • 線程數收斂優化
  • 線程默認虛擬空間優化
  • OOM優化

這些不同的優化手段需要根據項目的實際情況進行選擇,比如主線程創建線程優化的實現方面比較簡單、影響面也比較低,可以優先實施。 而線程數收斂需要涉及到字節碼插樁、各種對象代理 複雜度會高一些,可以根據當前項目的實際線程數情況再考慮是否需要優化。

線程OOM問題主要出現在低端設備 或一些特定廠商的機型上,可能對於某些大廠的用户基數來説有一定的收益,如果你的App日活並沒有那麼大,這個優化的優先級也是較低的。

性能優化專欄歷史文章:

| 文章 | 地址 | | --- | --- | | 監控Android Looper Message調度的另一種姿勢 |http://juejin.cn/post/7139741012456374279| | Android 高版本採集系統CPU使用率的方式 |http://juejin.cn/post/7135034198158475300| | Android 平台下的 Method Trace 實現及應用 |http://juejin.cn/post/7107137302043820039| | Android 如何解決使用SharedPreferences 造成的卡頓、ANR問題 |http://juejin.cn/post/7054766647026352158| | 基於JVMTI 實現性能監控 |http://juejin.cn/post/6942782366993612813|

參考資料

1.某音App

2.內核線程創建流程

3.http://juejin.cn/post/7209306358582853688 虛擬內存優化: 線程 + 多進程優化

4.http://github.com/didi/booster