在日志后臺上ANR的Top1問題,SharedPreferences相關的anr問題,我們經常會遇到。
主要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的系統會在Activity的onStop,onPause等生命周期中,調用QueuedWork.waitToFinish,等待落盤的任務隊列執行完成,如果任務隊列中的任務很多,或者待寫入的數據量很大時(sp文件是全量讀寫的),在一些io性能差的中低端機型上就會很容易出現anr.
SharedPreferences的源碼流程,可以參考鏈接:http://gityuan.com/2017/06/18/SharedPreferences/
下面主要分析apply方法的流程:
final class SharedPreferencesImpl implements SharedPreferences {
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);
// 通知回調
notifyListeners(mcr);
}
apply的基本流程是:
- 首先調用commitToMemory將數據改動同步到內存中,也就是SharedPreferencesImpl的mMap(HashMap)
- 然后調用 QueuedWork.add(awaitCommit);將一個等待的任務加入到列表中,在Activity等的生命周期中,就是以這個為判斷條件,等待寫入任務執行完成的。
- 調用enqueueDiskWrite方法的實現,將寫入任務加入到隊列中,寫入磁盤的操作會在子線程中執行。
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;
}
}
//將寫入磁盤的任務加入到單線程的線程池中(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執行
handler.sendEmptyMessageDelayed(QueuedWorkHandler.MSG_RUN, DELAY);
} else {
handler.sendEmptyMessage(QueuedWorkHandler.MSG_RUN);
}
}
}
//執行寫入磁盤任務
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);
}
// 將任務從鏈表中依次取出執行
if (work.size() > 0) {
for (Runnable w : work) {
w.run();
}
}
}
}
調用QueuedWork.waitToFinish()方法的代碼:
ActivityThread.java:
Activity 的 onStop,以及 Service 的 onStop 和 onStartCommand 都是通過 ActivityThread 觸發的。在一些組件的生命周期回調中,比如Service.onStartCommand,Service.onDestroy,Activity.onPause,Activity.onStop時,會調用QueuedWork.waitToFinish();去等待所有寫入任務的執行完成。
在android 8.0之前,這個方法的實現:
public static void waitToFinish() {
Runnable toFinish;
//等待所有的任務執行完成
while ((toFinish = sPendingWorkFinishers.poll()) != null) {
toFinish.run();
}
}
sPendingWorkFinishers并不是寫入任務的列表,而是等待狀態的列表,這個方法的作用就是如名字所代表的,就是在等待完成,阻塞主線程,干等著。
這里的toFinish.run方法,其實就只是執行一行代碼:mcr.writtenToDiskLatch.await(); 在等待寫入完成.
android 8.0 之前的實現QueuedWork.waitToFinish是有缺陷的。在多個生命周期方法中,在主線程等待任務隊列去執行完畢,而由于cpu調度的關系任務隊列所在的線程并不一定是處于執行狀態的,而且當apply提交的任務比較多時,等待全部任務執行完成,會消耗不少時間,這就有可能出現anr.
android 8.0的優化
而android 8.0以后,這個方法的實現做了很大的改變;
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;
}
...
// 觸發依次調用所有的寫入任務
processPendingWork();
...
try {
//等待任務執行完成
while (true) {
Runnable finisher;
synchronized (sLock) {
finisher = sFinishers.poll();
}
if (finisher == null) {
break;
}
finisher.run();
}
} finally {
sCanDelay = true;
}
}
}
在這個版本的實現中,會主動觸發processPendingWork取出寫任務列表中依次執行,而不是只在在等待。
SharedPreferences的實現中,除了線程調度做的改動外,android8.0還做了一個很重要的優化:
我們知道在調用apply方法時,會將改動同步提交到內存中map中,然后將寫入磁盤的任務加入的隊列中,在工作線程中從隊列中取出寫入任務,依次執行寫入。注意,不管是內存的寫入還是磁盤的寫入,對于一個xml格式的sp文件來說,都是全量寫入的。
這里就存在優化的空間,比如對于同一個sp文件,連續調用n次apply,就會有n次寫入磁盤任務執行,實際上只需要最后執行最后那次就可以了,最后那次提交對應內存的map是持有最新的數據,所以就可以省掉前面n-1次的執行,這個就是android 8.0中做的優化,看下代碼是如何實現的:
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中,增加了版本號控制的邏輯,版本號數值都是要遞增的。mDiskStateGeneration表示當前磁盤最新的版本號, mcr.memoryStateGeneration是指本次內存提交的版本號,很明顯只有滿足mDiskStateGeneration < mcr.memoryStateGeneration 這個條件才是有意義的提交,所以加了這個判斷。
mCurrentMemoryStateGeneration 是指當前內存中最新的版本號,調用commit或者apply時,這兩個方法都會調用commitToMemory(),在這個方法里會將這個值遞增1
if (changesMade) {
mCurrentMemoryStateGeneration++;
}
當滿足mCurrentMemoryStateGeneration == mcr.memoryStateGeneration 這個條件時,表示是最新的提交任務。
所以當工作線程要依次執行寫入任務列表中的任務時,只會執行最后的、最新的寫入任務,這樣就通過少做不必要的事情來實現了優化。
Android8.0對Sp的優化主要是有兩個方面:
改變原來被動等待線程調度執行寫入的方式,改為主動去調用,涉及主要方法是SharedPreferencesImpl.waitToFinish
增加版本號控制的邏輯,原來是所有的提交都會執行寫入磁盤一遍,現在是只執行最后、最新的提交寫入磁盤,涉及的主要方法是:SharedPreferencesImpl.writeToFile
在問題日志的平臺上,也可以看到,該問題在android8.0以上就沒有出現,都分布在android8.0以下。
復現方式:
在當前activity中,調用apply,寫入多次,大量的數據到sp中,再進行頁面跳轉,觸發onPause、onStop方法,則在一些低端機(如紅米note 1)很容易復現該問題,出現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();
}
}
解決方法
問題直接來自于在系統在主線程的幾個生命周期中去等待任務列表執行完成,那么android為什么要這樣設計呢?android的應用是被托管運行的,應用在運行過程中有可能被系統回收、殺死、或者用戶主動殺死,其實是在一個不確定的環境中運行,apply提交的任務,不是立即執行的,而是會加入到列表中,在未來的某一個時刻去執行,那么就存在不確定性了,有可能在執行之前應用進程被殺死了,那么寫入任務就失敗了。所以就在應用進程的存續時,抓緊找到一些時機去完成寫入磁盤的事情,也就是在上面的幾個生命周期方法中。
這個設計整體上是沒有大問題的,但是QueuedWork.waitToFinish的方法在老版的實現上存在很大的缺陷,它使得主線程只是在等待,而沒有做推動,這種情況下導致應用出現anr,進而被用戶或者系統殺死進程,這樣寫入任務還是不能執行完成,還影響用戶體驗,這個是得不償失的。8.0的版本才修復了這個缺陷。
在google的android issue平臺上,也有類似的問題報告:
https://issuetracker.google.com/issues/62206685
老版本 的QueuedWork.waitToFinish方法實現有缺陷,可以去規避這個方法來解決這個問題,就是去清除等待鎖的隊列,主線程在調用這個方法時,不必去等待。可以只在Android8.0以下加入此處理。
該解決方案參考自: https://mp.weixin.qq.com/s/IFgXvPdiEYDs5cDriApkxQ
代碼實現
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提交都實現為子線程中調用commit,就避免了apply任務的堆積問題。
但這個方案帶來的副作用比清理等待鎖要更明顯:
1.系統apply是先同步更新緩存再異步寫文件,調用方在同一線程內讀寫緩存是同步的,無需關心上下文數據讀寫同步問題
2.commit異步化之后直接在子線程中更新緩存再寫文件,調用方需要關注上下文線程切換,異步有可能引發讀寫數據不一致問題
因此還是推薦用第一種方案
SP推薦實踐
1.在工作線程中寫入sp時,直接調用commit就可以,不必調用apply,這種情況下,commit的開銷更小
2.在主線程中寫入sp時,不要調用commit,要調用apply
3.sp對應的文件盡量不要太大,按照模塊名稱去讀寫對應的sp文件,而不是一個整個應用都讀寫一個sp文件
4.sp的適合讀寫輕量的、小的配置信息,不適合保存大數據量的信息,比如長串的json字符串。
- 當有連續的調用PutXxx方法操作時(特別是循環中),當確認不需要立即讀取時,最后一次調用commit或apply即可。
參考鏈接:
http://gityuan.com/2017/06/18/SharedPreferences/
https://mp.weixin.qq.com/s/IFgXvPdiEYDs5cDriApkxQ