背景
Java 多線程開(kāi)發(fā)中為了保證數(shù)據(jù)的一致性,引入了同步鎖(synchronized)。但是,對(duì)鎖的過(guò)度使用,可能導(dǎo)致卡頓問(wèn)題,甚至 ANR:
- Systrace 中的主線程因?yàn)榈孺i阻塞了繪制,導(dǎo)致卡頓
- Slardar 平臺(tái)(字節(jié)跳動(dòng)內(nèi)部 APM 平臺(tái),以下簡(jiǎn)稱 Slardar)中搜索 waiting to lock 關(guān)鍵字發(fā)現(xiàn)很多鎖導(dǎo)致的 ANR,僅 Java 鎖異常占到總 ANR 的 3.9%
本文將著重向大家介紹 Slardar 線上鎖監(jiān)控方案的原理與使用方法,以及我們?cè)诙兑羯习l(fā)現(xiàn)的鎖的經(jīng)典案例與優(yōu)化實(shí)踐。
監(jiān)控方案
獲取運(yùn)行時(shí)鎖信息的方法有以下幾種
方案 | 應(yīng)用范圍 | 特點(diǎn) |
systrace | 線下 |
|
定制 ROM | 線下 |
|
JVMTI | 線下 |
|
考慮到,很多鎖問(wèn)題需要一定規(guī)模的線上用戶才能暴露出來(lái),另外沒(méi)有調(diào)用棧難以從根本上定位和解決線上用戶的鎖問(wèn)題。最終我們自研了一套線上鎖監(jiān)控系統(tǒng),它需要滿足以下要求:
- 線上監(jiān)控方案
- 豐富的鎖信息,包括 Java 調(diào)用棧
- 數(shù)據(jù)分析平臺(tái),包括聚合能力,設(shè)備和版本信息等
- 可納入開(kāi)發(fā)和合碼流程,防止不良代碼上線
這樣的鎖監(jiān)控系統(tǒng),能夠幫助我們高效定位和解決線上問(wèn)題,并實(shí)現(xiàn)防劣化。
鎖監(jiān)控原理
我們先從 Systrace 入手,有一類常見(jiàn)的耗時(shí)叫做 monitor contention,其實(shí)是 Android ART 虛擬機(jī)輸出的鎖信息。
簡(jiǎn)單介紹一下里面的信息
monitor contention with owner work_thread (27176) at android.content.res.Resources android.app.ResourcesManager.getOrCreateResources(android.os.IBinder, android.content.res.ResourcesKey, java.lang.ClassLoader)(ResourcesManager.java:901) waiters=1 blocking from java.util.ArrayList android.app.ActivityThread.collectComponentCallbacks(boolean, android.content.res.Configuration)(ActivityThread.java:5836)
- 持鎖線程:work_thread
- 持鎖線程方法:android.app.ResourcesManager.getOrCreateResources(...)
- 等待線程 1 個(gè)
- 等鎖方法:android.app.ActivityThread.collectComponentCallbacks(...)
Java 鎖,無(wú)論是同步方法還是同步塊,虛擬機(jī)最終都會(huì)到 MonitorEnter。我們關(guān)注的 trace 是 Android 6 引入的, 在鎖的開(kāi)始和結(jié)束時(shí)分別調(diào)用ATRACE_BEGIN(...)? 和 ATRACE_END()
線上方案
默認(rèn)情況下 atrace 是關(guān)閉的,開(kāi)關(guān)在 ATRACE_ENABLED() 中。我們通過(guò)設(shè)置 atrace_enabled_tags 為 ATRACE_TAG_DALVIK 可以開(kāi)啟當(dāng)前進(jìn)程的 ART 虛擬機(jī)的 atrace。
再看 ATRACE_BEGIN(...)? 和 ATRACE_END() 的實(shí)現(xiàn),其實(shí)是用 write 將字符串寫入一個(gè)特殊的 atrace_marker_fd (/sys/kernel/debug/tracing/trace_marker)。
因此通過(guò) hook libcutils.so 的 write 方法,并按 atrace_marker_fd 過(guò)濾,就實(shí)現(xiàn)了對(duì) ATRACE_BEGIN(...)? 和 ATRACE_END()? 的攔截。有了 BEGIN 和 END 后可以計(jì)算出阻塞時(shí)長(zhǎng),解析 monitor contention with owner... 日志可以得到我們關(guān)注的 Java 鎖信息。
獲取堆棧
到目前為止,我們已經(jīng)可以監(jiān)控到線上用戶的鎖問(wèn)題。但是還不夠,為了能夠優(yōu)化鎖的性能,我們想需要知道等鎖的具體原因,也就是 Java 調(diào)用棧。
獲取 Java 調(diào)用棧,可以使用Thread.getStackTrace()方法。由于我們 hook 住了虛擬機(jī)的等鎖線程,此時(shí)線程處于一種特殊狀態(tài),不可以直接通過(guò) JNI 調(diào)用 Java 方法,否則導(dǎo)致線上 crash 問(wèn)題。
解決方案是異步獲取堆棧,在 MonitorBegin 的時(shí)候通知子線程 5ms 之后抓取堆棧,MonitorEnd 計(jì)算阻塞時(shí)長(zhǎng),并結(jié)合堆棧數(shù)據(jù)一起放入隊(duì)列,等待上報(bào) Slardar。如果 MonitorEnd 時(shí)不滿足 5ms 則取消抓棧和上報(bào)
數(shù)據(jù)平臺(tái)
由于方案本身有一定性能開(kāi)銷,我們僅對(duì)灰度測(cè)試中的部分用戶開(kāi)啟了鎖監(jiān)控。配置線上采樣后,命中的用戶將自動(dòng)開(kāi)啟鎖監(jiān)控,數(shù)據(jù)上報(bào) Slardar 平臺(tái)后就可以消費(fèi)了。
具體 case 可以看到設(shè)備信息、阻塞時(shí)長(zhǎng)、調(diào)用堆棧
根據(jù)調(diào)用棧查找源碼,可以定位到是哪一個(gè)鎖,說(shuō)明上報(bào)數(shù)據(jù)是準(zhǔn)確的。
穩(wěn)定性方面,10 萬(wàn)灰度用戶開(kāi)啟鎖監(jiān)控后,無(wú)新增穩(wěn)定性問(wèn)題。
優(yōu)化實(shí)踐
經(jīng)過(guò)多輪鎖收集和治理,我們?nèi)〉昧艘恍┎诲e(cuò)的收益,這里簡(jiǎn)單介紹下鎖治理的幾個(gè)典型案例。
典型案例
inflate 鎖:?
先解析一下什么是 inflate:Android 中解析 xml 生成 View 樹(shù)的過(guò)程就叫做 inflate 過(guò)程。inflate 是一個(gè)耗時(shí)過(guò)程,常規(guī)的手段就是通過(guò)異步來(lái)減少其在主線程的耗時(shí),這樣大大的減少了卡頓、頁(yè)面打開(kāi)和啟動(dòng)時(shí)長(zhǎng);但此方式也會(huì)帶來(lái)新的問(wèn)題,比如 LayoutInflater 的 inflate 方法中有加鎖保護(hù)的代碼塊,并行構(gòu)建會(huì)造成鎖等待,可能反而增加主線程耗時(shí),針對(duì)這個(gè)問(wèn)題有三種解決方案:
克隆 LayoutInflater
- 把線程分為三類別:Main、工作線程和其它線程(野線程),Context(Activity 和 App)為每個(gè)類別提供專有 LayoutInflater,這樣能有效的規(guī)避 inflate 鎖。
- 優(yōu)點(diǎn):實(shí)現(xiàn)簡(jiǎn)單、兼容性好
- 缺點(diǎn):LayoutInflater 中非安全的靜態(tài)屬性在并發(fā)情況下有概率產(chǎn)生穩(wěn)定性問(wèn)題
code 構(gòu)造替代 xml 構(gòu)造
- 這種方式完美的繞開(kāi)了 inflate 操作,極大提高了 View 構(gòu)造速度。
- 優(yōu)點(diǎn):復(fù)雜度高、性能好
- 缺點(diǎn):影響編譯速度、View 自定義屬性需要做轉(zhuǎn)換、存在兼容性問(wèn)題(比如廠商改屬性)
定制 LayoutInflater
- 自定義 FastInflater(繼承自 LayoutInflater)替換系統(tǒng)的 PhoneLayoutInflater,重寫 inflate 操作,去掉鎖保護(hù);從統(tǒng)計(jì)數(shù)據(jù)看,在并發(fā)時(shí)快了約 4%。
- 優(yōu)點(diǎn):復(fù)雜度高、性能好
- 缺點(diǎn):存在兼容性,比如華為的 Inflater 為 HwPhoneLayoutInflater,無(wú)法直接替換。
文件目錄鎖:
ContextImpl 中獲取目錄(cache、files、DB 和 preferenceDir)的實(shí)現(xiàn)有兩個(gè)關(guān)鍵耗時(shí)點(diǎn):1. 存在 IPC(IStorageManager.mkdir)和文件 check;2. 加鎖“nSync”保護(hù);所以 ipc 變長(zhǎng)和并發(fā)存在,都可能導(dǎo)致 App 卡頓,如圖為 Anr 數(shù)據(jù):
相關(guān)的常用 Api 有 getExternalCacheDir、getCacheDir、getFilesDir、getTheme 等,考慮到系統(tǒng)的部分目錄一般不會(huì)發(fā)生變化,所以我們可以對(duì)一些不會(huì)變化的目錄進(jìn)行 cache 處理,減少帶 鎖方法塊的執(zhí)行,從而有效的繞過(guò)鎖等待。
MessageQueue:
Android 子線程與主線程通訊的通用方式是向主線程 MessageQueue 中插入一個(gè)任務(wù)(message),等此任務(wù)(message)被主線程 Looper 調(diào)度執(zhí)行;所以 MessageQueue 中會(huì)對(duì)消息鏈表的修改加鎖保護(hù),主要實(shí)現(xiàn)在 enqueueMessage 和 next 兩個(gè)方法中。
利用 Slardar 采集線上鎖信息,根據(jù)這些信息,我們可以輕松追蹤鎖的執(zhí)有線程和 owner,最后根據(jù)情況將請(qǐng)求(message)移到子線程,這樣就可以極大的減輕主線程壓力和等鎖的可能性。此問(wèn)題的修改方式并不復(fù)雜,重點(diǎn)在于如何監(jiān)控到這些執(zhí)鎖線程。
序列化和反序列化:
抖音中有一些常用數(shù)據(jù)對(duì)象使用 Json 格式存儲(chǔ)。為保證這些數(shù)據(jù)的完整性,在讀取和存儲(chǔ)時(shí)加了鎖保護(hù),從而導(dǎo)致鎖等待比較常見(jiàn),這種情況在啟動(dòng)場(chǎng)景特別明顯;所以要想減少鎖等待,就必段加快序列化和反序列化,針對(duì)這個(gè)問(wèn)題,我們做了三個(gè)優(yōu)化方案:
- Gson 反序列化的耗時(shí)集中在 TypeAdapter 的構(gòu)建,此過(guò)程利用反射創(chuàng)建 Filed 和 name(key)的映射表;所以我們?cè)诰幾g時(shí)針對(duì)數(shù)據(jù)類創(chuàng)建對(duì)應(yīng)的 TypeAdapter,大大減少反序列化的時(shí)耗。
- 部分類使用 parcel 序列化和反序列化,大大提高了速度,約減少 90%的時(shí)耗。
- 大對(duì)像根據(jù)情況拆分成多個(gè)小對(duì)像,這樣可以減少鎖粒度,也就減少了鎖等待。以上方案在抖音項(xiàng)目中都有使用,取得了很不錯(cuò)的收益。
AssetManager 鎖:
獲取 string、size、color 或 xml 等資源的最終實(shí)現(xiàn)基本都封裝在 AssertManager 中,為了保證數(shù)據(jù)的正確性,加了鎖(對(duì)象 AssetManager)保護(hù),大致的調(diào)用關(guān)系如圖:
常用的調(diào)用點(diǎn)有:
- View 構(gòu)造方法中調(diào)用 context.obtainStyledAttributes(...)獲取 TypedArray,最后都會(huì)調(diào)用 AssetManager 的帶鎖方法。
- View 的 toString 也調(diào)用了 AssetManager 的帶鎖方法。
隨著 xml 異步 inflate 的增加,這些方法并發(fā)調(diào)用也增加,造成主線程的鎖等待也日漸突出,最終導(dǎo)致卡頓,針對(duì)這個(gè)問(wèn)題,目前我們的優(yōu)化方案主要有:
- 去掉多余的調(diào)用,比如 View 的 toString,這個(gè)常見(jiàn)于日志打印。
- 一個(gè) Context 根據(jù)線程名提供不同的 AssetManager,繞過(guò) AssetManager 對(duì)象鎖;此方法可能帶來(lái)一些內(nèi)存消耗。
So 加載鎖優(yōu)化:
Android 提供的加載 so 的接口實(shí)現(xiàn)都在封裝在 Runtime 中,比如常用的 loadLibrary0 和 load0,如圖 1 和 l 圖 2 所示,此方法是加了鎖的,如果并發(fā)加載 so 就會(huì)造成鎖等待。通過(guò) Slardar 的監(jiān)控?cái)?shù)據(jù),我們驗(yàn)證了這個(gè)問(wèn)題,同時(shí)也有一些意外收獲,比如平臺(tái)可能有自己的 so 需要加:
我們根據(jù) so 的不同情況,主要有以下優(yōu)化思路:
- 對(duì)于 cinit 加載的 so,我們可以提前在子線程中加載一下 cinit 的宿主類。
- 業(yè)務(wù)層面的 so, 可以統(tǒng)一在子線程中進(jìn)行提前加載。
- 使用 load0 替代 loadLibrary0,可以減少鎖中拼接 so 路徑的時(shí)耗。
- so 文件加載優(yōu)化,比如 JNI_OnLoad。
ActivityThread:
在收集的的數(shù)據(jù)中我們也發(fā)現(xiàn)了一些系統(tǒng)層的框架鎖,比如下圖這個(gè):
這個(gè)問(wèn)題主要集中在啟動(dòng)階段,ams 會(huì)發(fā) trim 通知給 ActivityThread 中的 ApplicationThread,收到通知后會(huì)向 Choreographer 的 commit 列表(此任務(wù)列表不作展開(kāi))中添加一個(gè) trim 任務(wù),也就是在下個(gè) vsync 到達(dá)時(shí)被執(zhí)行;
trim 過(guò)程主要包括收集 Applicatioin、Activity、Service、Provider 和向它們發(fā)送 trim 消息,也是系統(tǒng)提供給業(yè)務(wù)清理自身內(nèi)存的一個(gè)時(shí)機(jī);收集過(guò)程是加鎖(ResourcesManager)保護(hù)的,如圖:
考慮到啟動(dòng)階段并不太關(guān)心內(nèi)存的釋放,所以可以嘗試在啟動(dòng)階段,比如 40 秒內(nèi),不執(zhí)行 trim 操作;具體的實(shí)現(xiàn)是這樣,首先替換 Choreographer 的 FrameHandler, 這樣就能接管 vsync 的 doFrame 操作,在啟動(dòng) 40 秒內(nèi)的每次 vsync 主動(dòng) check 或刪除 commint 任務(wù)列表中的 trim 操作。
收益
在抖音中我們除了優(yōu)化前面列出的這些典型鎖外,還優(yōu)化了一些業(yè)務(wù)本身的鎖,部分已經(jīng)通過(guò)線上實(shí)驗(yàn)驗(yàn)證了收益,也有一些還在嘗試實(shí)驗(yàn)中;通過(guò)對(duì)實(shí)驗(yàn)中各指標(biāo)的分析,也證實(shí)了鎖優(yōu)化能帶來(lái)啟動(dòng)和流暢度等技術(shù)收益,間接帶來(lái)了不錯(cuò)的業(yè)務(wù)收益,這也堅(jiān)定了我們?cè)谶@個(gè)方向上的繼續(xù)探索和深化。
小結(jié)
前面列出的只是有代表性的一些通用 Java 鎖,在實(shí)際開(kāi)發(fā)中遇到的遠(yuǎn)比這多,但不管什么樣的鎖,都可以根據(jù)進(jìn)程和代碼歸屬分為以下四類:業(yè)務(wù)鎖、依賴庫(kù)鎖、框架鎖和系統(tǒng)鎖;
不同類型的鎖優(yōu)化思路也會(huì)不一樣,部分方案可以復(fù)用,部分只能 case-by-case 解決,具體的優(yōu)化方案有:減少調(diào)用、繞過(guò)調(diào)用、使用讀寫鎖和無(wú)鎖等。
分類 | 描述 | 進(jìn)程 | 代碼 | 優(yōu)化方案 |
業(yè)務(wù)鎖 | 源碼可見(jiàn),可以直接修改;比如前面的序列化優(yōu)化。 | App 進(jìn)程 | 包含 | 直接優(yōu)化;靜態(tài) aop |
依賴庫(kù)鎖 | 包含編譯產(chǎn)物,可以修改產(chǎn)物 | App 進(jìn)程 | 包含 | 直接優(yōu)化;靜態(tài) aop |
框架鎖 | 運(yùn)行時(shí)加載,同時(shí)存在兼容性;比如前面提到的 inflate 鎖、AssetManager 鎖和 MessageQueue 鎖 | App 進(jìn)程 | 不包含 | 減少調(diào)用;動(dòng)態(tài) aop |
系統(tǒng)鎖 | 系統(tǒng)為 App 提供的服務(wù)和資源,App 間存在競(jìng)爭(zhēng),所以服務(wù)層需要加鎖保護(hù),比如 IPC、文件系統(tǒng)和數(shù)據(jù)庫(kù)等 | 服務(wù)進(jìn)程 | 不包含 | 減少調(diào)用 |
總結(jié)
經(jīng)過(guò)了長(zhǎng)達(dá)半年的探索和優(yōu)化,此方案已在線上使用,作為我們?nèi)粘7懒踊椭鲃?dòng)優(yōu)化的輸入工具,我們?cè)u(píng)判的點(diǎn)主要有以下四個(gè):
- 穩(wěn)定性:線上開(kāi)啟后,ANR、Crash 和 OOM 和大盤一致。
- 準(zhǔn)確性:從目前線上的消費(fèi)數(shù)據(jù)來(lái)看,這個(gè)值達(dá)到了 99%。
- 擴(kuò)展性:業(yè)務(wù)可以根據(jù)場(chǎng)景開(kāi)啟和關(guān)閉采集功能,也可以收集指定時(shí)間內(nèi)的鎖,比如啟動(dòng)階段可以收集 32ms 的鎖,其它階段收集 16ms 的鎖。
- 劣化影響:從線上實(shí)驗(yàn)數(shù)據(jù)看,一定量(UV)的情況下,業(yè)務(wù)和性能(丟幀和啟動(dòng))無(wú)顯著劣化。
此方案雖然只能監(jiān)控 synchronized 鎖,像 CAS、Native 鎖、sleep 和 wait 都無(wú)法監(jiān)控,但在我們?nèi)粘i_(kāi)發(fā)中synchronized 鎖占比非常大, 所以基本滿足了我們絕大部分的需求,當(dāng)然,我們也在持續(xù)探索其它鎖的監(jiān)控和驗(yàn)證其價(jià)值。