「每次接手維護五、六年前的 Android 舊專案,十個有九個會在
util資料夾深處看到一個全域單例ThreadPoolManager。別笑,因為這八成也是我當年剛升 Senior 時寫出來的『傳家寶』——當時以為封裝了執行緒池就是架構的頂峰,直到線上崩潰日誌天天在半夜把我叫醒。」

程式設計師的成長往往伴隨著自我嫌棄。如果你回頭看自己三年前寫的程式碼,竟然覺得寫得無懈可擊,那很遺憾,這代表你這三年來根本沒什麼長進。
在 2016 到 2019 年那段 RxJava 尚未全面普及、Kotlin 協程還在娘胎裡的年代,Android 開發者面臨著極其野蠻的並行困境:UI 執行緒卡頓 5 秒就直接噴出 ANR(Application Not Responding)。
為了解決「耗時任務不能放主執行緒」的問題,工程師們各顯神通。而最常見、流傳最廣的祖傳方案,莫過於手寫一個全域單例 ThreadPoolManager。
但這個看似拯救了系統資源的「萬靈丹」,在專案規模膨脹後,直接變成了線上災難的源頭。
歷史切片:人人都在寫的 ThreadPoolManager 到底長怎樣?
翻開舊專案的骨董庫,標準的 ThreadPoolManager 通常長成這個模樣:
public class ThreadPoolManager {
private static volatile ThreadPoolManager sInstance;
private final ThreadPoolExecutor mExecutor;
private final Handler mMainHandler = new Handler(Looper.getMainLooper());
private ThreadPoolManager() {
int corePoolSize = Runtime.getRuntime().availableProcessors() * 2 + 1;
// (笑死,反正無界佇列永遠塞不滿,maximumPoolSize 設成 64 基本上就是寫給面試官看的安慰劑)
int maximumPoolSize = 64;
long keepAliveTime = 60L;
// 致命原罪:無界佇列 LinkedBlockingQueue
mExecutor = new ThreadPoolExecutor(
corePoolSize,
maximumPoolSize,
keepAliveTime,
TimeUnit.SECONDS,
new LinkedBlockingQueue<Runnable>(),
new ThreadPoolExecutor.CallerRunsPolicy()
);
}
public static ThreadPoolManager getInstance() {
if (sInstance == null) {
synchronized (ThreadPoolManager.class) {
if (sInstance == null) {
sInstance = new ThreadPoolManager();
}
}
}
return sInstance;
}
public void execute(Runnable runnable) {
mExecutor.execute(runnable);
}
public Future<?> submit(Runnable runnable) {
return mExecutor.submit(runnable);
}
public <T> Future<T> submit(Callable<T> callable) {
return mExecutor.submit(callable);
}
public void postToMain(Runnable runnable) {
mMainHandler.post(runnable);
}
}
這個類別看起來四平八穩:雙重檢查鎖定(DCL)單例、依據 CPU 核心數動態計算核心執行緒、甚至還配了個回到主執行緒的 mMainHandler。(笑死,當年剛升 Senior 自以為很屌,還沾沾自喜寫進履歷;連 synchronized 該不該包在 if 裡面,我都能跟隔壁座位的同事吵半天。)
當年大家都是這樣寫的,甚至很多公司的面試官還會考你這段 DCL 單例與執行緒池參數。但只要專案一上線,各種光怪陸離的線上事故就會接踵而來。
原罪一:每 new 一條 Thread,你都在燃燒實體記憶體
在深入剖析架構前,我們先算一筆最殘酷的實體記憶體與 CPU 排程赤字。
很多高階語言開發者以為執行緒就像物件一樣便宜,new Thread() 呼叫一下就過去了。但在 Linux / Android 底層,Java 執行緒是透過 Bionic C 函式庫的 clone() 系統呼叫直接映射到 Linux 核心的輕量級行程(Light-weight Process, LWP):
[Linux Kernel task_struct (~2KB)]
│
├── [虛擬記憶體 VMA 配置]
└── [pthread 實體記憶體堆疊 (Thread Stack: 預設約 1024 KB)]
每 new 一條執行緒,作業系統就必須在核心空間配置 task_struct,並在虛擬記憶體中切出一段 Thread Stack(Android 預設約為 1024KB = 1MB)。
這筆實體記憶體赤字算下來有多慘烈:
- 50 條閒置執行緒:光是發呆站著,就無條件佔據至少 50MB 的記憶體空間。在 2GB RAM 的低階裝置(扣除硬體保留區後可用不足 1GB)上,這 50MB 相當於直接吞掉一整套相機管線的緩衝區。
- 執行緒暴增引發崩潰:當突發流量湧入,執行緒無節制增加時,很快就會觸發作業系統的記憶體限制,當場拋出致命的
java.lang.OutOfMemoryError: pthread_create (1040KB stack) failed: Try again,讓 App 秒退。
更可怕的是 Context Switch(上下文切換) 的算力反噬:
- 四核心 Cortex-A53 處理器一次只能真正平行運算 4 條執行緒。
- 當幾十條執行緒在背景爭奪 CPU 時間片時,CPU 必須以微秒級頻率不斷將暫存器狀態寫入記憶體、清空 CPU Pipeline、切換頁表(Page Table)。
- 頻繁的排程切換直接引發 L1/L2 快取抖動(Cache Thrashing)與 TLB Miss。結果 CPU 算力根本沒花在跑業務邏輯上,全耗在「排程切換」的無效開銷裡,導致前台 UI 執行緒直接卡死掉幀。
原罪二:Google 當年的 AsyncTask 到底有多搞笑?
很多人說:「既然手寫 Thread 這麼慘,那用官方的 AsyncTask 總行了吧?」
回顧 Android 歷史,官方的 AsyncTask 簡直是一部跨版本精神分裂史:
| 系統版本 | AsyncTask 預設行為 | 隱含致命地雷 |
|---|---|---|
| Android 1.5 | 單執行緒循序執行 | 全域排隊,前一個卡住後面全死。 |
| Android 1.6 ~ 2.3 | 執行緒池平行執行 (Core=5, Queue=10, Max=128) |
只要瞬間發出超過 138 個任務,第 139 個直接噴出 RejectedExecutionException 當場崩潰! |
| Android 3.0 ~ 10 | 認慫縮回單執行緒串列 (SERIAL_EXECUTOR) |
再次回歸全 App 排同一個佇列的噩夢。 |
| Android 11 (API 30) | 官方宣告正式廢棄(Deprecated) | 退出歷史舞台,全面推薦協程或 RxJava。 |
在 Android 3.0 之後的漫長歲月裡,AsyncTask.execute() 預設共用全域唯一的 SERIAL_EXECUTOR:
- 全 App 共享單一佇列:模組 A 在背景做一個需要 30 秒的肥大資料庫整理;此時模組 B 在另一個畫面只想讀 2KB 的本地快取,也必須在後面乖乖罰站 30 秒!
- 旋轉螢幕引發記憶體洩漏:
AsyncTask往往寫成 Activity 的匿名內部類別(Anonymous Inner Class),隱式持有外部 Activity 的強引用。使用者旋轉一下螢幕,舊 Activity 銷毀了卻無法被 GC 回收,直接引發幾十 MB 的記憶體洩漏。 cancel(true)只是個心理安慰劑:呼叫cancel(true)底層執行緒根本不會立刻終止,只是把isCancelled()旗標設為 true,並嘗試呼叫中斷。如果開發者在doInBackground裡沒寫條件檢查,背景迴圈依然會吃到天荒地老,只是跑完不觸發onPostExecute回呼而已。
原罪三:全域單例 ThreadPoolManager 的深水區三大「真地雷」
既然官方的 AsyncTask 靠不住,開發者才轉向封裝自己的 ThreadPoolManager。但一個全域單例池,卻把整個專案拖入了更大的並行深水區。
後端工程師面試愛考的死鎖,在 Android 上其實很少見,因為只要主執行緒卡死 5 秒,系統就直接噴出 ANR 彈窗把 App 強制關掉了。在日常專案中,天天在 Crashlytics 上榜的,全是以下三大「真地雷」:
1. 搜尋框與高頻點擊時的「時序錯亂舊蓋新」
這是非同步介面最普遍的邏輯漏洞:
使用者輸入 "A" ──(發送請求 A, 網路卡頓耗時 2.0s)──────────────────────────► [結果 A 姍姍來遲,強制覆蓋新畫面!]
使用者輸入 "AB" ─────(發送請求 AB, 命中快取耗時 0.2s) ──► [畫面顯示 AB] ───┘
- 使用者在搜尋框輸入「A」,發出請求 A(網路卡頓,耗時 2.0 秒);
- 緊接著輸入「AB」,發出請求 AB(命中本地快取,耗時 0.2 秒);
- 0.2 秒後,介面漂亮地渲染出「AB」的搜尋結果;
- 到了第 2.0 秒,請求 A 的舊結果慢吞吞地回傳了,直接把正確的「AB」畫面無情沖刷覆蓋掉!
加鎖(synchronized)有用嗎?完全沒用!鎖只管互斥,不管誰先誰後。使用者看著搜尋框寫著「AB」,下面卻列出「A」的搜尋清單,一臉問號。
2. 離開畫面後回呼 View 的 Detach 崩潰(IllegalStateException)
在 ThreadPoolManager 時代,最典型的回呼寫法是:
// 在 Fragment 或 Presenter 內
ThreadPoolManager.getInstance().execute(() -> {
User user = mDatabase.loadUser(userId); // 背景耗時查詢
ThreadPoolManager.getInstance().postToMain(() -> {
// 使用者早就按返回退出了!
requireActivity().showUserDetails(user);
});
});
當背景查詢在跑時,使用者等得不耐煩按了返回鍵。Fragment 已經被 Detach,甚至 Activity 都已經呼叫 onDestroy() 銷毀了。
背景工作跑完,興高采烈地用 postToMain() 衝進主執行緒,一呼叫 requireActivity() 或更新介面元件,立刻噴出:
java.lang.IllegalStateException: Fragment not attached to an Activity.
直接當場閃退給你看。
3. 執行緒飢餓死鎖(Thread Starvation Deadlock)與例外吞沒
這是在大中型專案中共用執行緒池最隱蔽的致命陷阱。
陷阱 A:submit() 悄悄把例外吞進黑洞
如果你用 executor.submit(Runnable) 取代 execute(Runnable),當任務內部拋出 NullPointerException 或 RuntimeException 時,控制台連一行錯誤 Log 都不會印出!
因為例外被包進了回傳的 FutureTask 裡;只要沒有人呼叫 future.get(),這個崩潰就會無聲無息地消失在虛空之中,功能莫名失效卻死活抓不到原因。
陷阱 B:父子任務共享同一個池引發飢餓死鎖
假設你的全域執行緒池核心數設為 4:
// 任務 A 佔據了全部 4 個核心執行緒
for (int i = 0; i < 4; i++) {
ThreadPoolManager.getInstance().execute(() -> {
// 內部又向同一個池子派發子任務 B,並阻塞等待其結果
Future<?> subFuture = ThreadPoolManager.getInstance().submit(() -> doSubTask());
subFuture.get(); // 阻塞等待子任務完成!
});
}
4 個父任務把池子塞滿了,全部卡在 subFuture.get() 等待子任務回傳。但子任務全部卡在 LinkedBlockingQueue 裡,根本分不到任何一條執行緒來執行!
父等子釋放執行緒,子等父空出名額——全域執行緒池永久性死鎖,整個 App 的背景任務自此徹底癱瘓。
現代 Android 怎麼寫:Kotlin 協程與 Flow 的降維打擊
現在 Google 官方與業界早已全面封殺手寫全域 ThreadPoolManager,轉向 Kotlin Coroutines(協程)與 Flow。這不是盲目跟風,而是用精確的語言級編譯器狀態機,徹底終結上述歷史原罪:
flowchart TD
subgraph Coroutine_Architecture ["現代 Android 並行架構"]
direction TB
VMS["viewModelScope (生命週期自動綁定)"]
subgraph Flow_Pipeline ["搜尋與事件流處理"]
direction LR
INPUT["高頻輸入事件"] --> DEBOUNCE["debounce(300ms) 防抖動"]
DEBOUNCE --> LATEST["collectLatest { ... }"]
end
subgraph Dispatchers_Split ["底層執行緒分流"]
direction LR
DEF["Dispatchers.Default\n(運算密集型: 精準綁定 CPU 核心數)"]
IO["Dispatchers.IO\n(阻塞型 I/O: 彈性伸縮最大 64 條)"]
end
VMS --> Flow_Pipeline
Flow_Pipeline --> Dispatchers_Split
end
1. 算力與 I/O 物理級嚴格分流
現代架構不再將所有工作混雜在同一個全域池子裡,而是交給專門的任務排程器(Dispatcher):
Dispatchers.Default:專供矩陣計算、JSON 大解析等 CPU 密集運算。執行緒數量嚴格綁定 CPU 實體核心數(四核 QM215 就是 4 條),絕不多開,從根本消除上下文切換(Context Switch)的快取抖動。Dispatchers.IO:專供網路請求與資料庫查詢等阻塞性任務。底層由彈性執行緒池支援(上限 64 條或 CPU 核心數取大值),並與Dispatchers.Default共用底層執行緒排程,實現 Work-Stealing(工作竊取演算法)。
2. 結構化並行(Structured Concurrency)滅絕 Detach 閃退
忘掉手動維護的 mMainHandler 吧!將任務繫結在 viewModelScope 或 lifecycleScope:
class UserProfileViewModel(
private val userRepository: UserRepository
) : ViewModel() {
fun loadUserData(userId: String) {
// 自動綁定 ViewModel 生命週期
viewModelScope.launch {
// 在 IO 執行緒執行非阻塞掛起
val user = withContext(Dispatchers.IO) {
userRepository.getUserFromDb(userId)
}
// 自動回到主執行緒安全更新 UI,無須 Handler;頁面關閉時整個任務自動被 Cancel
updateUi(user)
}
}
}
當使用者按下返回退出頁面時,ViewModel.onCleared() 自動被框架呼叫,viewModelScope 底層所有的 Job 會沿著階層關係自動向下廣播 Cancel 指令。
未完成的非同步工作立刻停止,主執行緒回呼自動取消,徹底杜絕 IllegalStateException 與記憶體洩漏!
3. collectLatest 一行程式碼終結「舊蓋新」
時序錯亂問題,在 Kotlin Flow 面前只需要一個運算子:
searchQueryFlow
.debounce(300L) // 擋掉 300ms 內的高頻敲鍵
.distinctUntilChanged()
.collectLatest { query ->
// 只要新的 query 進來,前一個還在跑的網路請求會立刻被底層 Cancel 掉!
val results = searchApi.query(query)
_searchResults.value = results
}
collectLatest 的本質就是:「新浪一來,前浪直接拍死在沙灘上」。
當使用者快速輸入「A」然後輸入「AB」時,輸入「AB」的瞬間,還在跑的請求 A 會收到協程取消訊號立刻中斷退出,保證只有最新的「AB」結果能抵達介面。不再需要手動寫旗標,不再有加鎖競態。
4. 非阻塞掛起(Suspend):一條執行緒扛上萬個請求
執行緒是昂貴的(1MB Stack),但協程是極其廉價的(記憶體中僅為幾十 Bytes 的 Continuation 物件)。
協程的 suspend 特性利用編譯器生成的狀態機,在等待網路 I/O 回傳時,把執行緒讓出來給別人跑,自己則掛起沉睡;等資料回傳後,再向排程器要一條空閒執行緒恢復執行。
因此我們根本不需要為了高併發硬開幾百條 Thread,即便是 2GB RAM 的弱晶片,也能輕鬆排程數萬個並行協程,徹底消滅 pthread_create failed 的 OOM 噩夢。
架構權衡與收工大實話
當然,這世上沒有銀彈。轉向協程之後,依然有它的工程代價:
- 阻塞型 API 依然會咬死執行緒:如果在
withContext(Dispatchers.Default)裡呼叫了Thread.sleep()或同步檔案讀寫,你依然會把 CPU 核心執行緒霸佔不放。協程的非阻塞需要底層庫(如 Retrofit, Room)原生支援suspend。 - 協程取消是協作式的(Cooperative Cancellation):若在自訂的密集迴圈中沒有定期檢查
isActive或呼叫yield(),即便外部 Scope 取消了,你的 CPU 迴圈依然會頑固地在背景空轉。 - 學習曲線與除錯成本:相比於直來直往的
ThreadPoolManager,協程的非同步呼叫堆疊(StackTrace)被狀態機拆得四分五裂,追查併發 Bug 時對工程師的心智模型要求更高。
但無論如何,把所有任務無腦丟進全域單例 ThreadPoolManager 的時代,真的該徹底翻篇了。
感謝觀看,收工。
發佈留言