在日志后台上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