天天看點

SharedPreference apply 引起的 ANR 問題SharedPreference如何阻塞主線程

轉發:

作者:位元組跳動技術團隊

連結:https://www.jianshu.com/p/9ae0f6842689

來源:簡書

簡書著作權歸作者所有,任何形式的轉載都請聯系作者獲得授權并注明出處。

項目中 ANR 率居高不下,從統計上來看排在前面的有幾個都是 SharedPreference(以下簡稱 SP)引起的。接下來我們抽絲剝繭的來分析其産生原因及如何解決。

crash 堆棧資訊如下。從 crash 收集平台上來看,有幾個類似的堆棧資訊。唯一的差別就是 ActivityThread 的入口方法。除了 ActivityThread 的 handleSleeping方法之外,還有 handleServiceArgs、handleStopService、handleStopActivity。

SharedPreference apply 引起的 ANR 問題SharedPreference如何阻塞主線程

image

ActivityThread 的這幾個方法是 Activity 或 Service 的生命周期變化的時候調用的。從堆棧資訊來看,元件生命周期變化,導緻調用 QueueWork 中的隊列處于等待狀态,等待逾時則發生 ANR。那麼 QueuedWork 的工作機制是什麼樣的呢,我們從源碼入手來進行分析。

SP 的 apply 到底做了什麼

首先從問題的源頭開始,SP 的 apply 方法。

apply 方法,首先建立了一個 awaitCommit 的 Runnable,然後加入到 QueuedWork 中,awaitCommit 中包含了一個等待鎖,需要在其它地方釋放。我們在上面看到的 QueuedWork.waitToFinish() 其實就是等待這個隊列中的 awaitCommit 全部釋放。

然後通過 SharedPreferencesImpl.this.enqueueDiskWrite 建立了一個任務來執行真正的 SP 持久化。

其實無論是 SP 的 commit 還是 apply 最終都會調用 enqueueDiskWrite 方法,差別是 commit 方法調用傳遞的第二個參數為 null。此方法内部也是根據第二個參數來區分 commit 和 apply 的,如果是 commit 則會同步的執行 writeToFileapply則會将 writeToFile 加入到一個任務隊列中異步的執行,從這裡也可以看出 commit 和 apply 的真正差別。

writeToFile 執行完成會釋放等待鎖,之後會回調傳遞進來的第二個參數 Runnable 的 run 方法,并将 QueuedWork 中的這個等待任務移除。

總結來看,SP 調用 apply 方法,會建立一個等待鎖放到 QueuedWork 中,并将真正資料持久化封裝成一個任務放到異步隊列中執行,任務執行結束會釋放鎖。Activity onStop 以及 Service 處理 onStop,onStartCommand 時,執行 QueuedWork.waitToFinish() 等待所有的等待鎖釋放。

如何解決,清空等待隊列

從上述分析來看,SP 操作僅僅把 commit 替換為 apply 不是萬能的,apply 調用次數過多容易引起 ANR。所有此類 ANR 都是經由 QueuedWork.waitToFinish() 觸發的,如果在調用此函數之前,将其中儲存的隊列手動清空,那麼是不是能解決問題呢,答案是肯定的。

Activity 的 onStop,以及 Service 的 onStop 和 onStartCommand 都是通過 ActivityThread 觸發的,ActivityThread 中有一個 Handler 變量,我們通過 Hook 拿到此變量,給此 Handler 設定一個 callback,Handler 的 dispatchMessage 中會先處理 callback。

1.在 Callback 中調用隊列的清理工作

2.隊列清理需要反射調用 QueuedWork。

清理等待鎖會産生什麼問題

SP 無論是 commit 還是 apply 都會産生 ANR,但從 Android 之初到目前 Android8.0,Google 一直沒有修複此 bug,我們貿然處理會産生什麼問題呢。Google 在 Activity 和 Service 調用 onStop 之前阻塞主線程來處理 SP,我們能猜到的唯一原因是盡可能的保證資料的持久化。因為如果在運作過程中産生了 crash,也會導緻 SP 未持久化,持久化本身是 IO 操作,也會失敗。我們清理了等待鎖隊列,會對資料持久化造成什麼影響呢,下面我們通過一組實驗來驗證。

程序啟動的時候,産生一個随機數字。用 commit 和 apply 兩種方式來存此變量。第二次程序啟動,擷取以兩種方式存取的值并做比較,如果相同表示 apply 持久化成功,如果不相同表示 apply 持久化失敗。

實驗一:開啟等待鎖隊列的清理。

實驗二:關閉等待鎖隊列的清理。

線上同時開啟兩個實驗,在實驗規模相同的情況下,統計 apply 失敗率。

實驗一,失敗率為 1.84%。

實驗二,失敗率為為 1.79%

可見,apply 機制本身的失敗率就比較高,清理等待鎖隊列對持久化造成的影響不大。

目前頭條 app 已經全量開啟清理等待鎖政策,上線至今沒有發現此政策産生的使用者回報。

SharedPreference如何阻塞主線程

https://www.jianshu.com/p/63ee8587de3f

最近發現我們的很多anr的原因都指向了SharedPreference,那麼帶着一些疑問,作如下探索:

  • sharedPreference為什麼會阻塞主線程?
  • sharedPreference有沒有記憶體緩存,他是如何讀和寫的?會立即寫入檔案嗎?
  • 他是如何保證資料同步的,如何才能避免sharedPreference引起的anr?

從sharedPreference的建立,到讀取,到寫入

sp的建立

先來看看sharedPreference是如何建立的,在ContextImple.getSharedPreference()中,

@Override
    public SharedPreferences getSharedPreferences(File file, int mode) {
        checkMode(mode);
        SharedPreferencesImpl sp;
        synchronized (ContextImpl.class) {
            final ArrayMap<File, SharedPreferencesImpl> cache = getSharedPreferencesCacheLocked();
            sp = cache.get(file);
            if (sp == null) {
                sp = new SharedPreferencesImpl(file, mode);
                cache.put(file, sp);
                return sp;
            }
        }
        if ((mode & Context.MODE_MULTI_PROCESS) != 0 ||
            getApplicationInfo().targetSdkVersion < android.os.Build.VERSION_CODES.HONEYCOMB) {
            // If somebody else (some other process) changed the prefs
            // file behind our back, we reload it.  This has been the
            // historical (if undocumented) behavior.
            sp.startReloadIfChangedUnexpectedly();
        }
        return sp;
    }
           

他會有緩存,并不是每次都去檔案中讀寫,有一個以sharedPreference的名稱為key(通過名稱緩存一個file,以這個file為key),對應這個sharedPerference的内容為value的靜态的map來緩存整個應用中的sp,是以我們最好不要建立過多的小的sp,盡量合并,不然這個靜态的map會很大。

然後看看sp的構造函數:

SharedPreferencesImpl(File file, int mode) {
        mFile = file;
        mBackupFile = makeBackupFile(file);
        mMode = mode;
        mLoaded = false;
        mMap = null;
        startLoadFromDisk();//
    }
    
//初始化的時候會開一個線程去讀取xml檔案。
    private void startLoadFromDisk() {
        synchronized (this) {
            mLoaded = false;
        }
        new Thread("SharedPreferencesImpl-load") {
            public void run() {
                loadFromDisk();
            }
        }.start();
    }
           

從構造函數中可以看出來:他會開一個線程去讀取檔案資料,也就是上次存儲的檔案,讀到記憶體中。(由此可以看出,sp是有記憶體緩存的)

sp的讀取:

每次讀取都會對目前的sp對象加鎖,然後判斷是否load本地檔案成功

@Nullable
    public String getString(String key, @Nullable String defValue) {
        synchronized (this) {
            awaitLoadedLocked();
            String v = (String)mMap.get(key);
            return v != null ? v : defValue;
        }
    }
           

這裡的

awaitLoadedLocked()

就是等待sp的建立,其實在sp的構造方法中已經開了一個線程去load本地檔案,這裡隻是等待他load完成。

load完成之後就可以從記憶體中去取了。

sp的寫操作:

我們一般使用editor對sp去進行寫操作。

先來看看editor如何建立出來的:

public Editor edit() {
        // TODO: remove the need to call awaitLoadedLocked() when
        // requesting an editor.  will require some work on the
        // Editor, but then we should be able to do:
        //
        //      context.getSharedPreferences(..).edit().putString(..).apply()
        //
        // ... all without blocking.
        synchronized (this) {
            awaitLoadedLocked();
        }

        return new EditorImpl();
    }
           

這裡可以看出來,就算是你不讀,隻寫,他也需要等到讀取本地檔案完成。

editor裡用一個map将改動的東西存起來,當送出的時候他會把他先送出到記憶體,然後再形成一個異步的送出。

editor裡可以暫時存放多個key的改動,然後形成一次送出,如果我們可以将多個送出合并成一次送出,盡量合并,因為每一次調用apply或者commit都會形成一個新的送出,建立各種鎖。

主要來看一下他的apply方法:

public void apply() {
            final MemoryCommitResult mcr = commitToMemory();
            final Runnable awaitCommit = new Runnable() {
                    public void run() {
                        try {
                            //阻塞調用者,誰調用,阻塞誰
                            mcr.writtenToDiskLatch.await();
                        } catch (InterruptedException ignored) {
                        }
                    }
                };

            QueuedWork.add(awaitCommit);

            Runnable postWriteRunnable = new Runnable() {
                    public void run() {
                        awaitCommit.run();
                        QueuedWork.remove(awaitCommit);
                    }
                };

            SharedPreferencesImpl.this.enqueueDiskWrite(mcr, postWriteRunnable);

            // Okay to notify the listeners before it's hit disk
            // because the listeners should always get the same
            // SharedPreferences instance back, which has the
            // changes reflected in memory.
            notifyListeners(mcr);
        }
           

這裡會先建立一個awaitCommit的Runnable,主要是用來阻塞調用者(

writtenToDiskLatch.await()

誰調用阻塞誰),然後将這個awaitCommit加到QueuedWrok的隊列中,然後又建立了一個postWriteRunnable,裡面主要是做清除工作。然後最後一句enqueueDiskWrite()這個方法:

private void enqueueDiskWrite(final MemoryCommitResult mcr,
                                  final Runnable postWriteRunnable) {
        final Runnable writeToDiskRunnable = new Runnable() {
                public void run() {
                    synchronized (mWritingToDiskLock) {
                        writeToFile(mcr);
                    }
                    synchronized (SharedPreferencesImpl.this) {
                        mDiskWritesInFlight--;
                    }
                    if (postWriteRunnable != null) {
                        postWriteRunnable.run();
                    }
                }
            };

        final boolean isFromSyncCommit = (postWriteRunnable == null);

        // Typical #commit() path with fewer allocations, doing a write on
        // the current thread.
        if (isFromSyncCommit) {
            boolean wasEmpty = false;
            synchronized (SharedPreferencesImpl.this) {
                wasEmpty = mDiskWritesInFlight == 1;
            }
            if (wasEmpty) {
                writeToDiskRunnable.run();
                return;
            }
        }

        QueuedWork.singleThreadExecutor().execute(writeToDiskRunnable);
    }
           

這裡又建立了一個Runnable,我們來理清一下他們之間的調用關系。

SharedPreference apply 引起的 ANR 問題SharedPreference如何阻塞主線程

sp.png

從上圖可以看到,其實那個加入到單線程線程池中的異步寫檔案操作(writeToDiskRunnable)才真正成為了一個異步任務,其他的兩個runnable隻是被調用了run方法。

一個異步寫操作:先調用寫入檔案,寫入完成調用setDiskWriteResult()這裡将計數鎖減一,表示目前這個寫操作完成。然後調用postWriteRunnable做清除隊列操作,這裡會調用awaitCommit這個runnable裡的await()但是因為剛剛的鎖已經解除了,是以這裡不會阻塞。這樣就表示一次apply的異步任務完成。

但是他為什麼要把awaitCommit這個Runnable存放到一個靜态的隊列中去呢?這裡就是阻塞主線程的關鍵了。

在QueuedWork這個類的主要内容:

/**
 * Internal utility class to keep track of process-global work that's
 * outstanding and hasn't been finished yet.
 *
 * This was created for writing SharedPreference edits out
 * asynchronously so we'd have a mechanism to wait for the writes in
 * Activity.onPause and similar places, but we may use this mechanism
 * for other things in the future.
 *
 * @hide
 */
 
    // The set of Runnables that will finish or wait on any async
    // activities started by the application.
    private static final ConcurrentLinkedQueue<Runnable> sPendingWorkFinishers =
            new ConcurrentLinkedQueue<Runnable>();
            
    /**
     * Add a runnable to finish (or wait for) a deferred operation
     * started in this context earlier.  Typically finished by e.g.
     * an Activity#onPause.  Used by SharedPreferences$Editor#startCommit().
     *
     * Note that this doesn't actually start it running.  This is just
     * a scratch set for callers doing async work to keep updated with
     * what's in-flight.  In the common case, caller code
     * (e.g. SharedPreferences) will pretty quickly call remove()
     * after an add().  The only time these Runnables are run is from
     * waitToFinish(), below.
     */
    public static void add(Runnable finisher) {
        sPendingWorkFinishers.add(finisher);
    }
    
    
    /**
     * Finishes or waits for async operations to complete.
     * (e.g. SharedPreferences$Editor#startCommit writes)
     *
     * Is called from the Activity base class's onPause(), after
     * BroadcastReceiver's onReceive, after Service command handling,
     * etc.  (so async work is never lost)
     */
    public static void waitToFinish() {
        Runnable toFinish;
        while ((toFinish = sPendingWorkFinishers.poll()) != null) {
            toFinish.run();
        }
    }

           

這裡可以看出,他是要保證寫入的内容不會丢失,是以才會将每個apply的await存起來,然後依次調用,如果有沒有完成的,則阻塞調用者也就是主線程。

那,到底是在哪裡調用的呢?

那我們就來找在我們的崩潰日志中,多次出現的

at java.util.concurrent.CountDownLatch.await(CountDownLatch.java:202)
at android.app.SharedPreferencesImpl$EditorImpl$1.run(SharedPreferencesImpl.java:364)
at android.app.QueuedWork.waitToFinish(QueuedWork.java:88)
at android.app.ActivityThread.handleStopActivity(ActivityThread.java:3246)
at android.app.ActivityThread.access$1100(ActivityThread.java:141)
at android.app.ActivityThread$H.handleMessage(ActivityThread.java:1239)
           

這裡看到ActivityThread。handleStopActivity()這個方法,果然在這個方法中能找到調用QueueWork中的await的地方:

private void handleStopActivity(IBinder token, boolean show, int configChanges, int seq) {
        ActivityClientRecord r = mActivities.get(token);
        if (!checkAndUpdateLifecycleSeq(seq, r, "stopActivity")) {
            return;
        }
        r.activity.mConfigChangeFlags |= configChanges;

        StopInfo info = new StopInfo();
        performStopActivityInner(r, info, show, true, "handleStopActivity");

        if (localLOGV) Slog.v(
            TAG, "Finishing stop of " + r + ": show=" + show
            + " win=" + r.window);

        updateVisibility(r, show);

        // Make sure any pending writes are now committed.
        if (!r.isPreHoneycomb()) {
            QueuedWork.waitToFinish();
        }

        // Schedule the call to tell the activity manager we have
        // stopped.  We don't do this immediately, because we want to
        // have a chance for any other pending work (in particular memory
        // trim requests) to complete before you tell the activity
        // manager to proceed and allow us to go fully into the background.
        info.activity = r;
        info.state = r.state;
        info.persistentState = r.persistentState;
        mH.post(info);
        mSomeActivitiesChanged = true;
    }
           

這個方法會在什麼時候調用呢?

當系統給app發送了指令之後會調用

再看一下這個handleStopActivity調用了哪些方法:

handleStopActivity的調用鍊

ActivityThread.handleStopActivity
    ActivityThread.performStopActivityInner
        ActivityThread.callCallActivityOnSaveInstanceState
            Instrumentation.callActivityOnSaveInstanceState
                Activity.performSaveInstanceState
                    Activity.onSaveInstanceState

        ActivityThread.performStop
            Activity.performStop
                Instrumentation.callActivityOnStop
                    Activity.onStop

    updateVisibility

    H.post(StopInfo)
        AMP.activityStopped
            AMS.activityStopped
                ActivityStack.activityStoppedLocked
                AMS.trimApplications
                    ProcessRecord.kill
                    ApplicationThread.scheduleExit
                        Looper.myLooper().quit()

                    AMS.cleanUpApplicationRecordLocked
                    AMS.updateOomAdjLocked
           

看到當handleStopActivity被調用之後會回調一些我們熟悉的方法

  • Activity.onSaveInstanceState
  • Activity.onStop

總結一下:

使用了apply方式異步寫sp的時候每次apply()調用都會形成一次送出,每次有系統消息發生的時候(handleStopActivity, handlePauseActivity)都會去檢查已經送出的apply寫操作是否完成,如果沒有完成則阻塞主線程。

作者:ironman_

連結:https://www.jianshu.com/p/63ee8587de3f

來源:簡書

簡書著作權歸作者所有,任何形式的轉載都請聯系作者獲得授權并注明出處。

繼續閱讀