SharedPreferences ANR问题分析和解决 & Android 8.0的优化

在日志后台上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的基本流程是:

  1. 首先调用commitToMemory将数据改动同步到内存中,也就是SharedPreferencesImpl的mMap(HashMap)
  2. 然后调用 QueuedWork.add(awaitCommit);将一个等待的任务加入到列表中,在Activity等的生命周期中,就是以这个为判断条件,等待写入任务执行完成的。
  3. 调用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:


image.png

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字符串。

  1. 当有连续的调用PutXxx方法操作时(特别是循环中),当确认不需要立即读取时,最后一次调用commit或apply即可。

参考链接:

http://gityuan.com/2017/06/18/SharedPreferences/
https://mp.weixin.qq.com/s/IFgXvPdiEYDs5cDriApkxQ

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,294评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,493评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,790评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,595评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,718评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,906评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,053评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,797评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,250评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,570评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,711评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,388评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,018评论 3 316
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,796评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,023评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,461评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,595评论 2 350