面試官問我: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的原理,只是今晚有點晚了,咱們早上睡吧,不是,早點回家吧~~~