虛擬內存優化:線程+多進程優化
在介紹內存的基礎知識的時候,我們講過在 32 位系統上虛擬內存只有 4G,因為有 1G 是給內核使用的,所以留給應用的只有 3G 了。3G 雖然看起來挺多,但依然會因為不夠用而導致應用崩潰。為什麼會這樣呢?
我們在學習 Java 堆的組成時就知道 MainSpace 會申請 512M 的虛擬內存,LargeObjectSpace 也會申請 512M 的虛擬內存,這就用掉了 1G 的虛擬內存,再加上其他 Space 和段映射申請的虛擬內存,如 bss 段、text 段以及各種 so 庫文件的映射等,這樣算下來,3G 的虛擬內存就沒剩下多少了。
所以,虛擬內存的優化,在提升程序的穩定性上,是一種很重要的方案。虛擬內存的優化手段也有很多,這一章我們主要介紹 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)。
這裏我就不繼續深入介紹 Linux 中線程的原理了,如果你有興趣可以參考這篇文章 《掌握 Android 和 Java 線程原理》。
除了通過線程的創建流程可以證明一個線程需要佔用 1M 大小的虛擬內存,我們還能在 maps 文件中證明這一點,還是拿前面篇章提到的“設置”這個系統應用的 maps 文件為例,也能發現 anno:stack_and_tls 也就是線程的虛擬內存,大小為 1M 左右。
理解了一個線程會佔用 1M 大小的虛擬內存,我們自然而然也能想到通過減少線程的數量和減少每個線程所佔用的虛擬內存大小來進行優化。接下來,我們就詳細瞭解一下如何實現這兩種方案。
減少線程數量
首先是減少線程的數量,我們主要有 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 就能實現需求。
|
---|---
在我們自定義的 ThreadFactory 中,創建 stack_size 為 - 512 kb 的線程,這麼一個簡單的操作就能減少線程所佔用的虛擬內存。
當我們將應用中線程棧的大小全改成 512 kb 後,可能會導致一些任務比較重的線程出現棧溢出,此時我們可以通過埋點收集會棧溢出的線程,不修改這部分線程的大小即可。總的來説,這是一個容易落地且投入產出比高的方案。
通過上面的方案介紹,我們也可以看到,減少一個線程所佔用的虛擬內存的方案很多,可以通過 Native Hook,也可以通過 Java 代碼直接修改。我們在做業務或者性能相關的工作時,往往都有多個實現方案,但是我們在敲定最終方案時,始終要選擇最簡單、最穩定且投入產出比最高的方案。
多進程架構優化
在 Java 堆內存優化中,我們已經講到了可以通過多進程優化,那對於虛擬內存,我們依然可以通過多進程的架構來優化。比如説,下面這些業務我都建議你放在獨立的進程中:
-
WebView 相關的業務
-
小程序相關的業務
-
Flutter 相關的業務
-
RN 相關的業務
這些業務都是虛擬內存佔用的大户,用獨立的進程來承載,會減少很多虛擬內存的佔用,也會減少相應的異常情況。並且,將這些業務放在子進程中也很簡單,只需要在承載這些業務的 activity 的 mainfest 配置文件中添加 android:process = "子進程名" 即可。需要注意的是,如果我們把業務放在子進程,就沒法直接和主進程通信了,需要藉助 Binder 跨進程通信的方式來完成。
當然,你還可能會擔心把這些業務放在獨立進程後,會影響這些業務的啟動速度,其實這都可以通過各種優化方案來解決,比如預啟動子進程等。在後面速度提升優化的章節中,我們會進行詳細講解。
小結
這一節課我們介紹了兩種虛擬內存優化方案,如下圖:
這兩種優化方案相對簡單,容易落地,投入產出比高。對於一箇中小型應用來説,這兩個方案几乎能保證 32 位手機上有足夠可用的虛擬內存了。如果這兩個方案落地後,還是會有因虛擬內存不足導致的應用崩潰問題,我們就需要接着用“黑科技”手段來進行優化了,所以在下一篇文章中,會接着帶大家看看有哪些“黑科技”可以用在虛擬內存優化上,它們又能帶來什麼樣的效果!