在日志后臺上ANR的Top1問題品嚣,SharedPreferences相關的anr問題悠夯,我們經(jīng)常會遇到是晨。
主要anr日志:
"main" prio=5 tid=1 WAIT
| group="main" sCount=1 dsCount=0 cgrp=default handle=1074614660
| sysTid=10796 nice=-4 sched=0/0 cgrp=default handle=1074614660
| state=S schedstat=( 7395789134 225970925 16305 ) utm=616 stm=123 core=0
at java.lang.Object.wait(Native Method)
at java.lang.Thread.parkFor(Thread.java:1212)
at sun.misc.Unsafe.park(Unsafe.java:325)
at java.util.concurrent.locks.LockSupport.park(LockSupport.java:157)
at java.util.concurrent.locks.AbstractQueuedSynchronizer.parkAndCheckInterrupt(AbstractQueuedSynchronizer.java:813)
at java.util.concurrent.locks.AbstractQueuedSynchronizer.doAcquireSharedInterruptibly(AbstractQueuedSynchronizer.java:973)
at java.util.concurrent.locks.AbstractQueuedSynchronizer.acquireSharedInterruptibly(AbstractQueuedSynchronizer.java:1281)
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:3561)
at android.app.ActivityThread.access$1100(ActivityThread.java:172)
問題分析:
該問題是與SharedPreferences操作相關的癌椿。在我們的代碼中健蕊,使用sp讀寫配置文件,都是采用了官方的推薦做法踢俄,調用apply提交绊诲,調用這個方法時,會首先寫入內存中褪贵,然后將落盤的任務加入隊列中掂之,會在異步線程中做落盤的操作,這個操作一般來說是沒有問題的脆丁,也是google官方推薦的做法世舰。但是另一方面android的系統(tǒng)會在Activity的onStop,onPause等生命周期中,調用QueuedWork.waitToFinish槽卫,等待落盤的任務隊列執(zhí)行完成跟压,如果任務隊列中的任務很多,或者待寫入的數(shù)據(jù)量很大時(sp文件是全量讀寫的)歼培,在一些io性能差的中低端機型上就會很容易出現(xiàn)anr.
SharedPreferences的源碼流程震蒋,可以參考鏈接:http://gityuan.com/2017/06/18/SharedPreferences/
下面主要分析apply方法的流程:
final class SharedPreferencesImpl implements SharedPreferences {
public void apply() {
//將數(shù)據(jù)提交到內存中
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);
// 通知回調
notifyListeners(mcr);
}
apply的基本流程是:
- 首先調用commitToMemory將數(shù)據(jù)改動同步到內存中,也就是SharedPreferencesImpl的mMap(HashMap)
- 然后調用 QueuedWork.add(awaitCommit);將一個等待的任務加入到列表中躲庄,在Activity等的生命周期中查剖,就是以這個為判斷條件,等待寫入任務執(zhí)行完成的噪窘。
- 調用enqueueDiskWrite方法的實現(xiàn)笋庄,將寫入任務加入到隊列中,寫入磁盤的操作會在子線程中執(zhí)行。
enqueueDiskWrite方法的實現(xiàn):
private void enqueueDiskWrite(final MemoryCommitResult mcr,
final Runnable postWriteRunnable) {
final Runnable writeToDiskRunnable = new Runnable() {
public void run() {
synchronized (mWritingToDiskLock) {
// 真正執(zhí)行寫入文件的操作
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;
}
}
//將寫入磁盤的任務加入到單線程的線程池中(8.0之前)
QueuedWork.singleThreadExecutor().execute(writeToDiskRunnable);
}
加入到任務隊列的處理中直砂,android8.0之前菌仁,是將runnable任務加入到單線程的線程池中, android 8.0之后做了很大的調整静暂,幾乎是對QueuedWork類做了重寫济丘。android 8.0中是將任務加入到LinkedList鏈表中,而且是在HandlerThread中做異步處理洽蛀,而不是使用線程池闪盔。
android 8.0 QueuedWork.java:
public static void queue(Runnable work, boolean shouldDelay) {
Handler handler = getHandler();
synchronized (sLock) {
//將任務加入到鏈表中
sWork.add(work);
if (shouldDelay && sCanDelay) {
//延時100ms執(zhí)行
handler.sendEmptyMessageDelayed(QueuedWorkHandler.MSG_RUN, DELAY);
} else {
handler.sendEmptyMessage(QueuedWorkHandler.MSG_RUN);
}
}
}
//執(zhí)行寫入磁盤任務
private static void processPendingWork() {
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);
}
// 將任務從鏈表中依次取出執(zhí)行
if (work.size() > 0) {
for (Runnable w : work) {
w.run();
}
}
}
}
調用QueuedWork.waitToFinish()方法的代碼:
ActivityThread.java:
Activity 的 onStop,以及 Service 的 onStop 和 onStartCommand 都是通過 ActivityThread 觸發(fā)的辱士。在一些組件的生命周期回調中泪掀,比如Service.onStartCommand,Service.onDestroy,Activity.onPause,Activity.onStop時,會調用QueuedWork.waitToFinish();去等待所有寫入任務的執(zhí)行完成颂碘。
在android 8.0之前异赫,這個方法的實現(xiàn):
public static void waitToFinish() {
Runnable toFinish;
//等待所有的任務執(zhí)行完成
while ((toFinish = sPendingWorkFinishers.poll()) != null) {
toFinish.run();
}
}
sPendingWorkFinishers并不是寫入任務的列表,而是等待狀態(tài)的列表头岔,這個方法的作用就是如名字所代表的塔拳,就是在等待完成,阻塞主線程峡竣,干等著靠抑。
這里的toFinish.run方法,其實就只是執(zhí)行一行代碼:mcr.writtenToDiskLatch.await(); 在等待寫入完成.
android 8.0 之前的實現(xiàn)QueuedWork.waitToFinish是有缺陷的适掰。在多個生命周期方法中颂碧,在主線程等待任務隊列去執(zhí)行完畢,而由于cpu調度的關系任務隊列所在的線程并不一定是處于執(zhí)行狀態(tài)的类浪,而且當apply提交的任務比較多時载城,等待全部任務執(zhí)行完成,會消耗不少時間费就,這就有可能出現(xiàn)anr.
android 8.0的優(yōu)化
而android 8.0以后诉瓦,這個方法的實現(xiàn)做了很大的改變;
public static void waitToFinish() {
Handler handler = getHandler();
synchronized (sLock) {
if (handler.hasMessages(QueuedWorkHandler.MSG_RUN)) {
// Delayed work will be processed at processPendingWork() below
handler.removeMessages(QueuedWorkHandler.MSG_RUN);
}
sCanDelay = false;
}
...
// 觸發(fā)依次調用所有的寫入任務
processPendingWork();
...
try {
//等待任務執(zhí)行完成
while (true) {
Runnable finisher;
synchronized (sLock) {
finisher = sFinishers.poll();
}
if (finisher == null) {
break;
}
finisher.run();
}
} finally {
sCanDelay = true;
}
}
}
在這個版本的實現(xiàn)中力细,會主動觸發(fā)processPendingWork取出寫任務列表中依次執(zhí)行睬澡,而不是只在在等待。
SharedPreferences的實現(xiàn)中眠蚂,除了線程調度做的改動外煞聪,android8.0還做了一個很重要的優(yōu)化:
我們知道在調用apply方法時,會將改動同步提交到內存中map中河狐,然后將寫入磁盤的任務加入的隊列中米绕,在工作線程中從隊列中取出寫入任務瑟捣,依次執(zhí)行寫入馋艺。注意栅干,不管是內存的寫入還是磁盤的寫入,對于一個xml格式的sp文件來說捐祠,都是全量寫入的碱鳞。
這里就存在優(yōu)化的空間,比如對于同一個sp文件踱蛀,連續(xù)調用n次apply,就會有n次寫入磁盤任務執(zhí)行窿给,實際上只需要最后執(zhí)行最后那次就可以了,最后那次提交對應內存的map是持有最新的數(shù)據(jù)率拒,所以就可以省掉前面n-1次的執(zhí)行崩泡,這個就是android 8.0中做的優(yōu)化,看下代碼是如何實現(xiàn)的:
SharedPreferencesImpl.writeToFile()方法:
// Only need to write if the disk state is older than this commit
if (mDiskStateGeneration < mcr.memoryStateGeneration) {
if (isFromSyncCommit) {
needsWrite = true;
} else {
synchronized (mLock) {
// No need to persist intermediate states. Just wait for the latest state to
// be persisted.
if (mCurrentMemoryStateGeneration == mcr.memoryStateGeneration) {
needsWrite = true;
}
}
}
}
android8.0中猬膨,增加了版本號控制的邏輯角撞,版本號數(shù)值都是要遞增的。mDiskStateGeneration表示當前磁盤最新的版本號勃痴, mcr.memoryStateGeneration是指本次內存提交的版本號谒所,很明顯只有滿足mDiskStateGeneration < mcr.memoryStateGeneration 這個條件才是有意義的提交,所以加了這個判斷沛申。
mCurrentMemoryStateGeneration 是指當前內存中最新的版本號劣领,調用commit或者apply時,這兩個方法都會調用commitToMemory()铁材,在這個方法里會將這個值遞增1
if (changesMade) {
mCurrentMemoryStateGeneration++;
}
當滿足mCurrentMemoryStateGeneration == mcr.memoryStateGeneration 這個條件時尖淘,表示是最新的提交任務。
所以當工作線程要依次執(zhí)行寫入任務列表中的任務時著觉,只會執(zhí)行最后的德澈、最新的寫入任務,這樣就通過少做不必要的事情來實現(xiàn)了優(yōu)化固惯。
Android8.0對Sp的優(yōu)化主要是有兩個方面:
改變原來被動等待線程調度執(zhí)行寫入的方式梆造,改為主動去調用,涉及主要方法是SharedPreferencesImpl.waitToFinish
增加版本號控制的邏輯葬毫,原來是所有的提交都會執(zhí)行寫入磁盤一遍镇辉,現(xiàn)在是只執(zhí)行最后、最新的提交寫入磁盤贴捡,涉及的主要方法是:SharedPreferencesImpl.writeToFile
在問題日志的平臺上忽肛,也可以看到,該問題在android8.0以上就沒有出現(xiàn)烂斋,都分布在android8.0以下屹逛。
復現(xiàn)方式:
在當前activity中础废,調用apply,寫入多次,大量的數(shù)據(jù)到sp中罕模,再進行頁面跳轉,觸發(fā)onPause评腺、onStop方法,則在一些低端機(如紅米note 1)很容易復現(xiàn)該問題淑掌,出現(xiàn)anr.
private void applyInfo(){
SharedPreferences applySp = mActivity.getSharedPreferences("apply",Context.MODE_PRIVATE);
SharedPreferences.Editor applyEdit = applySp.edit();
String content = "很長的文本";
for(int i = 1 ;i <= 1000; i++ ){
String strKey = "str"+i;
applyEdit.putString(strKey,content);
applyEdit.apply();
}
}
解決方法
問題直接來自于在系統(tǒng)在主線程的幾個生命周期中去等待任務列表執(zhí)行完成蒿讥,那么android為什么要這樣設計呢?android的應用是被托管運行的抛腕,應用在運行過程中有可能被系統(tǒng)回收芋绸、殺死、或者用戶主動殺死担敌,其實是在一個不確定的環(huán)境中運行摔敛,apply提交的任務,不是立即執(zhí)行的全封,而是會加入到列表中马昙,在未來的某一個時刻去執(zhí)行,那么就存在不確定性了售貌,有可能在執(zhí)行之前應用進程被殺死了给猾,那么寫入任務就失敗了。所以就在應用進程的存續(xù)時颂跨,抓緊找到一些時機去完成寫入磁盤的事情敢伸,也就是在上面的幾個生命周期方法中。
這個設計整體上是沒有大問題的恒削,但是QueuedWork.waitToFinish的方法在老版的實現(xiàn)上存在很大的缺陷池颈,它使得主線程只是在等待,而沒有做推動钓丰,這種情況下導致應用出現(xiàn)anr,進而被用戶或者系統(tǒng)殺死進程躯砰,這樣寫入任務還是不能執(zhí)行完成,還影響用戶體驗携丁,這個是得不償失的琢歇。8.0的版本才修復了這個缺陷。
在google的android issue平臺上梦鉴,也有類似的問題報告:
https://issuetracker.google.com/issues/62206685
老版本 的QueuedWork.waitToFinish方法實現(xiàn)有缺陷李茫,可以去規(guī)避這個方法來解決這個問題,就是去清除等待鎖的隊列肥橙,主線程在調用這個方法時魄宏,不必去等待〈娣ぃ可以只在Android8.0以下加入此處理宠互。
該解決方案參考自: https://mp.weixin.qq.com/s/IFgXvPdiEYDs5cDriApkxQ
代碼實現(xiàn)
ActivityThread 中有一個 Handler 變量味榛,我們通過 Hook 拿到此變量,給此 Handler 設置一個 callback予跌,Handler 的 dispatchMessage 中會先處理 callback搏色。
try {
Class<?> activityThreadClass = Class.forName("android.app.ActivityThread");
Method currentAtyThreadMethod = activityThreadClass.getDeclaredMethod("currentActivityThread");
Object activityThread = currentAtyThreadMethod.invoke(null);
Field mHField = activityThreadClass.getDeclaredField("mH");
mHField.setAccessible(true);
Handler handler = (Handler) mHField.get(activityThread);
Field mCallbackField = Handler.class.getDeclaredField("mCallback");
mCallbackField.setAccessible(true);
mCallbackField.set(handler,new SpCompatCallback());
Log.d(TAG,"hook success");
} catch (ClassNotFoundException e) {
e.printStackTrace();
} catch (NoSuchFieldException e) {
e.printStackTrace();
} catch (NoSuchMethodException e) {
e.printStackTrace();
} catch (IllegalAccessException e) {
e.printStackTrace();
} catch (InvocationTargetException e) {
e.printStackTrace();
} catch (Throwable e){
e.printStackTrace();
}
自定義callbak:SpCompatCallback,在這個方法中做清理等待鎖列表的操作:
public class SpCompatCallback implements Handler.Callback {
public SpCompatCallback(){
}
//handleServiceArgs
private static final int SERVICE_ARGS = 115;
//handleStopService
private static final int STOP_SERVICE = 116;
//handleSleeping
private static final int SLEEPING = 137;
//handleStopActivity
private static final int STOP_ACTIVITY_SHOW = 103;
//handleStopActivity
private static final int STOP_ACTIVITY_HIDE = 104;
//handlePauseActivity
private static final int PAUSE_ACTIVITY = 101;
//handlePauseActivity
private static final int PAUSE_ACTIVITY_FINISHING = 102;
@Override
public boolean handleMessage(Message msg) {
switch (msg.what){
case SERVICE_ARGS:
SpHelper.beforeSpBlock("SERVICE_ARGS");
break;
case STOP_SERVICE:
SpHelper.beforeSpBlock("STOP_SERVICE");
break;
case SLEEPING:
SpHelper.beforeSpBlock("SLEEPING");
break;
case STOP_ACTIVITY_SHOW:
SpHelper.beforeSpBlock("STOP_ACTIVITY_SHOW");
break;
case STOP_ACTIVITY_HIDE:
SpHelper.beforeSpBlock("STOP_ACTIVITY_HIDE");
break;
case PAUSE_ACTIVITY:
SpHelper.beforeSpBlock("PAUSE_ACTIVITY");
break;
case PAUSE_ACTIVITY_FINISHING:
SpHelper.beforeSpBlock("PAUSE_ACTIVITY_FINISHING");
break;
default:
break;
}
return false;
}
}
清理等待列表的操作:
public class SpHelper {
private static final String TAG = "SpHelper";
private static boolean init = false;
private static String CLASS_QUEUED_WORK = "android.app.QueuedWork";
private static String FIELD_PENDING_FINISHERS = "sPendingWorkFinishers";
private static ConcurrentLinkedQueue<Runnable> sPendingWorkFinishers = null;
public static void beforeSpBlock(String tag){
if(!init){
getPendingWorkFinishers();
init = true;
}
Log.d(TAG,"beforeSpBlock "+tag);
if(sPendingWorkFinishers != null){
sPendingWorkFinishers.clear();
}
}
private static void getPendingWorkFinishers() {
Log.d(TAG,"getPendingWorkFinishers");
try {
Class clazz = Class.forName(CLASS_QUEUED_WORK);
Field field = clazz.getDeclaredField(FIELD_PENDING_FINISHERS);
field.setAccessible(true);
sPendingWorkFinishers = (ConcurrentLinkedQueue<Runnable>) field.get(null);
Log.d(TAG,"getPendingWorkFinishers success");
} catch (ClassNotFoundException e) {
e.printStackTrace();
} catch (NoSuchFieldException e) {
e.printStackTrace();
} catch (IllegalAccessException e) {
e.printStackTrace();
} catch (Throwable e){
e.printStackTrace();
}
}
}
另外一種解決思路
濫用apply的情況下,會將任務堆積匕得,在后面造成anr;而在主線程調用commit,又會在提交時造成主線程的anr.那么可以將所有的sp提交都實現(xiàn)為子線程中調用commit,就避免了apply任務的堆積問題舰攒。
但這個方案帶來的副作用比清理等待鎖要更明顯:
1.系統(tǒng)apply是先同步更新緩存再異步寫文件嗜诀,調用方在同一線程內讀寫緩存是同步的,無需關心上下文數(shù)據(jù)讀寫同步問題
2.commit異步化之后直接在子線程中更新緩存再寫文件魂务,調用方需要關注上下文線程切換集币,異步有可能引發(fā)讀寫數(shù)據(jù)不一致問題
因此還是推薦用第一種方案
SP推薦實踐
1.在工作線程中寫入sp時考阱,直接調用commit就可以,不必調用apply,這種情況下鞠苟,commit的開銷更小
2.在主線程中寫入sp時乞榨,不要調用commit,要調用apply
3.sp對應的文件盡量不要太大当娱,按照模塊名稱去讀寫對應的sp文件吃既,而不是一個整個應用都讀寫一個sp文件
4.sp的適合讀寫輕量的、小的配置信息跨细,不適合保存大數(shù)據(jù)量的信息鹦倚,比如長串的json字符串。
- 當有連續(xù)的調用PutXxx方法操作時(特別是循環(huán)中)冀惭,當確認不需要立即讀取時震叙,最后一次調用commit或apply即可。
參考鏈接:
http://gityuan.com/2017/06/18/SharedPreferences/
https://mp.weixin.qq.com/s/IFgXvPdiEYDs5cDriApkxQ