虛擬記憶體優化:執行緒+多程序優化

語言: CN / TW / HK

在介紹記憶體的基礎知識的時候,我們講過在 32 位系統上虛擬記憶體只有 4G,因為有 1G 是給核心使用的,所以留給應用的只有 3G 了。3G 雖然看起來挺多,但依然會因為不夠用而導致應用崩潰。為什麼會這樣呢?

我們在學習 Java 堆的組成時就知道 MainSpace 會申請 512M 的虛擬記憶體,LargeObjectSpace 也會申請 512M 的虛擬記憶體,這就用掉了 1G 的虛擬記憶體,再加上其他 Space 和段對映申請的虛擬記憶體,如 bss 段、text 段以及各種 so 庫檔案的對映等,這樣算下來,3G 的虛擬記憶體就沒剩下多少了。

所以,虛擬記憶體的優化,在提升程式的穩定性上,是一種很重要的方案。虛擬記憶體的優化手段也有很多,這一章我們主要介紹 3 種優化方案:

  1. 通過執行緒治理來優化虛擬記憶體;

  2. 通過多程序架構來優化虛擬記憶體;

  3. 通過一些“黑科技”手段來優化虛記憶體。

方案 1 和 2 相對簡單但效果更佳,投入產出比最高,也是我們最常用的。而方案 3 是通過多個“黑科技”的手段來完成虛擬記憶體的優化,這些手段雖然屬於“黑科技”,但還是會用到我們學過的 Native Hook 等技術,所以你理解、吸收起來並不會很難。

那今天我們先介紹 方案 1 和 方案 2 ,方案 3 會在下一章節單獨介紹,下面就開始這一章的學習吧。

執行緒治理

首先,為什麼治理執行緒能優化虛擬記憶體呢?實際上,即使是一個空執行緒也會申請 1M 的虛擬空間來作為棧空間大小,我們可以分析 Thread 建立的原始碼來驗證這一點。同時,對執行緒建立的分析,也能讓你能更好的理解後面的優化方案。

執行緒建立流程

當我們使用執行緒執行任務時,通常會先呼叫 new Thread(Runnable runnable) 來建立一個 Thread.java 物件的例項,Thread 的建構函式中會將 stackSize 這個變數設定為 0,這個 stackSize 變數決定了執行緒棧大小,接著我們便會執行 Thread 例項提供的 start 方法執行這個執行緒,start 方法中會呼叫 nativeCreate 這個 Native 函式在系統層建立一個執行緒並執行。

```Java Thread(ThreadGroup group, String name, int priority, boolean daemon) { …… this.stackSize = 0; }

public synchronized void start() { if (started) throw new IllegalThreadStateException(); group.add(this); started = false; try { nativeCreate(this, stackSize, daemon); started = true; } finally { try { if (!started) { group.threadStartFailed(this); } } catch (Throwable ignore) {

    }
}

} ```

通過上面 Start 函式的原始碼可以看到,nativeCreate 會傳入 stackSize。你可能想問,這個 stackSize 不是決定了執行緒棧空間的大小嗎?但是它現在的值為 0,那前面為什麼說執行緒有 1M 大小的棧空間呢?我們接著往下看就能知道答案了。

我們接著看 nativeCreate 的原始碼實現(),它的實現類是 java_lang_Thread.cc

```C++ static void Thread_nativeCreate(JNIEnv env, jclass, jobject java_thread, jlong stack_size, jboolean daemon) { Runtime runtime = Runtime::Current(); if (runtime->IsZygote() && runtime->IsZygoteNoThreadSection()) { jclass internal_error = env->FindClass("java/lang/InternalError"); CHECK(internal_error != nullptr); env->ThrowNew(internal_error, "Cannot create threads in zygote"); return; }

Thread::CreateNativeThread(env, java_thread, stack_size, daemon == JNI_TRUE); } ```

nativeCreate 會執行 Thread::CreateNativeThread 函式,這個函式才是最終建立執行緒的地方,它的實現在 Thread.cc 這個物件中,並且在這個函式中會呼叫 FixStackSize 方法將 stack_size 調整為 1M,所以前面那個疑問在這裡就解決了,即使我們將 stack_size 設定為 0,這裡依然會被調整。我們繼續往下分析,看看一個執行緒究竟是怎樣被創建出來的?

```C++ void Thread::CreateNativeThread(JNIEnv* env, jobject java_peer, size_t stack_size, bool is_daemon) { …… // 調整 stack_size,預設值為 1 M stack_size = FixStackSize(stack_size); ……

if (child_jni_env_ext.get() != nullptr) { pthread_t new_pthread; pthread_attr_t attr; child_thread->tlsPtr_.tmp_jni_env = child_jni_env_ext.get(); CHECK_PTHREAD_CALL(pthread_attr_init, (&attr), "new thread"); CHECK_PTHREAD_CALL(pthread_attr_setdetachstate, (&attr, PTHREAD_CREATE_DETACHED), "PTHREAD_CREATE_DETACHED"); CHECK_PTHREAD_CALL(pthread_attr_setstacksize, (&attr, stack_size), stack_size); // 建立執行緒 pthread_create_result = pthread_create(&new_pthread, &attr, Thread::CreateCallback, child_thread); CHECK_PTHREAD_CALL(pthread_attr_destroy, (&attr), "new thread");

if (pthread_create_result == 0) {
  child_jni_env_ext.release();  // NOLINT pthreads API.
  return;
}

}

…… } ```

在上面簡化後的程式碼中我們可以看到,CreateNativeThread 的原始碼實現最終呼叫的是 pthread_create 函式,它是一個 Linux 函式,而 pthread_create 函式最終會呼叫 clone 這個核心函式。clone 函式會根據傳入的 stack 大小,通過 mmap 函式申請一塊對應大小的虛擬記憶體,並且建立一個程序。

C++ int clone(int (*fn)(void * arg), void *stack, int flags, void *arg);

所以,對於 Linux 系統來說,一個執行緒實際是一個精簡的程序。我們建立執行緒時,最終會執行 clone 這個核心函式去建立一個程序,通過檢視官方文件也能看到,Clone 函式實際上會建立一個新的程序(These system calls create a new ("child") process, in a manner similar to fork)。

image.png

這裡我就不繼續深入介紹 Linux 中執行緒的原理了,如果你有興趣可以參考這篇文章 《掌握 Android 和 Java 執行緒原理》。

除了通過執行緒的建立流程可以證明一個執行緒需要佔用 1M 大小的虛擬記憶體,我們還能在 maps 檔案中證明這一點,還是拿前面篇章提到的“設定”這個系統應用的 maps 檔案為例,也能發現 anno:stack_and_tls 也就是執行緒的虛擬記憶體,大小為 1M 左右。

image.png

理解了一個執行緒會佔用 1M 大小的虛擬記憶體,我們自然而然也能想到通過減少執行緒的數量和減少每個執行緒所佔用的虛擬記憶體大小來進行優化。接下來,我們就詳細瞭解一下如何實現這兩種方案。

減少執行緒數量

首先是減少執行緒的數量,我們主要有 2 種手段:

  1. 在應用中使用統一的執行緒池;

  2. 將應用中的野執行緒及野執行緒池進行收斂。

Java 開發者應該都知道執行緒池,但有的人認知可能不深。實際上,執行緒池是非常重要的知識點,需要我們熟悉並能熟練使用的。執行緒池對應用的效能提升有很大的幫助,它可以幫助我們更高效和更合理地使用執行緒,提升應用的效能。但這裡就不詳細介紹執行緒池的使用了,在後面的章節中我們會深入來講執行緒池的使用。如果你不熟悉執行緒池,那我建議你儘快熟悉起來,這裡主要針對如何減少執行緒數這個方向,介紹一下執行緒池中執行緒數量的最優設定。

對於執行緒池,我們需要手動設定核心執行緒數和最大執行緒數。核心執行緒是不會退出的執行緒,被執行緒池建立之後會一直存在。最大執行緒數是該執行緒池最大能達到的執行緒數量,當達到最大執行緒數後,執行緒池處理新的任務便當做異常,放在兜底邏輯中處理。那麼,這兩個執行緒數設定成多少比較合適呢?這個問題也經常作為面試題,需要引起注意。

執行緒池可以分為 CPU 執行緒池和 IO 執行緒池,CPU 執行緒池用來處理 CPU 型別的任務,如計算,邏輯等操作,需要能夠迅速響應,但任務耗時又不能太久。那些耗時較久的任務,如讀寫檔案、網路請求等 IO 操作便用 IO 執行緒池來處理,IO 執行緒池專門處理耗時久,響應又不需要很迅速的任務。因此,對於 CPU 的執行緒池,我們會將核心執行緒數設定為該手機的 CPU 核數,理想狀態下每一個核可以執行一個執行緒,這樣能減少 CPU 執行緒池的排程損耗又能充分發揮 CPU 效能。

至於 CPU 執行緒池的最大執行緒數,和核心執行緒數保持一致即可。 因為當最大執行緒數超過了核心執行緒數時,反倒會降低 CPU 的利用率,因為此時會把更多的 CPU 資源用於執行緒排程上,如果 CPU 核數的執行緒數量無法滿足我們的業務使用,很大可能就是我們對 CPU 執行緒池的使用上出了問題,比如在 CPU 執行緒中執行了 IO 阻塞的任務。

對於 IO 執行緒池,我們通常會將核心執行緒數設定為 0 個,而且 IO 執行緒池並不需要響應的及時性,所以將常駐執行緒設定為 0 可以減少該應用的執行緒數量。但並不是說這裡一定要設定為 0 個,如果我們的業務 IO 任務比較多,這裡也可以設定為不大於 3 個數量。對於 IO 執行緒池的最大執行緒數,則可以根據應用的複雜度來設定,如果是中小型應用且業務較簡單設定 64 個即可,如果是大型應用,業務多且複雜,可以設定成 128 個

可以看到,如果業務中所有的執行緒都使用公共執行緒池,那即使我們將執行緒的數量設定得非常寬裕,所有執行緒加起來所佔用的虛擬記憶體也不會超過 200 M。但現實情況下是,應用中總會有大量地方不遵守規範,獨自建立執行緒或者執行緒池,我們稱之為野執行緒或者野執行緒池。那如何才能收斂野執行緒和野執行緒池呢?

對於簡單的應用,我們一個個排查即可,通過全域性搜尋 new Thread() 執行緒建立程式碼,以及全域性搜尋 newFixedThreadPool 執行緒池建立程式碼,然後將不合規範的程式碼,進行修改收斂進公共執行緒池即可。

但如果是一箇中大型應用,還大量使用了二方庫、三方庫和 aar 包等,那全域性搜尋也不管用了,這個時候就需要我們使用位元組碼操作的方式了,技術方案還是前面文章介紹過的 Lancet,通過 hook 住 newFixedThreadPool 建立執行緒池的函式,並在函式中將執行緒池的建立替換成我們公共的執行緒池,就能完成對執行緒池的收斂。

```Java public class ThreadPoolLancet {

@TargetClass("java.util.concurrent.Executors")
@Proxy(value = "newFixedThreadPool")
public static ExecutorService newFixedThreadPool(int nThreads, ThreadFactory threadFactory) {
    // 替換並返回我們的公共執行緒池
    ……
}

@TargetClass("java.util.concurrent.Executors")
@Proxy(value = "newFixedThreadPool")
public static ExecutorService newFixedThreadPool(int nThreads) {
   // 替換並返回我們的公共執行緒池
    ……
}

} ```

收斂完了野執行緒池,那直接使用 new Thread() 建立的野執行緒又該怎麼收斂呢? 對於三方庫中的野執行緒,我們沒有太好的收斂手段,因為即使 Thread 的建構函式被 hook 住了,也不能將其收斂到公共執行緒池中。好在我們使用的三方庫大都已經很成熟並經過大量使用者驗證過,直接使用野執行緒的地方會很少。我們可以採用 hook 住 Thread 的建構函式並列印堆疊的方式,來確定這個執行緒是不是通過執行緒池創建出來的,如果三方庫中確實有大量的野執行緒,那麼我們只能將原始碼下載下來之後手動修改了。

減少執行緒佔用的虛擬記憶體

在剛才講解 CreateNativeThread 原始碼的時候我們講過,該函式會執行 FixStackSize 方法將 stack_size 調整為 1M。那結合前面各種 hook 的案例,我們很容易就能想到,通過 hook FixStackSize 這個函式,是不是可以將 stack_size 的從 1M 減少到 512 KB 了呢? 當時是可以的,但是這個時候我們沒法通過 PLT Hook 的方案來實現了,而是要通過 Inline Hook 方案實現,因為 FixStackSize 是 so 庫內部函式的呼叫,所以只有 FixStackSize 才能實現。

那如果我們想用 PLT Hook 方案來實現可以做到麼?其實也可以。CreateNativeThread 是位於 libart.so 中的函式,但是 CreateNativeThread 實際是呼叫 pthread_create 來建立執行緒的,而 pthread_create 是位於 libc.so 庫中的函式,如果在 CreateNativeThread 中呼叫 pthread_create ,同樣需要通過走 plt 表和 got 表查詢地址的方式,所以我們通過 bhook 工具 hook 住 libc.so 庫中的 pthread_create 函式,將入參 &attr 中的 stack_size 直接設定成 512KB 即可,實現起來也非常簡單,一行程式碼即可。

C++ static int AdjustStackSize(pthread_attr_t const* attr) { pthread_attr_setstacksize(attr, 512 * 1024); }

至於如何 hook 住 pthread_create 這個函式的方法也非常簡單,通過 bhook 也是一行程式碼就能實現,前面的篇章已經講過怎麼使用了,所以這個方案剩下的部分就留給你自己去實踐啦。

除了 Native Hook 方案,我們還能在 Java 層通過位元組碼操作的方式來實現該方案。stack_size 不就是通過 Java 層傳遞到 Native 層嘛,那我們直接在 Java 層調整 stack_size 的大小就可以了,但在這之前之前,要先看看在 FixStackSize 函式中是如何調整 stack_size 大小的。

```C++ static size_t FixStackSize(size_t stack_size) {

if (stack_size == 0) { stack_size = Runtime::Current()->GetDefaultStackSize(); }

stack_size += 1 * MB;

……

return stack_size; } ```

FixStackSize 函式的原始碼實現很簡單,就是通過 stack_size += 1 * MB 來設定 stack_size 的:如果我們傳入的 stack_size 為 0 時,預設大小就是 1 M ;如果我們傳入的 stack_size 為 -512KB 時,stack_size 就會變成 512KB(1M - 512KB)。那我們是不是隻用帶有 stackSize 入參的建構函式去建立執行緒,並且設定 stackSize 為 -512KB 就行了呢?

Java public Thread(ThreadGroup group, Runnable target, String name, long stackSize) { this(group, target, name, stackSize, null, true); }

是的,但是因為應用中建立執行緒的地方太多很難一一修改,而且我們實際不需要這樣去修改。前面我們已經將應用中的執行緒全部收斂到公共執行緒池中去建立了,所以只需要修改公共執行緒池中建立的執行緒方式就可以了,並且執行緒池剛好也可以讓我們自己建立執行緒,那隻需要傳入自定義的 ThreadFactory 就能實現需求。

image.png|image.png ---|---

在我們自定義的 ThreadFactory 中,建立 stack_size 為 - 512 kb 的執行緒,這麼一個簡單的操作就能減少執行緒所佔用的虛擬記憶體。

image.png

當我們將應用中執行緒棧的大小全改成 512 kb 後,可能會導致一些任務比較重的執行緒出現棧溢位,此時我們可以通過埋點收集會棧溢位的執行緒,不修改這部分執行緒的大小即可。總的來說,這是一個容易落地且投入產出比高的方案。

通過上面的方案介紹,我們也可以看到,減少一個執行緒所佔用的虛擬記憶體的方案很多,可以通過 Native Hook,也可以通過 Java 程式碼直接修改。我們在做業務或者效能相關的工作時,往往都有多個實現方案,但是我們在敲定最終方案時,始終要選擇最簡單、最穩定且投入產出比最高的方案。

多程序架構優化

在 Java 堆記憶體優化中,我們已經講到了可以通過多程序優化,那對於虛擬記憶體,我們依然可以通過多程序的架構來優化。比如說,下面這些業務我都建議你放在獨立的程序中:

  1. WebView 相關的業務

  2. 小程式相關的業務

  3. Flutter 相關的業務

  4. RN 相關的業務

這些業務都是虛擬記憶體佔用的大戶,用獨立的程序來承載,會減少很多虛擬記憶體的佔用,也會減少相應的異常情況。並且,將這些業務放在子程序中也很簡單,只需要在承載這些業務的 activity 的 mainfest 配置檔案中新增 android:process = "子程序名" 即可。需要注意的是,如果我們把業務放在子程序,就沒法直接和主程序通訊了,需要藉助 Binder 跨程序通訊的方式來完成。

當然,你還可能會擔心把這些業務放在獨立程序後,會影響這些業務的啟動速度,其實這都可以通過各種優化方案來解決,比如預啟動子程序等。在後面速度提升優化的章節中,我們會進行詳細講解。

小結

這一節課我們介紹了兩種虛擬記憶體優化方案,如下圖:

image.png

這兩種優化方案相對簡單,容易落地,投入產出比高。對於一箇中小型應用來說,這兩個方案几乎能保證 32 位手機上有足夠可用的虛擬記憶體了。如果這兩個方案落地後,還是會有因虛擬記憶體不足導致的應用崩潰問題,我們就需要接著用“黑科技”手段來進行優化了,所以在下一篇文章中,會接著帶大家看看有哪些“黑科技”可以用在虛擬記憶體優化上,它們又能帶來什麼樣的效果!