江義旺:滴滴出行安卓端 finalize time out 的解決方案

出品 | 滴滴技術
作者 | 江義旺

圖片描述

前言:隨着安卓 APP 規模越來越大,代碼越來越多,各種疑難雜症問題也隨之出現。比較常見的一個問題就是 GC finalize() 方法出現 java.util.concurrent.TimeoutException,這類問題難查難解,困擾了很多開發者。那麼這類問題是怎麼出現的呢?有什麼解決辦法呢?這篇文章爲將探索 finalize() timeout 的原因和解決方案,分享我們的踩坑經驗,希望對遇到此類問題的開發者有所幫助。

在一些大型安卓 APP 中,經常會遇到一個奇怪的 BUG:ava.util.concurrent.TimeoutException

其表現爲對象的 finalize() 方法超時,如 android.content.res.AssetManager.finalize() timed out after 10 seconds 。

此前滴滴出行安卓端曾長期受此 BUG 的影響,每天有一些用戶會因此遇到 Crash,經過深度分析,最終找到有效解決方案。這篇文章將對這個 BUG 的來龍去脈以及我們的解決方案進行分析。

▍問題詳情

finalize() TimeoutException 發生在很多類中,典型的 Crash 堆棧如:

1 java.util.concurrent.TimeoutException: android.content.res.AssetManager$AssetInputStream.finalize() timed out after 15 seconds
2 at android.content.res.AssetManager$AssetInputStream.close(AssetManager.java:559)
3 at android.content.res.AssetManager$AssetInputStream.finalize(AssetManager.java:592)
4 at java.lang.Daemons$FinalizerDaemon.doFinalize(Daemons.java:187)
5 at java.lang.Daemons$FinalizerDaemon.run(Daemons.java:170)
6 at java.lang.Thread.run(Thread.java:841)

△ 左滑瀏覽全貌

這類 Crash 都是發生在 java.lang.Daemons$FinalizerDaemon.doFinalize 方法中,直接原因是對象的 finalize() 方法執行超時。系統版本從 Android 4.x 版本到 8.1 版本都有分佈,低版本分佈較多,出錯的類有系統的類,也有我們自己的類。由於該問題在 4.x 版本中最具有代表性,下面我們將基於 AOSP 4.4 源碼進行分析:

▍源碼分析

首先從 Daemons 和 FinalizerDaemon 的由來開始分析,Daemons 開始於 Zygote 進程:Zygote 創建新進程後,通過 ZygoteHooks 類調用了 Daemons 類的 start() 方法,在 start() 方法中啓動了 FinalizerDaemon,FinalizerWatchdogDaemon 等關聯的守護線程。

 1 public final class Daemons {
 2    ...
 3    private static final long MAX_FINALIZE_NANOS = 10L * NANOS_PER_SECOND;
 4
 5    public static void start() {
 6        FinalizerDaemon.INSTANCE.start();
 7        FinalizerWatchdogDaemon.INSTANCE.start();
 8        ...
 9    }
10
11    public static void stop() {
12        FinalizerDaemon.INSTANCE.stop();
13        FinalizerWatchdogDaemon.INSTANCE.stop();
14        ...
15    }
16}

△ 左滑瀏覽全貌

Daemons 類主要處理 GC 相關操作,start() 方法調用時啓動了 5 個守護線程,其中有 2 個守護線程和這個 BUG 具有直接的關係。

▍FinalizerDaemon 析構守護線程

對於重寫了成員函數finalize()的類,在對象創建時會新建一個 FinalizerReference 對象,這個對象封裝了原對象。當原對象沒有被其他對象引用時,這個對象不會被 GC 馬上清除掉,而是被放入 FinalizerReference 的鏈表中。FinalizerDaemon 線程循環取出鏈表裏面的對象,執行它們的 finalize() 方法,並且清除和對應 FinalizerReference對象引用關係,對應的 FinalizerReference 對象在下次執行 GC 時就會被清理掉。

 1 private static class FinalizerDaemon extends Daemon {
 2    ...
 3    @Override public void run() {
 4        while (isRunning()) {
 5            // Take a reference, blocking until one is ready or the thread should stop
 6            try {
 7                doFinalize((FinalizerReference<?>) queue.remove());
 8            } catch (InterruptedException ignored) {
 9            }
10        }
11    }
12
13    @FindBugsSuppressWarnings("FI_EXPLICIT_INVOCATION")
14    private void doFinalize(FinalizerReference<?> reference) {
15        ...
16        try {
17            finalizingStartedNanos = System.nanoTime();
18            finalizingObject = object;
19            synchronized (FinalizerWatchdogDaemon.INSTANCE) {
20                FinalizerWatchdogDaemon.INSTANCE.notify();
21            }
22            object.finalize();
23        } catch (Throwable ex) {
24            ...
25        } finally {
26            finalizingObject = null;
27        }
28    }
29}

△ 左滑瀏覽全貌

▍FinalizerWatchdogDaemon 析構監護守護線程

析構監護守護線程用來監控 FinalizerDaemon 線程的執行,採用 Watchdog 計時器機制。當 FinalizerDaemon 線程開始執行對象的 finalize() 方法時,FinalizerWatchdogDaemon 線程會啓動一個計時器,當計時器時間到了之後,檢測 FinalizerDaemon 中是否還有正在執行 finalize() 的對象。檢測到有對象存在後就視爲 finalize() 方法執行超時,就會產生 TimeoutException 異常。

 1 private static class FinalizerWatchdogDaemon extends Daemon {
 2    ...
 3    @Override public void run() {
 4        while (isRunning()) {
 5            ...
 6            boolean finalized = waitForFinalization(object);
 7            if (!finalized && !VMRuntime.getRuntime().isDebuggerActive()) {
 8                finalizerTimedOut(object);
 9                break;
10            }
11        }
12    }
13    ...
14    private boolean waitForFinalization(Object object) {
15        sleepFor(FinalizerDaemon.INSTANCE.finalizingStartedNanos, MAX_FINALIZE_NANOS);
16        return object != FinalizerDaemon.INSTANCE.finalizingObject;//當sleep時間到之後,檢測 FinalizerDaemon 線程中當前正在執行 finalize 的對象是否存在,如果存在說明 finalize() 方法超時
17    }
18
19    private static void finalizerTimedOut(Object object) {
20        String message = object.getClass().getName() + ".finalize() timed out after "
21                + (MAX_FINALIZE_NANOS / NANOS_PER_SECOND) + " seconds";
22        Exception syntheticException = new TimeoutException(message);
23        syntheticException.setStackTrace(FinalizerDaemon.INSTANCE.getStackTrace());
24        Thread.UncaughtExceptionHandler h = Thread.getDefaultUncaughtExceptionHandler();
25        ...
26        h.uncaughtException(Thread.currentThread(), syntheticException);
27    }
28}

△ 左滑瀏覽全貌

由源碼可以看出,該 Crash 是在 FinalizerWatchdogDaemon 的線程中創建了一個TimeoutException 傳給 Thread 類的 defaultUncaughtExceptionHandler 處理造成的。由於異常中填充了 FinalizerDaemon 的堆棧,之所以堆棧中沒有出現和 FinalizerWatchdogDaemon 相關的類。

▍原因分析

finalize()導致的 TimeoutException Crash 非常普遍,很多 APP 都面臨着這個問題。使用 finalize() TimeoutException 爲關鍵詞在搜索引擎或者 Stack Overflow 上能搜到非常多的反饋和提問,技術網站上對於這個問題的原因分析大概有兩種:

▍對象 finalize() 方法耗時較長

當 finalize() 方法中有耗時操作時,可能會出現方法執行超時。耗時操作一般有兩種情況,一是方法內部確實有比較耗時的操作,比如 IO 操作,線程休眠等。另外有種線程同步耗時的情況也需要注意:有的對象在執行 finalize() 方法時需要線程同步操作,如果長時間拿不到鎖,可能會導致超時,如 android.content.res.AssetManager$AssetInputStream 類:

 1 public final class AssetInputStream extends InputStream {
 2    ...
 3    public final void close() throws IOException {
 4        synchronized (AssetManager.this) {
 5            ...
 6        }
 7    }
 8    ...
 9    protected void finalize() throws Throwable {
10        close();
11    }
12    ...
13 }

△ 左滑瀏覽全貌

AssetManager 的內部類 AssetInputStream 在執行 finalize() 方法時調用 close() 方法時需要拿到外部類 AssetManager 對象鎖, 而在 AssetManager 類中幾乎所有的方法運行時都需要拿到同樣的鎖,如果 AssetManager 連續加載了大量資源或者加載資源是耗時較長,就有可能導致內部類對象 AssetInputStream 在執行finalize() 時長時間拿不到鎖而導致方法執行超時。

 1 public final class AssetManager implements AutoCloseable {
 2    ...
 3    /*package*/ final CharSequence getResourceText(int ident) {
 4        synchronized (this) {
 5            ...
 6        }
 7        return null;
 8    }
 9    ...
10    public final InputStream open(String fileName, int accessMode) throws IOException {
11        synchronized (this) {
12            ...
13        }
14        throw new FileNotFoundException("Asset file: " + fileName);
15    }
16    ...
17 }

△ 左滑瀏覽全貌

▍5.0 版本以下機型 GC 過程中 CPU 休眠導致

有種觀點認爲系統可能會在執行 finalize() 方法時進入休眠, 然後被喚醒恢復運行後,會使用現在的時間戳和執行 finalize() 之前的時間戳計算耗時,如果休眠時間比較長,就會出現 TimeoutException。

詳情請見∞

確實這兩個原因能夠導致 finalize() 方法超時,但是從 Crash 的機型分佈上看大部分是發生在系統類,另外在 5.0 以上版本也有大量出現,因此我們認爲可能也有其他原因導致此類問題:

▍IO 負載過高

許多類的 finalize() 都需要釋放 IO 資源,當 APP 打開的文件數目過多,或者在多進程或多線程併發讀取磁盤的情況下,隨着併發數的增加,磁盤 IO 效率將大大下降,導致 finalize() 方法中的 IO 操作運行緩慢導致超時。

▍FinalizerDaemon 中線程優先級過低

FinalizerDaemon 中運行的線程是一個守護線程,該線程優先級一般爲默認級別 (nice=0),其他高優先級線程獲得了更多的 CPU 時間,在一些極端情況下高優先級線程搶佔了大部分 CPU 時間,FinalizerDaemon 線程只能在 CPU 空閒時運行,這種情況也可能會導致超時情況的發生,(從 Android 8.0 版本開始,FinalizerDaemon 中守護線程優先級已經被提高,此類問題已經大幅減少)

▍解決方案

當問題出現後,我們應該找到問題的根本原因,從根源上去解決。然而對於這個問題來說卻不太容易實現,和其他問題不同,這類問題原因比較複雜,有系統原因,也有 APP 自身的原因,比較難以定位,也難以系統性解決。

▍理想措施

理論上我們可以做的措施有:

  1. 減少對 finalize() 方法的依賴,儘量不依靠 finalize() 方法釋放資源,手動處理資源釋放邏輯。
  2. 減少 finalizable 對象個數,即減少有 finalize() 方法的對象創建,降低 finalizable 對象 GC 次數。

3.finalize() 方法內儘量減少耗時以及線程同步時間。

  1. 減少高優先級線程的創建和使用,降低高優先級線程的 CPU 使用率。

▍止損措施

理想情況下的措施,可以從根本上解決此類問題,但現實情況下卻不太容易完全做到,對一些大型APP來說更難以徹底解決。那麼在解決問題的過程中,有沒有別的辦法能夠緩解或止損呢?總結了技術網站上現有的方案後,可以總結爲以下幾種:

  • 手動修改 finalize() 方法超時時間
1  try {
2    Class<?> c = Class.forName(“java.lang.Daemons”);
3    Field maxField = c.getDeclaredField(“MAX_FINALIZE_NANOS”);
4    maxField.setAccessible(true);
5    maxField.set(null, Long.MAX_VALUE);
6 } catch (Exception e) {
7    ...
8 }

△ 左滑瀏覽全貌

詳情請見∞

這種方案思路是有效的,但是這種方法卻是無效的。Daemons 類中 的 MAX_FINALIZE_NANOS 是個 long 型的靜態常量,代碼中出現的 MAX_FINALIZE_NANOS 字段在編譯期就會被編譯器替換成常量,因此運行期修改是不起作用的。MAX_FINALIZE_NANOS默認值是 10s,國內廠商常常會修改這個值,一般有 15s,30s,60s,120s,我們可以推測廠商修改這個值也是爲了加大超時的闕值,從而緩解此類 Crash。

  • 手動停掉 FinalizerWatchdogDaemon 線程
 1    try {
 2        Class clazz = Class.forName("java.lang.Daemons$FinalizerWatchdogDaemon");
 3        Method method = clazz.getSuperclass().getDeclaredMethod("stop");
 4        method.setAccessible(true);
 5        Field field = clazz.getDeclaredField("INSTANCE");
 6        field.setAccessible(true);
 7        method.invoke(field.get(null));
 8    } catch (Throwable e) {
 9        e.printStackTrace();
10    }

△ 左滑瀏覽全貌

詳情請見∞

這種方案利用反射 FinalizerWatchdogDaemon 的 stop() 方法,以使 FinalizerWatchdogDaemon 計時器功能永遠停止。當 finalize() 方法出現超時, FinalizerWatchdogDaemon 因爲已經停止而不會拋出異常。這種方案也存在明顯的缺點:

  1. 在 Android 5.1 版本以下系統中,當 FinalizerDaemon 正在執行對象的 finalize() 方法時,調用 FinalizerWatchdogDaemon 的 stop() 方法,將導致 run() 方法正常邏輯被打斷,錯誤判斷爲 finalize() 超時,直接拋出 TimeoutException。
  2. Android 9.0 版本開始限制 Private API 調用,不能再使用反射調用 Daemons 以及 FinalizerWatchdogDaemon 類方法。

▍終極方案

這些方案都是阻止 FinalizerWatchdogDaemon 的正常運行,避免出現 Crash,從原理上還是具有可行性的:finalize() 方法雖然超時,但是當 CPU 資源充裕時,FinalizerDaemon 線程還是可以獲得充足的 CPU 時間,從而獲得了繼續運行的機會,最大可能的延長了 APP 的存活時間。但是這些方案或多或少都是有缺陷的,那麼有其他更好的辦法嗎?

What should we do? We just ignore it.

我們的方案就是忽略這個 Crash,那麼怎麼能夠忽略這個 Crash 呢?首先我們梳理一下這個 Crash 的出現過程:

  1. FinalizerDaemon 執行對象 finalize() 超時。
  2. FinalizerWatchdogDaemon 檢測到超時後,構造異常交給 Thread 的 defaultUncaughtExceptionHandler 調用 uncaughtException() 方法處理。
  3. APP 停止運行。

Thread 類的 defaultUncaughtExceptionHandler 我們很熟悉了,Java Crash 捕獲一般都是通過設置 Thread.setDefaultUncaughtExceptionHandler() 方法設置一個自定義的 UncaughtExceptionHandler ,處理異常後通過鏈式調用,最後交給系統默認的 UncaughtExceptionHandler 去處理,在 Android 中默認的 UncaughtExceptionHandler 邏輯如下:

 1 public class RuntimeInit {
 2    ...
 3   private static class UncaughtHandler implements Thread.UncaughtExceptionHandler {
 4       public void uncaughtException(Thread t, Throwable e) {
 5           try {
 6                ...
 7               // Bring up crash dialog, wait for it to be dismissed 展示APP停止運行對話框
 8               ActivityManagerNative.getDefault().handleApplicationCrash(
 9                       mApplicationObject, new ApplicationErrorReport.CrashInfo(e));
10           } catch (Throwable t2) {
11                ...
12           } finally {
13               // Try everything to make sure this process goes away.
14               Process.killProcess(Process.myPid()); //退出進程
15               System.exit(10);
16           }
17       }
18   }
19
20    private static final void commonInit() {
21        ...
22        /* set default handler; this applies to all threads in the VM */
23        Thread.setDefaultUncaughtExceptionHandler(new UncaughtHandler());
24        ...
25    }
26 }

△ 左滑瀏覽全貌

從系統默認的 UncaughtExceptionHandler 中可以看出,APP Crash 時彈出的停止運行對話框以及退出進程操作都是在這裏處理中處理的,那麼只要不讓這個代碼繼續執行就可以阻止 APP 停止運行了。基於這個思路可以將這個方案表示爲如下的代碼:

 1 final Thread.UncaughtExceptionHandler defaultUncaughtExceptionHandler = Thread.getDefaultUncaughtExceptionHandler();
 2 Thread.setDefaultUncaughtExceptionHandler(new Thread.UncaughtExceptionHandler() {
 3    @Override
 4    public void uncaughtException(Thread t, Throwable e) {
 5        if (t.getName().equals("FinalizerWatchdogDaemon") && e instanceof TimeoutException) {
 6             //ignore it
 7        } else {
 8            defaultUncaughtExceptionHandler.uncaughtException(t, e);
 9        }
10    }
11 });

△ 左滑瀏覽全貌

  • 可行性

這種方案在 FinalizerWatchdogDaemon 出現 TimeoutException 時主動忽略這個異常,阻斷 UncaughtExceptionHandler 鏈式調用,使系統默認的 UncaughtExceptionHandler 不會被調用,APP 就不會停止運行而繼續存活下去。由於這個過程用戶無感知,對用戶無明顯影響,可以最大限度的減少對用戶的影響。

  • 優點

1.對系統侵入性小,不中斷 FinalizerWatchdogDaemon 的運行。

2.Thread.setDefaultUncaughtExceptionHandler() 方法是公開方法,兼容性比較好,可以適配目前所有 Android 版本。

▍總結

不管什麼樣的緩解措施,都是治標不治本,沒有從根源上解決。對於這類問題來說,雖然人爲阻止了 Crash,避免了 APP 停止,APP 能夠繼續運行,但是 finalize() 超時還是客觀存在的,如果 finalize() 一直超時的狀況得不到緩解,將會導致 FinalizerDaemon 中 FinalizerReference 隊列不斷增長,最終出現 OOM 。因此還需要從一點一滴做起,優化代碼結構,培養良好的代碼習慣,從而徹底解決這個問題。當然 BUG 不斷,優化不止,在解決問題的路上,緩解止損措施也是非常重要的手段。誰能說能抓老鼠的白貓不是好貓呢?

▍END
轉載請至 / 轉載合作入口

圖片描述

圖片描述

曾就職於奇虎360,長期從事移動端研發,2018年加入滴滴,專注於安卓移動端性能優化,架構演進,新技術探索,開源項目DroidAssist 作者。

圖片描述

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章