面試官問我:SharedPreference源碼中apply跟commit的原理,導致ANR的原因

語言: CN / TW / HK

記得看文章三部曲,點贊,評論,轉發。 微信搜索【程序員小安】關注還在移動開發領域苟活的大齡程序員,“面試系列”文章將在公眾號同步發佈。

1.前言

好幾年前寫過一篇SharedPreference源碼相關的文章,對apply跟commit方法講解的不夠透徹,作為顏值擔當的天才少年來説,怎麼能不一次深入到底呢?

2.正文

為了熟讀源碼,下班後我約了同事小雪一起探討,畢竟三人行必有我師焉。哪裏來的三個人,不管了,跟小雪研究學術更重要。

在這裏插入圖片描述

小安學長,看了你之前的文章:Android SharedPreference 源碼分析(一)對apply(),commit()的底層原理還是不理解,尤其是線程和一些同步鎖他裏面怎麼使用,什麼情況下會出現anr?

既然説到apply(),commit()的底層原理,那肯定是老步驟了,上源碼。 apply源碼如下:

```java public void apply() { final long startTime = System.currentTimeMillis();

        final MemoryCommitResult mcr = commitToMemory();
        final Runnable awaitCommit = new Runnable() {
                public void run() {
                    try {
                        mcr.writtenToDiskLatch.await();
                    } catch (InterruptedException ignored) {
                    }

                    if (DEBUG && mcr.wasWritten) {
                        Log.d(TAG, mFile.getName() + ":" + mcr.memoryStateGeneration
                                + " applied after " + (System.currentTimeMillis() - startTime)
                                + " ms");
                    }
                }
            };
        // 將 awaitCommit 添加到隊列 QueuedWork 中
        QueuedWork.addFinisher(awaitCommit);

        Runnable postWriteRunnable = new Runnable() {
                public void run() {
                    awaitCommit.run();
                    QueuedWork.removeFinisher(awaitCommit);// 將 awaitCommit 從隊列 QueuedWork 中移除
                }
            };

        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);
    }

```

你這丟了一大堆代碼,我也看不懂啊。

別急啊,這漫漫長夜留給我們的事情很多啊,聽我一點點給你講,包你滿意。 請添加圖片描述

apply()方法做過安卓的都知道(如果你沒有做過安卓,那你點開我博客幹什麼呢,死走不送),頻繁寫文件建議用apply方法,因為他是異步存儲到本地磁盤的。那麼具體源碼是如何操作的,讓我們掀開他的底褲,不是,讓我們透過表面看本質。

我們從下往上看,apply方法最後調用了SharedPreferencesImpl.this.enqueueDiskWrite(mcr, postWriteRunnable);我長得帥我先告訴你,enqueueDiskWrite方法會把存儲文件的動作放到子線程,具體怎麼放的,我們等下看源碼,這邊你只要知道他的作用。這個方法的第二個參數 postWriteRunnable做了兩件事:\ 1)讓awaitCommit執行,及執行 mcr.writtenToDiskLatch.await();\ 2)執行QueuedWork.remove(awaitCommit);代碼

writtenToDiskLatch是什麼,QueuedWork又是什麼?

writtenToDiskLatch是CountDownLatch的實例化對象,CountDownLatch是一個同步工具類,它通過一個計數器來實現的,初始值為線程的數量。每當一個線程完成了自己的任務調用countDown(),則計數器的值就相應得減1。當計數器到達0時,表示所有的線程都已執行完畢,然後在等待的線程await()就可以恢復執行任務。\ 1)countDown(): 對計數器進行遞減1操作,當計數器遞減至0時,當前線程會去喚醒阻塞隊列裏的所有線程。\ 2)await(): 阻塞當前線程,將當前線程加入阻塞隊列。 可以看到如果postWriteRunnable方法被觸發執行的話,由於 mcr.writtenToDiskLatch.await()的緣故,UI線程會被一直阻塞住,等待計數器減至0才能被喚醒。

QueuedWork其實就是一個基於handlerThread的,處理任務隊列的類。handlerThread類為你創建好了Looper和Thread對象,創建Handler的時候使用該looper對象,則handleMessage方法在子線程中,可以做耗時操作。如果對於handlerThread的不熟悉的話,可以看我前面的文章:Android HandlerThread使用介紹以及源碼解析

在這裏插入圖片描述 覺得厲害,那咱就繼續深入。\ enqueueDiskWrite源碼如下所示: ```java private void enqueueDiskWrite(final MemoryCommitResult mcr, final Runnable postWriteRunnable) { final boolean isFromSyncCommit = (postWriteRunnable == null);

    final Runnable writeToDiskRunnable = new Runnable() {
            public void run() {
                synchronized (mWritingToDiskLock) {
                    writeToFile(mcr, isFromSyncCommit);
                }
                synchronized (mLock) {
                    mDiskWritesInFlight--;
                }
                if (postWriteRunnable != null) {
                    postWriteRunnable.run();
                }
            }
        };

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

    QueuedWork.queue(writeToDiskRunnable, !isFromSyncCommit);
}

``` 很明顯postWriteRunnable不為null,程序會執行QueuedWork.queue(writeToDiskRunnable, !isFromSyncCommit);從writeToDiskRunnable我們可以看到,他裏面做了兩件事:\ 1)writeToFile():內容存儲到文件;\ 2)postWriteRunnable.run():postWriteRunnable做了什麼,往上看,上面已經講了該方法做的兩件事。

QueuedWork.queue源碼: ```java public static void queue(Runnable work, boolean shouldDelay) { Handler handler = getHandler();

    synchronized (sLock) {
        sWork.add(work);

        if (shouldDelay && sCanDelay) {
            handler.sendEmptyMessageDelayed(QueuedWorkHandler.MSG_RUN, DELAY);
        } else {
            handler.sendEmptyMessage(QueuedWorkHandler.MSG_RUN);
        }
    }
}

java private static class QueuedWorkHandler extends Handler { static final int MSG_RUN = 1;

    QueuedWorkHandler(Looper looper) {
        super(looper);
    }

    public void handleMessage(Message msg) {
        if (msg.what == MSG_RUN) {
            processPendingWork();
        }
    }
}

``` 這邊我默認你已經知道HandlerThread如何使用啦,如果不知道,麻煩花五分鐘去看下我之前的博客。\ 上面的代碼很簡單,其實就是把writeToDiskRunnable這個任務放到sWork這個list中,並且執行handler,根據HandlerThread的知識點,我們知道handlermessage裏面就是子線程了。

接下來我們繼續看handleMessage裏面的processPendingWork()方法: ```java private static void processPendingWork() { long startTime = 0;

    if (DEBUG) {
        startTime = System.currentTimeMillis();
    }

    synchronized (sProcessingWork) {
        LinkedList<Runnable> work;

        synchronized (sLock) {
            work = (LinkedList<Runnable>) sWork.clone();
            sWork.clear();

            // Remove all msg-s as all work will be processed now
            getHandler().removeMessages(QueuedWorkHandler.MSG_RUN);
        }

        if (work.size() > 0) {
            for (Runnable w : work) {
                w.run();
            }

            if (DEBUG) {
                Log.d(LOG_TAG, "processing " + work.size() + " items took " +
                        +(System.currentTimeMillis() - startTime) + " ms");
            }
        }
    }
}

這代碼同樣很簡單,先是把sWork克隆給work,然後開啟循環,執行work對象的run方法,及調用writeToDiskRunnable的run方法。上面講過了,他裏面做了兩件事:1)內容存儲到文件 2)postWriteRunnable方法回調。 執行run方法的代碼:java final Runnable writeToDiskRunnable = new Runnable() { public void run() { synchronized (mWritingToDiskLock) { writeToFile(mcr, isFromSyncCommit);//由於handlermessage在子線程,則writeToFile也在子線程中 } synchronized (mLock) { mDiskWritesInFlight--; } if (postWriteRunnable != null) { postWriteRunnable.run(); } } }; writeToFile方法我們不深入去看,但是要關注,裏面有個setDiskWriteResult方法,在該方法裏面做了如下的事情:java void setDiskWriteResult(boolean wasWritten, boolean result) { this.wasWritten = wasWritten; writeToDiskResult = result; writtenToDiskLatch.countDown();//計數器-1 } ``` 如何上面認真看了的同學,應該可以知道,當調用countDown()方法時,會對計數器進行遞減1操作,當計數器遞減至0時,當前線程會去喚醒阻塞隊列裏的所有線程。也就是説,當文件寫完時,UI線程會被喚醒。

既然文件寫完就會釋放鎖,那什麼情況下會出現ANR呢?

Android系統為了保障在頁面切換,也就是在多進程中sp文件能夠存儲成功,在ActivityThread的handlePauseActivity和handleStopActivity時會通過waitToFinish保證這些異步任務都已經被執行完成。如果這個時候過渡使用apply方法,則可能導致onpause,onStop執行時間較長,從而導致ANR。 ```java private void handlePauseActivity(IBinder token, boolean finished, boolean userLeaving, int configChanges, boolean dontReport, int seq) { ...... r.activity.mConfigChangeFlags |= configChanges; performPauseActivity(token, finished, r.isPreHoneycomb(), "handlePauseActivity");

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

       ......
}

``` 你肯定要問,為什麼過渡使用apply方法,就有可能導致ANR?那我們只能看QueuedWork.waitToFinish();到底做了什麼

```java public static void waitToFinish() { long startTime = System.currentTimeMillis(); boolean hadMessages = false;

    Handler handler = getHandler();

    synchronized (sLock) {
        if (handler.hasMessages(QueuedWorkHandler.MSG_RUN)) {
            // Delayed work will be processed at processPendingWork() below
            handler.removeMessages(QueuedWorkHandler.MSG_RUN);

            if (DEBUG) {
                hadMessages = true;
                Log.d(LOG_TAG, "waiting");
            }
        }

        // We should not delay any work as this might delay the finishers
        sCanDelay = false;
    }

    StrictMode.ThreadPolicy oldPolicy = StrictMode.allowThreadDiskWrites();
    try {
        processPendingWork();
    } finally {
        StrictMode.setThreadPolicy(oldPolicy);
    }

    try {
        while (true) {
            Runnable finisher;

            synchronized (sLock) {
                finisher = sFinishers.poll();
            }

            if (finisher == null) {
                break;
            }

            finisher.run();
        }
    } finally {
        sCanDelay = true;
    }

    synchronized (sLock) {
        long waitTime = System.currentTimeMillis() - startTime;

        if (waitTime > 0 || hadMessages) {
            mWaitTimes.add(Long.valueOf(waitTime).intValue());
            mNumWaits++;

            if (DEBUG || mNumWaits % 1024 == 0 || waitTime > MAX_WAIT_TIME_MILLIS) {
                mWaitTimes.log(LOG_TAG, "waited: ");
            }
        }
    }
}

``` 看着一大坨代碼,其實做了兩件事:\ 1)主線程執行processPendingWork()方法,把之前未執行完的內容存儲到文件的操作執行完,這部分動作直接在主線程執行,如果有未執行的文件操作並且文件較大,則主線程會因為IO時間長造成ANR。\ 2)循環取出sFinishers數組,執行他的run方法。如果這時候有多個異步線程或者異步線程時間過長,同樣會造成阻塞產生ANR。

第一個很好理解,第二個沒有太看明白,sFinishers數組是在什麼時候add數據的,而且根據writeToDiskRunnable方法可以知道,先寫文件再加鎖的,為啥會阻塞呢?

在這裏插入圖片描述

sFinishers的addFinisher方法是在apply()方法裏面調用的,代碼如下: ```java @Override public void apply() { ...... // 將 awaitCommit 添加到隊列 QueuedWork 中 QueuedWork.addFinisher(awaitCommit);

        Runnable postWriteRunnable = new Runnable() {
                @Override
                public void run() {
                    awaitCommit.run();
                    QueuedWork.removeFinisher(awaitCommit);// 將 awaitCommit 從隊列 QueuedWork 中移除
                }
            };
        ......
    }

正常情況下其實是不會發生ANR的,因為writeToDiskRunnable方法中,是先進行文件存儲再去阻塞等待的,此時CountDownLatch永遠都為0,則不會阻塞主線程。java final Runnable writeToDiskRunnable = new Runnable() { @Override public void run() { synchronized (mWritingToDiskLock) { writeToFile(mcr, isFromSyncCommit);//寫文件,寫成功後會調用writtenToDiskLatch.countDown();計數器-1 } synchronized (mLock) { mDiskWritesInFlight--; } if (postWriteRunnable != null) { postWriteRunnable.run();//回調到awaitCommit.run();進行阻塞 } } }; ```

但是如果processPendingWork方法在異步線程在執行時,及通過enqueueDiskWrite方法觸發的正常文件保存流程,這時候文件比較大或者文件比較多,子線程則一直在運行中;當用户點擊頁面跳轉時,則觸發該Activity的handlePauseActivity方法,根據上面的分析,handlePauseActivity方法裏面會執行waitToFinish保證這些異步任務都已經被執行完成。\ 由於這邊主要介紹循環取出sFinishers數組,執行他的run方法造成阻塞產生ANR,我們就重點看下sFinishers數組對象是什麼,並且執行什麼動作。 java private static final LinkedList<Runnable> sFinishers = new LinkedList<>(); @UnsupportedAppUsage public static void addFinisher(Runnable finisher) { synchronized (sLock) { sFinishers.add(finisher); } } addFinisher剛剛上面提到是在apply方法中調用,則finisher就是入參awaitCommit,他的run方法如下: ```java final Runnable awaitCommit = new Runnable() { @Override public void run() { try { mcr.writtenToDiskLatch.await();//阻塞 } catch (InterruptedException ignored) { }

                    if (DEBUG && mcr.wasWritten) {
                        Log.d(TAG, mFile.getName() + ":" + mcr.memoryStateGeneration
                                + " applied after " + (System.currentTimeMillis() - startTime)
                                + " ms");
                    }
                }
            };

``` 不難看出,就是調用CountDownLatch對象的await方法,阻塞當前線程,將當前線程加入阻塞隊列。也就是這個時候整個UI線程都阻塞在這邊,等待processPendingWork這個異步線程執行完畢,雖然你是在子線程,但是我主線程在等你執行結束才會進行頁面切換,所以如果過渡使用apply方法,則可能導致onpause,onStop執行時間較長,從而導致ANR。

小安學長不愧是我的偶像,我都明白了,那繼續講講同步存儲commit()方法吧。

commit方法其實就比較簡單了,無非是內存和文件都在UI線程中,我們看下代碼證實一下: ```java @Override public boolean commit() { long startTime = 0;

        if (DEBUG) {
            startTime = System.currentTimeMillis();
        }

        MemoryCommitResult mcr = commitToMemory();//內存保存

        SharedPreferencesImpl.this.enqueueDiskWrite(
            mcr, null /* sync write on this thread okay */);//第二個參數為null
        try {
            mcr.writtenToDiskLatch.await();
        } catch (InterruptedException e) {
            return false;
        } finally {
            if (DEBUG) {
                Log.d(TAG, mFile.getName() + ":" + mcr.memoryStateGeneration
                        + " committed after " + (System.currentTimeMillis() - startTime)
                        + " ms");
            }
        }
        notifyListeners(mcr);
        return mcr.writeToDiskResult;
    }

``` 可以看到enqueueDiskWrite的第二個參數為null,enqueueDiskWrite方法其實上面講解apply的時候已經貼過了,為了不讓你往上翻我們繼續看enqueueDiskWrite方法:

```java private void enqueueDiskWrite(final MemoryCommitResult mcr, final Runnable postWriteRunnable) { final boolean isFromSyncCommit = (postWriteRunnable == null);//此時postWriteRunnable為null,isFromSyncCommit 則為true

    final Runnable writeToDiskRunnable = new Runnable() {
            @Override
            public void run() {
                synchronized (mWritingToDiskLock) {
                    writeToFile(mcr, isFromSyncCommit);
                }
                synchronized (mLock) {
                    mDiskWritesInFlight--;
                }
                if (postWriteRunnable != null) {
                    postWriteRunnable.run();
                }
            }
        };

    // Typical #commit() path with fewer allocations, doing a write on
    // the current thread.
    if (isFromSyncCommit) {  //當調用commit方法時,isFromSyncCommit則為true
        boolean wasEmpty = false;
        synchronized (mLock) {
            wasEmpty = mDiskWritesInFlight == 1;
        }
        if (wasEmpty) {
            writeToDiskRunnable.run();//主線程回調writeToDiskRunnable的run方法,進行writeToFile文件的存儲
            return;
        }
    }

    QueuedWork.queue(writeToDiskRunnable, !isFromSyncCommit);
}

``` 關鍵代碼已經註釋過了,由於postWriteRunnable為null,則isFromSyncCommit為true,代碼會在主線程回調writeToDiskRunnable的run方法,進行writeToFile文件的存儲。這部分動作直接在主線程執行,如果文件較大,則主線程也會因為IO時間長造成ANR的。

所以SharedPreference 不管是commit()還是apply()方法,如果文件過大或者過多,都會有ANR的風險,那如何規避呢?

解決肯定有辦法的,下一篇就介紹SharedPreference 的替代方案mmkv的原理,只是今晚有點晚了,咱們早上睡吧,不是,早點回家吧~~~