欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

Android内存优化(一)之Android常见的Java层内存泄露场景及合理的修复方案

程序员文章站 2022-04-18 22:58:41
...

首先解释下内存泄露:
内存泄漏(Memory Leak)是指程序中己动态分配的堆内存由于某种原因程序未释放或无法释放,造成系统内存的浪费,导致程序运行速度减慢甚至系统崩溃等严重后果。
内存泄露分为永久性泄露和临时性泄露.永久性泄露是指只要泄露出现,泄露的内存永远不会回收,此种情况一般问题比较严重,一旦发现,需快速解决.临时性泄露是指泄露场景出现后,在在未来的某段时间内回收掉内存,有些人会感觉这种情况不是泄露问题,但其实也是内存泄露的一种,临时性泄露会造成系统资源的浪费,对于手机性能,电量都是损害,很可能会出现页面的卡顿情况,在低端机上表现尤为明显,因此也应需要严肃对待~
从当前内存工具内存自动化监测工具报的内存泄露问题,内存泄露场景主要有以下几种,下面的分析会结合源码按照此顺利依次展开详细说明:

  • (1)AsyncTask使用异常,最为常见
  • (2)Handler, Runnable(Thread)生命周期异常, 常见
  • (3)HandlerThread使用异常,常见
  • (4)非静态内部类持有外部类的引用,常见
  • (5)匿名内存类持有外部类的引用,常见
  • (6)static变量持有activity,常见
  • (7)static view持有activity,常见
  • (8)资源未关闭
  • (9)注册的监听器未注销
  • (10)其他泄露场景,包含了最近出现的各种case集合,出现概率不高,不做统一分析
    其实AsyncTask泄露,Handler,Runnable(Thread)泄露,HandlerThread泄露基本都是由于非静态内部类持有外部类的引用和匿名内存类持有外部类的引用这两种方式泄露,只是这几种泄露出现的频率非常之高,因此决定把几个场景单独拎出来,着重说下,以及具体说下这几种泄露场景的修复方式~

(1) AsyncTask的泄露问题

AsyncTask泄露问题非常之多,也从源码运行的角度重点说下这个泄露问题,只要理解了源码,就会明了其实AsyncTask的泄露问题也是蛮严重的~

关键引用:

GC ROOT thread java.lang.Thread. (named ‘AsyncTask #17’)

原理:

在使用AsyncTask时,一般会继承AsyncTask并重写doInBackground方法,onPostExecute方法,在doInBackground方法中做耗时操作,在onPostExecute方法中更新UI,这是日常用法~
泄露的场景是,当Activity onDestroy方法回调后,AsyncTask的方法没有执行完成,或者是在doInBackground方法中,或者是在onPostExecute方法中,而AsyncTask持有Activity的引用(一般是非静态内部类持有外部类的引用和匿名内存类持有外部类的引用两种形式,具体可以看下面的解释),导致Activity无法及时回收,从而导致内存泄露~
看过AsyncTask的源码的同学应该会知道,AsyncTask的设计其实是对Handler+Thread的封装,相比于Handler的使用,AsyncTask让开发者用起来更加简单与方便,而带来的就是对其了解的不够透彻
AsyncTask的运行是通过调用execute方法,可能很多人会知道AsyncTask的运行背后是有一个线程池,猜想就是多个AsyncTask可以在多核处理器上同时被调度

187    private static final int CORE_POOL_SIZE = Math.max(2, Math.min(CPU_COUNT - 1, 4));
188    private static final int MAXIMUM_POOL_SIZE = CPU_COUNT * 2 + 1;
189    private static final int KEEP_ALIVE_SECONDS = 30;
190
191    private static final ThreadFactory sThreadFactory = new ThreadFactory() {
192        private final AtomicInteger mCount = new AtomicInteger(1);
193
194        public Thread newThread(Runnable r) {
195            return new Thread(r, "AsyncTask #" + mCount.getAndIncrement());
196        }
197    };
198
199    private static final BlockingQueue<Runnable> sPoolWorkQueue =
200            new LinkedBlockingQueue<Runnable>(128);

但其实已经不准确了,这是很老的观点了~现在的AsyncTask的执行默认是靠sDefaultExecutor的调度,

558    @MainThread
559    public final AsyncTask<Params, Progress, Result> execute(Params... params) {
560        return executeOnExecutor(sDefaultExecutor, params);
561    }

sDefaultExecutor在AsyncTask中是以常量形式存在,而且整个App的AsyncTask实例会公用一个sDefaultExecutor,在AsyncTask叫SERIAL_EXECUTOR,翻译过来是线性执行器的意思,其实就是化并行为串行的意思~

219    public static final Executor SERIAL_EXECUTOR = new SerialExecutor();
224    private static volatile Executor sDefaultExecutor = SERIAL_EXECUTOR;
235    private static class SerialExecutor implements Executor {
236        final ArrayDeque<Runnable> mTasks = new ArrayDeque<Runnable>();
237        Runnable mActive;
238
239        public synchronized void execute(final Runnable r) {
240            mTasks.offer(new Runnable() {
241                public void run() {
242                    try {
243                        r.run();
244                    } finally {
245                        scheduleNext();
246                    }
247                }
248            });
249            if (mActive == null) {
250                scheduleNext();
251            }
252        }
253
254        protected synchronized void scheduleNext() {
255            if ((mActive = mTasks.poll()) != null) {
256                THREAD_POOL_EXECUTOR.execute(mActive);
257            }
258        }
259    }

SerialExecutor使用双端队列ArrayDeque管理Runnable对象,如果一次性启动了多个任务,首先第一个Task执行execute方法时,调用ArrayDeque的offer将传入的Runnable对象添加至队列尾部,然后判断mActive是否为null,第一次运行时为null,会调用scheduleNext方法,在scheduleNext方法中赋值mActive,通过THREAD_POOL_EXECUTOR调度,之后再有新的任务被执行时,同样会调用offer方法将传入的Runnable对象添加至队列的尾部,但此时mActive不在为null,于是不会执行scheduleNext方法,也就是说不会得到立即执行,那什么时候会执行呢?看finally中,同样会调用scheduleNext方法,也就是说,当此Task执行完成后,会去执行下一个Task,SerialExecutor模仿的是单一线程池的效果,如果我们快速地启动了很多任务,同一时刻只会有一个线程正在执行,其余的均处于等待状态。
那么假设一下,如果用户开启某个页面,而此页面有Task在执行,再打开另外一个页面,这个页面还有Task需要执行,这个时候很可能会出现卡一个的情况,不是硬件配置差,而是软件质量差导致的~那么如何修复呢?

解决办法

1: cancel + isCancelled ,强烈推荐
但需解释下,cancel方法可能不会得到立即执行,在接口调用处也有如下说明:

/**
 * <p>Attempts to cancel execution of this task. This attempt will
 * fail if the task has already completed, already been cancelled,
 * or could not be cancelled for some other reason. If successful,
 * and this task has not started when <tt>cancel</tt> is called,
 * this task should never run. If the task has already started,
 * then the <tt>mayInterruptIfRunning</tt> parameter determines
 * whether the thread executing this task should be interrupted in
 * an attempt to stop the task.</p>
 * 
 * <p>Calling this method will result in {@link #onCancelled(Object)} being
 * invoked on the UI thread after {@link #doInBackground(Object[])}
 * returns. Calling this method guarantees that {@link #onPostExecute(Object)}
 * is never invoked. After invoking this method, you should check the
 * value returned by {@link #isCancelled()} periodically from
 * {@link #doInBackground(Object[])} to finish the task as early as
 * possible.</p>
 *  * @param mayInterruptIfRunning <tt>true</tt> if the thread executing this
 * task should be interrupted; otherwise, in-progress tasks are allowed
 * to complete.
 *  * @return <tt>false</tt> if the task could not be cancelled,
 * typically because it has already completed normally;
 * <tt>true</tt> otherwise
 *  * @see #isCancelled()
 * @see #onCancelled(Object)
 */
public final boolean cancel(boolean mayInterruptIfRunning) {
    mCancelled.set(true);
    return mFuture.cancel(mayInterruptIfRunning);
}

第一段的意思是如果任务没有运行且cancel方法被调用,那么任务会被立即取消且确保不会被执行,当任务已经启动了,mayInterruptIfRunning参数决定是否尝试去停止Task
第二段的意思是调用cancel方法能确保onPostdExecute方法不会被执行,执行了cancel方法,不会立即终止任务,会等doInBackground方法执行完成后返回,然后定期通过调用isCancelled方法检查task状态尽早的结束task~什么意思呢?AsyncTask不会立即结束一个正在运行的线程,调用cancel方法只是给AsyncTask设置了”cancelled”状态,并不是停止Task,那么有人说是不是由mayInterruptIfRunning参数来控制?其实mayInterruptIfRunning只是执行线程的interrupt方法,并不是真正的中断线程,而是通知线程应该中断了~
什么意思?具体来说,当一个线程调用interrupt方法,
如果线程处于被阻塞状态(例如处于sleep, wait, join 等状态),那么线程将立即退出被阻塞状态,并抛出一个InterruptedException异常。仅此而已。
如果线程处于正常活动状态,那么会将该线程的中断标志设置为 true,仅此而已。被设置中断标志的线程将继续正常运行,不受影响。
真正决定任务取消的是需要手动调用isCancelled方法check task状态,因此推荐的修复方案是在手动调用cancel方法的同时,能调用inCancelled方法检测task状态:

@Override
protected Integer doInBackground(Void... mgs) {
// Task被取消了,马上退出
if(isCancelled()) return null;
.......
// Task被取消了,马上退出
if(isCancelled()) return null;
}
...

2:建议在修复方案1的基础上将AsyncTask作为静态内部类存在(与Handler处理方式相似),避免内部类的this$0持有外部类的引用
但不推荐只修改AsyncTask为静态内部类的方案,虽然不是泄露了,但没有根本上解决问题~
3:如果AsyncTask中需要使用Context,建议使用weakreference
4:如果确实需要做相对耗时的操作,建议用service去做,而不要用AsyncTask,推荐

(2) Handler,Runnable(Thread)泄露问题

这个问题爆发的也是相当多,说下此问题

关键引用:

references android.os.MessageQueue.mMessages’
references android.os.Message.callback’

原理

作为Android的一个通信利器,消息机制永远都是不可忽略的,主要是靠Handler,Message,MessageQueue,Looper四个类来完成各自任务

  • Message:消息分为硬件产生的消息(如按钮、触摸)和软件生成的消息;

  • MessageQueue:消息队列的主要功能向消息池投递消息(MessageQueue.enqueueMessage)和取走消息池的消息(MessageQueue.next);

  • Handler:消息辅助类,主要功能向消息池发送各种消息事件(Handler.sendMessage)和处理相应消息事件(Handler.handleMessage);

  • Looper:不断循环执行(Looper.loop),按分发机制将消息分发给目标处理者。
    架构图(来源于gityuan的博客)如:

Handler泄露是怎么发生的呢?当我们使用Handler时,往往会在Activity创建非静态Handler实例,重写handleMessage方法,此时会隐式持有外部Activity引用(如果不理解隐式持有外部Activity引用,可以看(4)非静态内部类持有外部类的引用,里面会详细解释),而MessageQueue持有Message引用,Message持有Handler引用,也就是说说Message不被消费,就不会释放Activity~有很多业务组会postDelayed,在几分钟之后打点啊,统计啊或者做其他操作,都是不合理的~或者在Message中处理非常耗时的操作,最后造成消息堆积,无法得到及时处理,最后造成内存泄露~
Runnable(Thread)泄露相对更容易理解,主要是异步线程持有外部Activity 的引用,而回调Activity onDestroy方法时,线程没有执行完成,导致内存泄露~

解决办法

解决内存泄露就是剪断引用链的过程,从引用链来看,能动的就是Handler和Activity的引用关系,因此解决方案主要有:
1:在Activity的onDestroy执行时,Handler泄露可手动调用Handler的removeCallbacksAndMessages,清除异步消息,Runnable(Thread)泄露则可通过终止线程(控制逻辑需要自己写),切断引用链~推荐
2:将Handler,Runnable(Thread)定义为静态内部类,推荐
通过此方式,不会隐式持有外部Activity的引用
3:如果确实需要使用Activity做相关操作,建议使用弱引用,或者使用ApplicationContext,推荐
4:如果确实有耗时操作,建议使用jobschedule去做,推荐

(3) HandlerThread 泄露

关键引用:

GC ROOT android.os.HandlerThread.< Java Local>

原理

HandlerThread的封装其实也是为了解决线程通信,本质还是线程,只不多内部建立了Looper,从上面的Handler了解到如果一个线程处理消息,需要Handler,Message,MessageQueue,Looper四者完成各自任务,一般使用的时候会手动调用Looper.prepare(),Looper.loop()方法(主线程除外,主线程会自动创建Looper对象并开启消息循环),而使用HandlerThread时,相对简单一些,一般创建HandlerThread并开启,就可以进行线程通信了~
解决办法
1:在Activity的onDestroy方法中,手动调用HandlerThread的quit方法,强烈推荐
当我们调用HandlerThread的quit方法方法时,实际就是执行了MessageQueue中的removeAllMessagesLocked方法,把MessageQueue消息池中的所有消息全部清空,无论是延时消息(延迟消息是指通过sendMessageDelayed或通过postDelayed等方法发送的需要延迟执行的消息)还是非延迟消息。
在HandlerThread中还有一个方法:quitSafely,简单提一下,实际执行的是removeAllFutureMessageLocked方法,只会清空MessageQueue消息池中所有的延迟消息,并将所有非延迟消息派发出去让Handler处理,相比于quit,更安全一些,这个看业务组的需求吧,一般Activity都退出了,消息派不派发都没有实际意义了
2:将HandlerThread定义为静态内部类,推荐
3:使用ApplicationContext,推荐

(4) 非静态内部类持有外部类的引用

这是个很笼统的概念,引起此种泄露的原因可能有很多,希望能找到真正泄露的东西,而不是简单的将其改成static或者使用ApplicationContext

关键引用:

this$0

原理:

泄露出现的场景主要是非静态内部类隐式持有外部Activity的引用,而非静态内部类的生命周期超出了Activity的生命周期,在Activity执行onDestroy方法时,由于非静态内部类隐式持有外部Activity的引用,导致Activity无法GC,从而导致内存泄露.
在这里解释下关键引用this$0是个啥?this$0的意思就是所说的隐式持有外部Activity引用,内部类可以访问外部类的成员变量,靠的就是this$0,这个东西是编译器自动加上的,不需要手动定义,在反编译的smali文件中很容易看到~(当然如果有多层内部类的嵌套,会有this$1,this$2)

在AAAAAActivity.java文件中,SdkDialogFragment是AAAAAActivity的内部类,简单看下

class BBBBBFragment extends DialogFragment {

    private Dialog mDialog;

    public BBBBBFragment(Dialog dialog) {
        mDialog = dialog;
    }
}

反编译后,在AAAAAActivity$BBBBBFragment.smali文件中有如下片段

# instance fields
.field private mDialog:Landroid/app/Dialog;
.field final synthetic this$0:Lmiui/external/AAAAAActivity;

# direct methods
.method public constructor <init>(Lmiui/external/AAAAAActivity;Landroid/app/Dialog;)V
.locals 0
.param p1, "this$0" # Lmiui/external/AAAAAActivity;
.param p2, "dialog" # Landroid/app/Dialog;
.prologue
.line 107
iput-object p1, p0, Lmiui/external/AAAAAActivity$BBBBBFragment;->this$0:Lmiui/external/AAAAAActivity;
invoke-direct {p0}, Landroid/app/DialogFragment;-><init>()V
.line 108
iput-object p2, p0, Lmiui/external/AAAAAActivity$BBBBBFragment;->mDialog:Landroid/app/Dialog;
.line 107
return-void
.end method

解决办法

1:非静态内部类持有外部类的引用,主要是非静态内部类的生命周期超过了Activity的生命周期,但原因可能有很多种,或者是AsyncTask问题,或者是Runnable问题,或者是未unregister问题,或者是资源未关闭问题
建议找到泄露的真凶,从引用链着手,如果没有思路可以看看hprof做进一步的分析~强烈推荐~
2:如果想快速修复,可以有几个办法,如非静态内部类改为静态内部类,如使用Application Context,但都不是很推荐~

(5) 匿名内存类持有外部类的引用

跟(4)很相似,也是个很笼统的概念,引起此种泄露的原因可能有很多,希望能找到真正泄露的东西,而不是简单的将其改成static或者使用ApplicationContext

关键引用:

this$0;anonymous

原理:

泄露出现的场景主要是匿名内部类隐式持有外部Activity的引用,而匿名内部类的生命周期超出了Activity的生命周期,在Activity执行onDestroy方法时,由于匿名内部类隐式持有外部Activity的引用,导致Activity无法GC,从而导致内存泄露.与(4)基本是一个道理,与(4)的差别,在反编译的时候经常会看到xxxxx1.class,xxxxxx2.class,这些就是匿名内部类,经常的书写格式一般是new xxxxxx() { 类的成员变量,成员方法 }.xxxxx();
其实我们经常用,

leak_single.setOnClickListener(new View.OnClickListener() {
    @Override
 public void onClick(View view) {
        Intent intent = new Intent(MainActivity.this,LeakSingleTestActivity.class);
        startActivity(intent);
    }
});

或者在创建Runnable,创建Handler,创建AsyncTask时,都有可能会使用匿名内部类,但这个时候一定要小心~,很有可能会出现内存泄露的问题

解决办法:

不要贪图用匿名内部类的代码简洁而忽略了内存泄露问题,对于有泄露风险的匿名内部类,建议都要改成内部类的形式~强烈推荐,然后再找到真正泄露的点,这就回到(4)的解决方案

(6) static变量持有activity

此种泄露相对简单好解一些,从引用链上基本就可以判断问题处在什么地方

关键引用:

GC ROOT static 加上应用的包名
也有不是App自身的问题,比如系统的问题或者第三方sdk的问题

原理:

static成员变量的使用不恰当,在某个时机,将activity传入当做context,从而导致Activity无法正常被回收~

解决办法:

1:找到static成员变量泄露的Activity的时机,并将其引用链剪断即可
2:如果可以,将static变量回复为非static变量,使其可以正常GC
3:如果确实需要context,且需要保持static,则可使用Application Context

(7) static view

此种问题比较好确认,GC ROOT是包名相关的View名称

关键引用:

GC ROOT static 包名**[View名称]

原理:

当某个View初始化时耗费大量资源,而且要求Activity生命周期内保持不变,这个时候很多业务组可能会吧view变成static,加载到视图树上(View Hierachy),像这样,当Activity被销毁时,应当释放资源。但很可能会带来泄露问题,View是跟Context紧密关联,使用不当就会出现泄露问题,需要特别注意.
可能有的朋友说,我在使用View的时候没操作Context?怎么会有Activity的引用呢?
其实View的代码中是默认有Context

3988    public View(Context context) {
3989        mContext = context;

Context是什么时候给到View的呢?View创建的时候,new View,有的时候是我们写代码时自己new,更多的时候是setContentView时将Activity作为context传给View

@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_main);

解决办法:

1:通过设计改变static为普通变量,不要在Android中使用static修饰View,完全避免此种可能,推荐
2:在onDestroy时将static view 置为null

(8)资源未关闭

对于使用了BraodcastReceiver,ContentObserver,File,Cursor,Stream,Bitmap等资源的代码,应该在Activity销毁时及时关闭或者注销,否则这些资源将不会被回收,造成内存泄漏。

(9)注册的监听器未注销

在Android程序里面存在很多需要register与unregister的监听器,我们需要确保及时unregister监听器。

(10)其他泄露场景

1> 集合中的内存泄漏
我们通常把一些对象的引用加入到了集合容器(比如ArrayList)中,当我们不需要该对象时,
并没有把它的引用从集合中清理掉,这样这个集合就会越来越大。如果这个集合是static的话,那情况就更严重了。
所以要在退出程序之前,将集合里的东西clear,然后置为null,再退出程序。
2> AccountManager使用不当导致的泄露
关键引用:GC ROOT static com.xiaomi.accounts.AccountManager.sThis
references com.xiaomi.accounts.AccountManagerAmsTaskResponse
addAccount的时候activity参数传null, 这样AmsTask不持有activity的引用, 避免泄露的产生.
但是如果不传activity, 会导致无法启动账号的登录界面, 所以activity还是必要的,
解决方案就是在addAccount参数的回调AccountManagerCallback中手动获取intent, AccountManagerCallback持有一个activity的弱引用, 启动activity时是判断持有的activity是否存在
3> EventBus使用不当导致泄露
GC ROOT static org.greenrobot.eventbus.EventBus.b\
references org.greenrobot.eventbus.EventBus.f
4> InputMethodManager mCurRootView导致的泄露
关键引用:references android.view.inputmethod.InputMethodManager.mCurRootView
5> 疑似系统内存泄露
GC ROOT static android.app.ActivityThread.sCurrentActivityThread
6> 疑似webview泄露
7>NetWorkDispatcher

总结:

内存泄露问题的修复方案可能会有很多种,而且也会有快速修复方法,但希望各位能把泄露的root cause找到,简单的修复方案可能只是将问题掩盖了,检测工具检测不出来,但没有实质性解决泄露问题~
希望各位在写代码的时候,能在脑海中模拟出手机运行这段代码的场景,从开始到消亡,做好控制~一段好的代码,高质量的代码,就跟艺术品一样,需要各位花些心思,这样才能真正解决手机的内存问题或其他问题,一点一点让手机更流畅~
附常见的修复方案:
1:使用Application的Context,替换Activity的context
2:使用弱引用,GC会来决定引用的对象何时回收并将对象从内存中移除
3:手动置空,解除引用关系
4:代码控制异步线程的生命周期,及时cancel
5:将内部类设置为static,因为非静态内部类会隐式持有外部类实例的引用
6:注册和取消注册成对出现,在对象合适的生命周期进行监听的注销
7:资源性对象(比如Cursor、File等)往往都做了一些缓冲,在不使用时应该及时关闭