卡頓、死鎖、ANR原理,線上監控方案分析
一、前言
最近參加了幾輪面試,發現很多5-7年工作經驗的候選人在效能優化這一塊,基本上只能說出傳統的分析方式,例如ANR分析,是通過檢視/data/anr/ 下的log,分析主執行緒堆疊、cpu、鎖資訊等,
然而,這種方法有一定的侷限性,並不是每次都奏效,很多時候是沒有堆疊資訊給你分析的,例如有些高版本裝置需要root許可權才能訪問/data/anr/ 目錄,或者是線上使用者的反饋,只有一張ANR的截圖加上一句話描述。
假如你的App沒有實現ANR監控上報,那麼你大概率會把這個問題當成“未復現”處理掉,而沒有真正解決問題。
於是整理了這一篇文章,主要關於卡頓、ANR、死鎖監控方案。
二、卡頓原理和監控
2.1 卡頓原理
一般來說,主執行緒有耗時操作會導致卡頓,卡頓超過閾值,觸發ANR。
從原始碼層面一步步分析卡頓原理:
首先應用程序啟動的時候,Zygote
會反射呼叫 ActivityThread
的 main 方法,啟動 loop 迴圈
->ActivityThread
public static void main(String[] args) {
...
Looper.prepareMainLooper();
Looper.loop();
...
}
看下Looper的loop方法
->Looper public static void loop() { for (;;) { //1、取訊息 Message msg = queue.next(); // might block ... //2、訊息處理前回調 if (logging != null) { logging.println(">>>>> Dispatching to " + msg.target + " " + msg.callback + ": " + msg.what); } ... //3、訊息開始處理 msg.target.dispatchMessage(msg);// 分發處理訊息 ... //4、訊息處理完回撥 if (logging != null) { logging.println("<<<<< Finished to " + msg.target + " " + msg.callback); } } ... }
由於loop迴圈存在,所以主執行緒可以長時間執行。如果想要在主執行緒執行某個任務,唯一的辦法就是通過主執行緒Handler post一個任務到訊息佇列裡去,然後loop迴圈中拿到這個msg,交給這個msg的target處理,這個target是Handler。
從上面的程式碼塊可以看出,導致卡頓的原因可能有兩個地方
- 註釋1的
queue.next()
阻塞, - 註釋3的
dispatchMessage
耗時太久。
2.1.1 MessageQueue#next 耗時
看下原始碼
MessageQueue#next
Message next() { for (;;) { //1、nextPollTimeoutMillis 不為0則阻塞 nativePollOnce(ptr, nextPollTimeoutMillis); synchronized (this) { // Try to retrieve the next message. Return if found. final long now = SystemClock.uptimeMillis(); Message prevMsg = null; Message msg = mMessages; // 2、先判斷當前第一條訊息是不是同步屏障訊息, if (msg != null && msg.target == null) { //3、遇到同步屏障訊息,就跳過去取後面的非同步訊息來處理,同步訊息相當於被設立了屏障 // Stalled by a barrier. Find the next asynchronous message in the queue. do { prevMsg = msg; msg = msg.next; } while (msg != null && !msg.isAsynchronous()); } //4、正常的訊息處理,判斷是否有延時 if (msg != null) { if (now < msg.when) { //3.1 // Next message is not ready. Set a timeout to wake up when it is ready. nextPollTimeoutMillis = (int) Math.min(msg.when - now, Integer.MAX_VALUE); } else { // Got a message. mBlocked = false; if (prevMsg != null) { prevMsg.next = msg.next; } else { mMessages = msg.next; } msg.next = null; if (DEBUG) Log.v(TAG, "Returning message: " + msg); msg.markInUse(); return msg; } } else { //5、如果沒有取到非同步訊息,那麼下次迴圈就走到1那裡去了,nativePollOnce為-1,會一直阻塞 // No more messages. nextPollTimeoutMillis = -1; } }
next
方法的大致流程是這樣的:
-
MessageQueue是一個連結串列資料結構,判斷MessageQueue的頭部(第一個訊息)是不是一個同步屏障訊息,所謂同步屏障訊息,就是給同步訊息加一層屏障,讓同步訊息不被處理,只會處理非同步訊息;
-
如果遇到同步屏障訊息,就會跳過MessageQueue中的同步訊息,只獲取裡面的非同步訊息來處理。如果裡面沒有非同步訊息,那就會走到註釋5,nextPollTimeoutMillis設定為-1,下次迴圈呼叫註釋1的nativePollOnce就會阻塞;
-
如果looper能正常獲取到訊息,不管是非同步訊息或者同步訊息,處理流程都是一樣的,在註釋4,先判斷是否帶延時,如果是,nextPollTimeoutMillis就會被賦值,然後下次迴圈呼叫註釋1的nativePollOnce就會阻塞一段時間。如果不是delay訊息,就直接返回這個msg,給handler處理;
從上面分析可以看出,next
方法是不斷從MessageQueue裡取出訊息,有訊息就處理,沒有訊息就呼叫nativePollOnce
阻塞,nativePollOnce
底層是Linux的epoll機制,這裡涉及到一個Linux IO 多路複用的知識點
Linux IO 多路複用,select、poll、epoll
Linux 上IO多路複用方案有 select、poll、epoll。它們三個中 epoll 的效能表現是最優秀的,能支援的併發量也最大。
- select 是作業系統提供的系統呼叫函式,通過它,我們可以把一個檔案描述符的陣列發給作業系統, 讓作業系統去遍歷,確定哪個檔案描述符可以讀寫, 然後告訴我們去處理。
- poll:它和 select 的主要區別就是,去掉了 select 只能監聽 1024 個檔案描述符的限制
- epoll:epoll 主要就是針對select的這三個可優化點進行了改進
1、核心中儲存一份檔案描述符集合,無需使用者每次都重新傳入,只需告訴核心修改的部分即可。 2、核心不再通過輪詢的方式找到就緒的檔案描述符,而是通過非同步 IO 事件喚醒。 3、核心僅會將有 IO 事件的檔案描述符返回給使用者,使用者也無需遍歷整個檔案描述符集合。
回到 MessageQueue
的next
方法,看看哪裡可能阻塞
同步屏障訊息沒移除導致next一直阻塞
有一種情況,在存在同步屏障訊息的情況下,當非同步訊息被處理完之後,如果沒有及時把同步屏障訊息移除,會導致同步訊息一直沒有機會處理,一直阻塞在nativePollOnce。
同步屏障訊息
Android 是禁止App往MessageQueue插入同步屏障訊息的,程式碼會報錯
系統一些高優先順序的操作會使用到同步屏障訊息,例如View在繪製的時候,最終都要呼叫ViewRootImpl
的scheduleTraversals
方法,會往MessageQueue
插入同步屏障訊息,繪製完成後會移除同步屏障訊息。
->ViewRootImpl
@UnsupportedAppUsage
void scheduleTraversals() {
if (!mTraversalScheduled) {
mTraversalScheduled = true;
//插入同步屏障訊息
mTraversalBarrier = mHandler.getLooper().getQueue().postSyncBarrier();
mChoreographer.postCallback(
Choreographer.CALLBACK_TRAVERSAL, mTraversalRunnable, null);
if (!mUnbufferedInputDispatch) {
scheduleConsumeBatchedInput();
}
notifyRendererOfFramePending();
pokeDrawLockIfNeeded();
}
}
void unscheduleTraversals() {
if (mTraversalScheduled) {
mTraversalScheduled = false;
//移除同步屏障訊息
mHandler.getLooper().getQueue().removeSyncBarrier(mTraversalBarrier);
mChoreographer.removeCallbacks(
Choreographer.CALLBACK_TRAVERSAL, mTraversalRunnable, null);
}
}
為了保證View的繪製過程不被主執行緒其它任務影響,View在繪製之前會先往MessageQueue插入同步屏障訊息,然後再註冊Vsync訊號監聽,Choreographer$FrameDisplayEventReceiver
就是用來接收vsync訊號回撥的
Choreographer$FrameDisplayEventReceiver
private final class FrameDisplayEventReceiver extends DisplayEventReceiver
implements Runnable {
...
@Override
public void onVsync(long timestampNanos, long physicalDisplayId, int frame) {
...
//
mTimestampNanos = timestampNanos;
mFrame = frame;
Message msg = Message.obtain(mHandler, this);
//1、傳送非同步訊息
msg.setAsynchronous(true);
mHandler.sendMessageAtTime(msg, timestampNanos / TimeUtils.NANOS_PER_MS);
}
@Override
public void run() {
// 2、doFrame優先執行
doFrame(mTimestampNanos, mFrame);
}
}
收到Vsync訊號回撥,註釋1會往主執行緒MessageQueue
post一個非同步訊息,保證註釋2的doFrame
優先執行。
doFrame
才是View真正開始繪製的地方,會呼叫ViewRootImpl
的doTraversal
、performTraversals
,
而performTraversals
裡面會呼叫我們熟悉的View的onMeasure
、onLayout
、onDraw
。
這裡還可以延伸到vsync訊號原理,以及為什麼要等vsync訊號回撥才開始View的繪製流程、掉幀的原理、螢幕的雙緩衝、三緩衝,由於文章篇幅關係,不是本文的重點,就不一一分析了~
雖然app無法傳送同步屏障訊息,但是使用非同步訊息是允許的
非同步訊息
首先,SDK中限制了App不能post非同步訊息到MessageQueue裡去的,相關欄位被加了UnsupportedAppUsage
註解
-> Message
@UnsupportedAppUsage
/*package*/ int flags;
/**
* Returns true if the message is asynchronous, meaning that it is not
* subject to {@link Looper} synchronization barriers.
*
* @return True if the message is asynchronous.
*
* @see #setAsynchronous(boolean)
*/
public boolean isAsynchronous() {
return (flags & FLAG_ASYNCHRONOUS) != 0;
}
不過呢,高版本的Handler的構造方法可以通過傳async=true,來使用非同步訊息
public Handler(@Nullable Callback callback, boolean async) {}
複製程式碼
然後在Handler
傳送訊息的時候,都會走到 enqueueMessage
方法,如下程式碼塊所示,每個訊息都帶了非同步屬性,有優先處理權
private boolean enqueueMessage(@NonNull MessageQueue queue, @NonNull Message msg,long uptimeMillis) {
...
//如果mAsynchronous為true,就都設定為非同步訊息
if (mAsynchronous) {
msg.setAsynchronous(true);
}
return queue.enqueueMessage(msg, uptimeMillis);
}
對於低版本SDK,想要使用非同步訊息,可以通過反射呼叫Handler(@Nullable Callback callback, boolean async)
,參考androidx內部的一段程式碼如下
->androidx.arch.core.executor.DefaultTaskExecutor
private static Handler createAsync(@NonNull Looper looper) {
if (Build.VERSION.SDK_INT >= 28) {
return Handler.createAsync(looper);
}
if (Build.VERSION.SDK_INT >= 16) {
try {
return Handler.class.getDeclaredConstructor(Looper.class, Handler.Callback.class,
boolean.class)
.newInstance(looper, null, true);
} catch (IllegalAccessException ignored) {
} catch (InstantiationException ignored) {
} catch (NoSuchMethodException ignored) {
} catch (InvocationTargetException e) {
return new Handler(looper);
}
}
return new Handler(looper);
}
需要注意的是,App要謹慎使用非同步訊息,使用不當的情況下可能會出現主執行緒假死的問題,排查也比較困難
分析完MessageQueue#next
再回頭來看看 Handler
的dispatchMessage
方法
2.1.2 dispatchMessage
上面說到next方法輪循取訊息一般情況下是沒有問題的,那麼只剩下處理訊息的邏輯
Handler#dispatchMessage
/**
* Handle system messages here.
*/
public void dispatchMessage(Message msg) {
if (msg.callback != null) {
handleCallback(msg);
} else {
if (mCallback != null) {
if (mCallback.handleMessage(msg)) {
return;
}
}
handleMessage(msg);
}
}
dispatchMessage 有三個邏輯,分別對應Handler 使用的三種方式
- Handler#post(Runnable r)
- 構造方法傳CallBack,
public Handler(@Nullable Callback callback, boolean async) {}
- Handler 重寫 handleMessage 方法
所以,應用卡頓,原因一般都可以認為是Handler處理訊息太耗時導致的,細分的原因可能是方法本身太耗時、演算法效率低、cpu被搶佔、記憶體不足、IPC超時等等。
2.2 卡頓監控
面試中,被問到如何監控App卡頓,統計方法耗時,我們可以從原始碼開始切入,講講如何通過Looper
提供的Printer
介面,計算Handler
處理一個訊息的耗時,判斷是否出現卡頓。
2.2.1 卡頓監控方案一
看下Looper 迴圈的註釋2和註釋4,可以找到一種卡頓監控的方法
Looper#loop
public static void loop() {
for (;;) {
//1、取訊息
Message msg = queue.next(); // might block
...
//2、訊息處理前回調
if (logging != null) {
logging.println(">>>>> Dispatching to " + msg.target + " " +
msg.callback + ": " + msg.what);
}
...
//3、訊息開始處理
msg.target.dispatchMessage(msg);// 分發處理訊息
...
//4、訊息處理完回撥
if (logging != null) {
logging.println("<<<<< Finished to " + msg.target + " " + msg.callback);
}
}
...
}
註釋2和註釋4的logging.println
是谷歌提供給我們的一個介面,可以監聽Handler處理訊息耗時,我們只需要呼叫Looper.getMainLooper().setMessageLogging(printer)
,即可從回撥中拿到Handler處理一個訊息的前後時間。
需要注意的是,監聽到發生卡頓之後,dispatchMessage
早已呼叫結束,已經出棧,此時再去獲取主執行緒堆疊,堆疊中是不包含卡頓的程式碼的。
所以需要在後臺開一個執行緒,定時獲取主執行緒堆疊,將時間點作為key,堆疊資訊作為value,儲存到Map中,在發生卡頓的時候,取出卡頓時間段內的堆疊資訊即可。
不過這種方案只適合線下使用,原因如下:
logging.println("<<<<< Finished to " + msg.target + " " + msg.callback);
存在字串拼接,頻繁呼叫,會建立大量物件,造成記憶體抖動。- 後臺執行緒頻繁獲取主執行緒堆疊,對效能有一定影響,獲取主執行緒堆疊,會暫停主執行緒的執行。
2.2.2 卡頓監控方案二
對於線上卡頓監控,需要了解位元組碼插樁技術。
通過Gradle Plugin+ASM,編譯期在每個方法開始和結束位置分別插入一行程式碼,統計方法耗時,
虛擬碼如下
插樁前
fun method(){
run()
}
插樁後
fun method(){
input(1)
run()
output(1)
}
目前微信的Matrix 使用的卡頓監控方案就是位元組碼插樁,如下圖所示
插樁需要注意的問題:
-
避免方法數暴增:在方法的入口和出口應該插入相同的函式,在編譯時提前給程式碼中每個方法分配一個獨立的 ID 作為引數。
-
過濾簡單的函式:過濾一些類似直接 return、i++ 這樣的簡單函式,並且支援黑名單配置。對一些呼叫非常頻繁的函式,需要新增到黑名單中來降低整個方案對效能的損耗。
微信Matrix做了大量優化,整體包體積增加1%-2%,幀率下降2幀以內,對效能影響整體可以接受,不過依然只會在灰度包使用。
再來說說ANR~
三、ANR 原理
ANR 的型別和觸發ANR的流程
3.1 哪些場景會造成ANR呢
- Service Timeout:比如前臺服務在20s內未執行完成,後臺服務是10s;
- BroadcastQueue Timeout:比如前臺廣播在10s內未執行完成,後臺60s
- ContentProvider Timeout:內容提供者,在publish過超時10s;
- InputDispatching Timeout: 輸入事件分發超時5s,包括按鍵和觸控事件。
相關超時定義可以參考ActivityManagerService
// How long we allow a receiver to run before giving up on it.
static final int BROADCAST_FG_TIMEOUT = 10*1000;
static final int BROADCAST_BG_TIMEOUT = 60*1000;
// How long we wait until we timeout on key dispatching.
static final int KEY_DISPATCHING_TIMEOUT = 5*1000;
3.2 ANR觸發流程
來簡單分析下原始碼,ANR觸發流程其實可以比喻成埋炸彈和拆炸彈的過程,
以後臺Service為例
3.2.1 埋炸彈
Context.startService
呼叫鏈如下:
AMS.startService
ActiveServices.startService
ActiveServices.realStartServiceLocked
ActiveServices.realStartServiceLocked
private final void realStartServiceLocked(ServiceRecord r, ProcessRecord app, boolean execInFg) throws RemoteException {
...
//1、這裡會傳送delay訊息(SERVICE_TIMEOUT_MSG)
bumpServiceExecutingLocked(r, execInFg, "create");
try {
...
//2、通知AMS建立服務
app.thread.scheduleCreateService(r, r.serviceInfo,
mAm.compatibilityInfoForPackageLocked(r.serviceInfo.applicationInfo),
app.repProcState);
}
...
}
註釋1的bumpServiceExecutingLocked內部呼叫scheduleServiceTimeoutLocked
void scheduleServiceTimeoutLocked(ProcessRecord proc) {
...
Message msg = mAm.mHandler.obtainMessage(
ActivityManagerService.SERVICE_TIMEOUT_MSG);
msg.obj = proc;
// 傳送deley訊息,前臺服務是20s,後臺服務是10s
mAm.mHandler.sendMessageDelayed(msg,
proc.execServicesFg ? SERVICE_TIMEOUT : SERVICE_BACKGROUND_TIMEOUT);
}
註釋2通知AMS啟動服務之前,註釋1處傳送Handler延時訊息,埋下炸彈,如果10s內(前臺服務是20s)沒人來拆炸彈,炸彈就會爆炸,即ActiveServices#serviceTimeout
方法會被呼叫
3.2.2 拆炸彈
啟動一個Service,先要經過AMS管理,然後AMS會通知應用程序執行Service的生命週期, ActivityThread
的handleCreateService
方法會被呼叫
-> ActivityThread#handleCreateService
private void handleCreateService(CreateServiceData data) {
try {
...
Application app = packageInfo.makeApplication(false, mInstrumentation);
service.attach(context, this, data.info.name, data.token, app,
ActivityManager.getService());
//1、service onCreate呼叫
service.onCreate();
mServices.put(data.token, service);
try {
//2、拆炸彈在這裡
ActivityManager.getService().serviceDoneExecuting(
data.token, SERVICE_DONE_EXECUTING_ANON, 0, 0);
} catch (RemoteException e) {
throw e.rethrowFromSystemServer();
}
}
}
註釋1,Service
的onCreate
方法被呼叫,
註釋2,呼叫AMS的serviceDoneExecuting
方法,最終會呼叫到ActiveServices. serviceDoneExecutingLocked
private void serviceDoneExecutingLocked(ServiceRecord r, boolean inDestroying,
boolean finishing) {
...
//移除delay訊息
mAm.mHandler.removeMessages(ActivityManagerService.SERVICE_TIMEOUT_MSG, r.app);
...
}
可以看到,onCreate
方法呼叫完之後,就會移除delay訊息,炸彈被拆除。
3.2.3 引爆炸彈
假設Service的onCreate執行超過10s,那麼炸彈就會引爆,也就是
ActiveServices#serviceTimeout
方法會被呼叫
void serviceTimeout(ProcessRecord proc) {
...
if (anrMessage != null) {
mAm.mAppErrors.appNotResponding(proc, null, null, false, anrMessage);
}
...
}
所有ANR,最終都會呼叫AppErrors
的appNotResponding
方法
AppErrors #appNotResponding
final void appNotResponding(ProcessRecord app, ActivityRecord activity,
ActivityRecord parent, boolean aboveSystem, final String annotation) {
...
//1、寫入event log
// Log the ANR to the event log.
EventLog.writeEvent(EventLogTags.AM_ANR, app.userId, app.pid,
app.processName, app.info.flags, annotation);
...
//2、收集需要的log,anr、cpu等,StringBuilder憑藉
// Log the ANR to the main log.
StringBuilder info = new StringBuilder();
info.setLength(0);
info.append("ANR in ").append(app.processName);
if (activity != null && activity.shortComponentName != null) {
info.append(" (").append(activity.shortComponentName).append(")");
}
info.append("\n");
info.append("PID: ").append(app.pid).append("\n");
if (annotation != null) {
info.append("Reason: ").append(annotation).append("\n");
}
if (parent != null && parent != activity) {
info.append("Parent: ").append(parent.shortComponentName).append("\n");
}
ProcessCpuTracker processCpuTracker = new ProcessCpuTracker(true);
...
// 3、dump堆疊資訊,包括java堆疊和native堆疊,儲存到檔案中
// For background ANRs, don't pass the ProcessCpuTracker to
// avoid spending 1/2 second collecting stats to rank lastPids.
File tracesFile = ActivityManagerService.dumpStackTraces(
true, firstPids,
(isSilentANR) ? null : processCpuTracker,
(isSilentANR) ? null : lastPids,
nativePids);
String cpuInfo = null;
...
//4、輸出ANR 日誌
Slog.e(TAG, info.toString());
if (tracesFile == null) {
// 5、沒有抓到tracesFile,發一個SIGNAL_QUIT訊號
// There is no trace file, so dump (only) the alleged culprit's threads to the log
Process.sendSignal(app.pid, Process.SIGNAL_QUIT);
}
StatsLog.write(StatsLog.ANR_OCCURRED, ...)
// 6、輸出到drapbox
mService.addErrorToDropBox("anr", app, app.processName, activity, parent, annotation, cpuInfo, tracesFile, null);
...
synchronized (mService) {
mService.mBatteryStatsService.noteProcessAnr(app.processName, app.uid);
//7、後臺ANR,直接殺程序
if (isSilentANR) {
app.kill("bg anr", true);
return;
}
//8、錯誤報告
// Set the app's notResponding state, and look up the errorReportReceiver
makeAppNotRespondingLocked(app,
activity != null ? activity.shortComponentName : null,
annotation != null ? "ANR " + annotation : "ANR",
info.toString());
//9、彈出ANR dialog,會呼叫handleShowAnrUi方法
// Bring up the infamous App Not Responding dialog
Message msg = Message.obtain();
msg.what = ActivityManagerService.SHOW_NOT_RESPONDING_UI_MSG;
msg.obj = new AppNotRespondingDialog.Data(app, activity, aboveSystem);
mService.mUiHandler.sendMessage(msg);
}
}
主要流程如下:
1、寫入event log
2、寫入 main log
3、生成tracesFile
4、輸出ANR logcat(控制檯可以看到)
5、如果沒有獲取到tracesFile,會發一個SIGNAL_QUIT
訊號,這裡看註釋是會觸發收集執行緒堆疊資訊流程,寫入traceFile
6、輸出到drapbox
7、後臺ANR,直接殺程序
8、錯誤報告
9、彈出ANR dialog,會呼叫 AppErrors#handleShowAnrUi
方法。
ANR觸發流程小結
ANR觸發流程,可以比喻為埋炸彈和拆炸彈的過程,
以啟動Service為例,Service的onCreate方法呼叫之前會使用Handler傳送延時10s的訊息,Service 的onCreate方法執行完,會把這個延時訊息移除掉。
假如Service的onCreate方法耗時超過10s,延時訊息就會被正常處理,也就是觸發ANR,會收集cpu、堆疊等資訊,彈ANR Dialog。
service、broadcast、provider 的ANR原理都是埋定時炸彈和拆炸彈原理,
但是input的超時檢測機制稍微有點不同,需要等收到下一次input事件,才會去檢測上一次input事件是否超時,input事件裡埋的炸彈是普通炸彈,需要通過掃雷來排查。
四、ANR 分析方法
上面已經分析了ANR觸發流程,最終會把發生ANR時的執行緒堆疊、cpu等資訊儲存起來,我們一般都是分析 /data/anr/traces.txt 檔案
4.1 模擬死鎖導致ANR
private fun testAnr(){
val lock1 = Object()
val lock2 = Object()
//子執行緒持有鎖1,想要競爭鎖2
thread {
synchronized(lock1){
Thread.sleep(100)
synchronized(lock2){
Log.d(TAG, "testAnr: getLock2")
}
}
}
//主執行緒持有鎖2,想要競爭鎖1
synchronized(lock2){
Thread.sleep(100)
synchronized(lock1){
Log.d(TAG, "testAnr: getLock1")
}
}
}
觸發ANR之後,一般我們會拉取anr日誌: adb pull /data/traces.txt(檔名可能是anr_xxx.txt)
4.2 分析ANR 檔案
首先看主執行緒,搜尋 main
ANR日誌中有很多資訊,可以看到,主執行緒id是1(tid=1),在等待一個鎖,這個鎖一直被id為22的程持有,那麼看下22號執行緒的堆疊
id為22的執行緒是Blocked狀態,正在等待一個鎖,這個鎖被id為1的執行緒持有,同時這個22號執行緒還持有一個鎖,這個鎖是主執行緒想要的。
通過ANR日誌,可以很清楚分析出這個ANR是死鎖導致的,並且有具體堆疊資訊。
上面只是舉例一種死鎖導致ANR的情況,實際專案中,可能有很多情況會導致ANR,例如記憶體不足、CPU被搶佔、系統服務沒有及時響應等等。
如果是線上問題,怎麼樣才能拿到ANR日誌呢?
五、ANR 監控
前面已經分析了ANR觸發流程,以及常規的線下分析方法,看起來還是有點繁瑣的,需要pull出anr日誌,然後分析執行緒堆疊等資訊。對於線上ANR,如何搭建一個完善的ANR監控系統呢?
下面將介紹ANR監控的方式
5.1 抓取系統traces.txt 上傳
1、當監控執行緒發現主執行緒卡死時,主動向系統傳送SIGNAL_QUIT訊號。
2、等待/data/anr/traces.txt檔案生成。
3、檔案生成以後進行上報。
看起來好像可行,不過有以下兩個問題:
1、traces.txt 裡面包含所有執行緒的資訊,上傳之後需要人工過濾分析
2、很多高版本系統需要root許可權才能讀取 /data/anr這個目錄
既然這個方案存在問題,那麼可還有其它辦法?
5.2 ANRWatchDog
ANRWatchDog 是一個自動檢測ANR的開源庫
5.2.1 ANRWatchDog 原理
其原始碼只有兩個類,核心是ANRWatchDog
這個類,繼承自Thread,它的run 方法如下,看註釋處
public void run() {
setName("|ANR-WatchDog|");
long interval = _timeoutInterval;
// 1、開啟迴圈
while (!isInterrupted()) {
boolean needPost = _tick == 0;
_tick += interval;
if (needPost) {
// 2、往UI執行緒post 一個Runnable,將_tick 賦值為0,將 _reported 賦值為false
_uiHandler.post(_ticker);
}
try {
// 3、執行緒睡眠5s
Thread.sleep(interval);
} catch (InterruptedException e) {
_interruptionListener.onInterrupted(e);
return ;
}
// If the main thread has not handled _ticker, it is blocked. ANR.
// 4、執行緒睡眠5s之後,檢查 _tick 和 _reported 標誌,正常情況下_tick 已經被主執行緒改為0,_reported改為false,如果不是,說明 2 的主執行緒Runnable一直沒有被執行,主執行緒卡住了
if (_tick != 0 && !_reported) {
...
if (_namePrefix != null) {
// 5、判斷髮生ANR了,那就獲取堆疊資訊,回撥onAppNotResponding方法
error = ANRError.New(_tick, _namePrefix, _logThreadsWithoutStackTrace);
} else {
error = ANRError.NewMainOnly(_tick);
}
_anrListener.onAppNotResponding(error);
interval = _timeoutInterval;
_reported = true;
}
}
}
ANRWatchDog
的原理是比較簡單的,概括為以下幾個步驟
- 開啟一個執行緒,死迴圈,迴圈中睡眠5s
- 往UI執行緒post 一個Runnable,將_tick 賦值為0,將 _reported 賦值為false
- 執行緒睡眠5s之後檢查_tick和_reported欄位是否被修改
- 如果_tick和_reported沒有被修改,說明給主執行緒post的Runnable一直沒有被執行,也就說明主執行緒卡頓至少5s(只能說至少,這裡存在5s內的誤差)。
- 將執行緒堆疊資訊輸出
其中涉及到併發的一個知識點,關於 volatile
關鍵字的使用,面試中的常客, volatile
的特點是:保證可見性,禁止指令重排,適合在一個執行緒寫,其它執行緒讀的情況。
面試中一般會展開問JMM,工作記憶體,主記憶體等,以及為什麼要有工作記憶體,能不能所有欄位都用 volatile
關鍵字修飾等問題。
回到ANRWatchDog本身,細心的同學可能會發現一個問題,使用ANRWatchDog有時候會捕獲不到ANR,是什麼原因呢?
5.2.2 ANRWatchDog 缺點
ANRWatchDog 會出現漏檢測的情況,看圖
如上圖這種情況,紅色表示卡頓,
-
假設主執行緒卡頓了2s之後,ANRWatchDog這時候剛開始一輪迴圈,將_tick 賦值為5,並往主執行緒post一個任務,把_tick修改為0
-
主執行緒過了3s之後不卡頓了,將_tick賦值為0
-
等到ANRWatchDog睡眠5s之後,發現_tick的值是0,判斷為沒有發生ANR。而實際上,主執行緒中間是卡頓了5s,ANRWatchDog誤差是在5s之內的(5s是預設的,執行緒的睡眠時長)
針對這個問題,可以做一下優化。
5.3 ANRMonitor
ANRWatchDog 漏檢測的問題,根本原因是因為執行緒睡眠5s,不知道前一秒主執行緒是否已經出現卡頓了,如果改成每間隔1秒檢測一次,就可以把誤差降低到1s內。
接下來通過改造ANRWatchDog ,來做一下優化,命名為ANRMonitor。
我們想讓子執行緒間隔1s執行一次任務,可以通過 HandlerThread
來實現
流程如下:
核心的Runnable程式碼
@Volatile
var mainHandlerRunEnd = true
//子執行緒會間隔1s呼叫一次這個Runnable
private val mThreadRunnable = Runnable {
blockTime++
//1、標誌位 mainHandlerRunEnd 沒有被主執行緒修改,說明有卡頓
if (!mainHandlerRunEnd && !isDebugger()) {
logw(TAG, "mThreadRunnable: main thread may be block at least $blockTime s")
}
//2、卡頓超過5s,觸發ANR流程,列印堆疊
if (blockTime >= 5) {
if (!mainHandlerRunEnd && !isDebugger() && !mHadReport) {
mHadReport = true
//5s了,主執行緒還沒更新這個標誌,ANR
loge(TAG, "ANR->main thread may be block at least $blockTime s ")
loge(TAG, getMainThreadStack())
//todo 回調出去,這裡可以按需把其它執行緒的堆疊也輸出
//todo debug環境可以開一個新程序,彈出堆疊資訊
}
}
//3、如果上一秒沒有卡頓,那麼重置標誌位,然後讓主執行緒去修改這個標誌位
if (mainHandlerRunEnd) {
mainHandlerRunEnd = false
mMainHandler.post {
mainHandlerRunEnd = true
}
}
//子執行緒間隔1s呼叫一次mThreadRunnable
sendDelayThreadMessage()
}
- 子執行緒每隔1s會執行一次mThreadRunnable,檢測標誌位 mainHandlerRunEnd 是否被修改
- 假如mainHandlerRunEnd如期被主執行緒修改為true,那麼重置mainHandlerRunEnd標誌位為false,然後繼續執行步驟1
- 假如mainHandlerRunEnd沒有被修改true,說明有卡頓,累計卡頓5s就觸發ANR流程
在監控到ANR的時候,除了獲取主執行緒堆疊,還有cpu、記憶體佔用等資訊也是比較重要的,demo中省略了這部分內容。
5.3.1 測試ANR
5.3.2 ANR檢測結果
logcat列印所示
主執行緒卡頓超過5s,會打堆疊資訊,如果是卡頓1-5s內,會有warning的log 提示,線下可以做成彈窗或者toast提示,
看到這裡,大家應該能想到,線下也可以用這種方法檢測卡頓,定位到耗時的程式碼。
此方案可以結合ProcessLifecycleOwner
,應用在前臺才開啟檢測,進入後臺則停止檢測。
六、死鎖監控
在發生ANR的時候,有時候只有主執行緒堆疊資訊可能還不夠,例如發生死鎖的情況,需要知道當前執行緒在等待哪個鎖,以及這個鎖被哪個執行緒持有,然後把發生死鎖的執行緒堆疊資訊都收集到。
流程如下:
-
獲取當前blocked狀態的執行緒
-
獲取該執行緒想要競爭的鎖
-
獲取該鎖被哪個執行緒持有
-
通過關係鏈,判斷死鎖的執行緒,輸出堆疊資訊
在Java層並沒有相關API可以實現死鎖監控,可以從Native層入手。
6.1 獲取當前blocked狀態的執行緒
這個比較簡單,一個for迴圈就搞定,不過我們要的執行緒id是native層的執行緒id,Thread 內部有一個native執行緒地址的欄位叫 nativePeer
,通過反射可以獲取到。
Thread[] threads = getAllThreads();
for (Thread thread : threads) {
if (thread.getState() == Thread.State.BLOCKED) {
long threadAddress = (long) ReflectUtil.getField(thread, "nativePeer");
// 找不到地址,或者執行緒已經掛了,此時獲取到的可能是0和-1
if (threadAddress <= 0) {
continue;
}
...後續
}
}
有了native層執行緒地址,還需要找到native層相關函式
6.2 獲取當前執行緒想要競爭的鎖
從ART 原始碼可以找到這個函式 androidxref.com/8.0.0_r4/xr…
函式:Monitor::GetContendedMonitor
從原始碼和原始碼的解釋可以看出,這個函式是用來獲取當前執行緒等待的Monitor。
順便說說Monitor以及Java物件結構
Monitor
Monitor是一種併發控制機制,提供多執行緒環境下的互斥和同步,以支援安全的併發訪問。
Monitor由以下3個元素組成:
- 臨界區:例如synchronize修飾的程式碼塊
- 條件變數:用來維護因不滿足條件而阻塞的執行緒佇列
- Monitor物件,維護Monitor的入口、臨界區互斥量(即鎖)、臨界區和條件變數,以及條件變數上的阻塞和喚醒
Java的Class物件
Java的Class物件包括三部分組成:
-
物件頭:MarkWord和物件指標
MarkWord(標記欄位):儲存雜湊碼、分代年齡、鎖標誌位、偏向執行緒ID、偏向時間戳等資訊 物件指標:即指向當前物件的類的元資料的指標,虛擬機器通過這個指標來確定這個物件是哪個類的例項。
-
例項資料:物件實際的資料
-
對齊填充:按8位元組對齊(JVM自動記憶體管理系統要求物件起始地址必須是8位元組的整數倍)。例如Integer物件,物件頭MarkWord和物件指標分別佔用4位元組,例項資料4位元組,那麼對齊填充就是4位元組,Integer佔用記憶體是int的4倍。
回到 GetContendedMonitor
函式,我們可以通過開啟動態庫libart.so
,然後使用dlsym
獲取函式的符號地址,然後就可以進行呼叫了。
由於Android 7.0開始,系統限制App中呼叫dlopen
,dlsym
等函式開啟系統動態庫,我們可以使用 ndk_dlopen這個庫來繞過這個限制
//1、初始化
ndk_init(env);
//2、開啟動態庫libart.so
void *so_addr = ndk_dlopen("libart.so", RTLD_NOLOAD);
if (so_addr == NULL) {
return 1;
}
開啟動態庫之後,會返回動態庫的記憶體地址,接下來就可以通過dlsym
獲取GetContendedMonitor
這個函式的符號地址,只不過要注意,c++可以過載,所以它的函式符號比較特殊,需要從libart.so
中搜索匹配找到
//c++跟c不一樣,c++可以過載,描述符會變,需要開啟libart.so,在裡面搜尋查詢GetContendedMonitor的函式符號
//http://androidxref.com/8.0.0_r4/xref/system/core/libbacktrace/testdata/arm/libart.so
//獲取Monitor::GetContendedMonitor函式符號地址
get_contended_monitor = ndk_dlsym(so_addr, "_ZN3art7Monitor19GetContendedMonitorEPNS_6ThreadE");
if (get_contended_monitor == NULL) {
return 2;
}
到此,第一個函式的符號地址找到了,接下來要找另外一個函式
6.3 獲取目標鎖被哪個執行緒持有
函式:Monitor::GetLockOwnerThreadId
用同樣的方式來獲取這個函式符號地址
// Monitor::GetLockOwnerThreadId
//這個函式是用來獲取 Monitor的持有者,會返回執行緒id
get_lock_owner_thread = ndk_dlsym(so_addr, get_lock_owner_symbol_name(api_level));
if (get_lock_owner_thread == NULL) {
return 3;
}
由於從android 10開始,這個GetLockOwnerThreadId
函式符號有變化,所以需要通過api版本來判斷使用哪一個
const char *get_lock_owner_symbol_name(jint level) {
if (level <= 29) {
//android 9.0 之前
//http://androidxref.com/9.0.0_r3/xref/system/core/libbacktrace/testdata/arm/libart.so 搜尋 GetLockOwnerThreadId
return "_ZN3art7Monitor20GetLockOwnerThreadIdEPNS_6mirror6ObjectE";
} else {
//android 10.0
// todo 10.0 原始碼中這個函式符號變了,需要自行查閱
return "_ZN3art7Monitor20GetLockOwnerThreadIdEPNS_6mirror6ObjectE";
}
}
到此,就得到了兩個函式符號地址,接下來就把blocked狀態的native執行緒id傳過去,呼叫就行了
6.4 找到一直不釋放鎖的執行緒
Java_com_lanshifu_demo_anrmonitor_DeadLockMonitor_getContentThreadIdArt(JNIEnv *env,jobject thiz,jlong native_thread) {
LOGI("getContentThreadIdArt");
int monitor_thread_id = 0;
if (get_contended_monitor != NULL && get_lock_owner_thread != NULL) {
LOGI("get_contended_monitor != NULL");
//1、呼叫一下獲取monitor的函式,返回當前執行緒想要競爭的monitor
int monitorObj = ((int (*)(long)) get_contended_monitor)(native_thread);
if (monitorObj != 0) {
LOGI("monitorObj != 0");
// 2、獲取這個monitor被哪個執行緒持有,返回該執行緒id
monitor_thread_id = ((int (*)(int)) get_lock_owner_thread)(monitorObj);
} else {
LOGE("GetContendedMonitor return null");
monitor_thread_id = 0;
}
} else {
LOGE("get_contended_monitor == NULL || get_lock_owner_thread == NULL");
}
return monitor_thread_id;
}
兩個步驟:
- 獲取當前執行緒要競爭的鎖
- 獲取這個鎖被哪個執行緒持有
通過兩個步驟,得到的是那個一直不釋放鎖的執行緒id。
6.5 通過演算法,找到死鎖
前面已經知道當前blocked狀態的執行緒id(還需要轉換成native執行緒id),以及這個blocked執行緒在等待哪個執行緒釋放鎖,也就是得到關係鏈:
- A等待B B等待A
- A等待B B等待C C等待A ...
- 其它...
如何判斷有死鎖?我們可以用Map來儲存對應關係
map[A]=B
map[B]=A
最後通過互斥條件判斷出死鎖執行緒,把造成死鎖的執行緒堆疊資訊輸出,如下
檢查出死鎖,線下可以彈窗或者toast,線上則可以採集資料上報。
6.6 死鎖監控小結
死鎖監控原理還是比較清晰的:
- 獲取blocked狀態的執行緒
- 獲取該執行緒想要競爭的鎖(native層函式)
- 獲取這個鎖被哪個執行緒持有(native層函式)
- 有了關係鏈,就可以找出造成死鎖的執行緒
由於死鎖監控涉及到native層程式碼,對於很多應用層開發的同學來說可能有點難度,
但是正因為有難度,我們去了解,去學習,並且掌握了,才能在眾多競爭者中脫穎而出。
七、形成閉環
前面分開講了卡頓監控、ANR監控和死鎖監控,我們可以把它連線起來,在發生ANR的時候,將整個監控流程形成一個閉環
- 發生ANR
- 獲取主執行緒堆疊資訊
- 檢測死鎖
- 獲取死鎖對應執行緒堆疊資訊
- 上報到伺服器
- 結合git,定位到最後修改程式碼的同學,給他提一個線上問題單
八、總結
這篇文章從原始碼層面分析了卡頓、ANR,以及死鎖監控,平時開發中,大部分同學可能都是做業務需求為主,對於ANR問題,可能不太注重,或者直接依賴第三方,例如Bugly,但是呢,在面試中,面試官基本不太會問你這些工具的使用,要問也是從原理層面問。
本文以卡頓作為切入點
-
講解卡頓原理以及卡頓監控的方式;
-
引申了Handler機制、Linux的epoll機制
-
分析ANR觸發流程,可以比喻為埋炸彈和拆炸彈過程
-
ANR常規分析方案,/data/anr/traces.txt,
-
ANRWatchDog 方案
-
ANRWatchDog存在問題,進行優化
-
死鎖導致的ANR,死鎖監控
-
形成閉環
好了,今天的文章就到這裡,感謝您的閱讀,有問題可以在評論區留言探討,期待與大家共同進步。喜歡的話不要忘了三連。大家的支援和認可,是我分享的最大動力。
Android高階開發系統進階筆記、最新面試複習筆記PDF,我的GitHub