面試官問我:SharedPreference原始碼中apply跟commit的原理,導致ANR的原因
記得看文章三部曲,點贊,評論,轉發。 微信搜尋【程式設計師小安】關注還在移動開發領域苟活的大齡程式設計師,“面試系列”文章將在公眾號同步釋出。
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的原理,只是今晚有點晚了,咱們早上睡吧,不是,早點回家吧~~~
- 面試官問我:SharedPreference原始碼中apply跟commit的原理,導致ANR的原因
- 面試官問我:同步屏障和非同步訊息的執行機制
- 面試官問我:ThreadLocal的原理是什麼,Looper物件為什麼要存在ThreadLocal中?
- 華為研發費10年暴增9倍:手機跌出全球前五,招百位天才少年加入
- 太賽博朋克了!華為天才少年自制B站百大Up獎盃,網友:技術難度不高,但侮辱性極強...
- 面試官問我:如何使用LeakCanary排查Android中的記憶體洩露,看我如何用漫畫裝逼!
- 面試官問我:Android EventBus的原始碼,看我如何用漫畫裝逼!
- 面試官問我:Android APP中如何測試FPS?看我如何分析京東,拼多多App的FPS
- 面試官問我:Andriod中子執行緒為什麼不能更新UI?
- 看完這篇View繪製原理,和阿里面試官扯皮就沒問題了