Android - 常见的内存泄漏场景及解决方案_ListerCi的博客-程序员秘密

技术标签: Android  

我的CSDN: ListerCi
我的简书: 东方未曦

一、引言

一般情况下Android的内存泄漏是因为,存在引用指向一个本该被回收的对象,例如已经执行onDestroy()的Activity。在这种情况下,由于Activity内某些对象的生命周期比Activity要长,在Activity理论上被销毁时,该对象依旧存在并持有Activity的引用,因此内存回收机制(GC)无法释放Activity,最终导致内存泄漏。

为了发现和修复APP中存在的内存泄漏,开发人员会在APP上安装内存检测工具(如leakcanary),当出现内存泄漏时,该工具会提供一个报告,里面包含了一条引用链,指明可能造成内存泄漏的引用。开发人员需要在合适的地方切断引用链,以便GC释放掉没有被引用的对象。

有些内存泄漏的修复很简单,将非静态内部类内部类改为静态内部类或者将Context改为ApplicationContext后检测工具就检测不出内存泄漏了,但是这到底是为什么呢?而且就算检测工具检测不出内存泄漏,就真的万无一失了吗?

带着这些问题,我们来分析一下Android常见的内存泄漏场景以及解决方案。

二、Java内存管理及垃圾回收机制

在了解Android的内存泄漏之前,我们需要先了解Java的内存管理以及垃圾回收机制。

2.1 内存管理

Java的内存分配区域主要分为以下几个部分。

1. 静态变量区

用于存储被static修饰的静态变量,这块区域在程序开始运行时就已经分配完毕,并且存在于程序的整个运行过程。

2. 栈

主要用于分配局部变量,包括基本类型的变量和对象的引用变量,当局部变量的作用域结束之后,Java会自动释放掉该变量占用的内存空间。

3. 堆

堆是动态内存区域,程序运行期间新建的对象实例和数组都存储在堆中,垃圾回收机制(GC)管理的就是这块内存。为了及时地将不被使用的对象释放掉,GC需要监控每一个对象的状态,当一个对象不再被引用时,GC就会释放该对象。

4. 常量池

常量池中的内容在编译时就已经确定,主要包含代码中的基本类型和对象类型的常量值。
例如,String就是对象类型,如果在编译时确定了String的值(String s = "test"),那么它的值就存储在常量池中,而它的引用存储在栈中。如果String的值是在程序运行时确定的(String s = new String("...")),那么它的值就存储在堆中。

假设当前有一个实例A存储在堆中,我们定义了一个引用a指向实例A。此时引用a其实是保存在栈中的,它的值为实例A在堆内存中的首地址,此时程序就可以通过a读写A的值。

2.2 垃圾回收机制

上面提到,当一个对象不再被引用时,GC就应该将其回收。确实有一种引用计数法来判断一个对象是否需要被释放,当该对象的引用计数为0时代表它需要被回收。但是如果存在两个对象,没有别的引用指向它们,但是它们互相引用,此时它们的引用计数都不为0,导致无法释放,容易造成内存泄漏。

目前主流的的方法是通过可达性分析来判断一个对象是否需要被释放。该算法的基本思路就是通过一些被称为引用链(GC Roots)的对象作为起点,从这些节点开始向下搜索,搜索走过的路径被称为(Reference Chain),当一个对象到GC Roots没有任何引用链相连时(即从GC Roots节点到该节点不可达),则证明该对象是不可用的。

在Java中,可作为GC Root的对象包括以下几种:

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象
  • 方法区中类静态属性引用的对象
  • 方法区中常量引用的对象
  • 本地方法栈中JNI(即一般说的Native方法)引用的对象

三、Android常见内存泄漏场景

3.1 内部类持有外部类引用造成的内存泄漏

1. 非静态内部类

我们知道非静态内部类可以访问外部类的变量,它通过变量this$0隐式地持有外部类的引用,这个变量是编译器为非静态内部类添加的,如果内部类的生命周期超过外部类,则会引发内存泄漏。

造成这种情况的具体原因很多,可能是多线程或者监听器未反注册。如果需要快速修复,可以将内部类改为static,但是static变量的生命周期与App相同,该变量不会被回收。因此最好是在出现内存泄漏时,通过引用链寻找可以切断的地方。后文的监听器和Handler都属于这种情况。

2. 匿名内部类

匿名内部类引发内存泄漏的原因与非静态内部类相似,匿名内部类通过xxx$1.class持有了外部类的引用,如果匿名内部类的生命周期超过外部类,在外部类例如Activity销毁时,内部类依旧持有外部类的引用,就会引发内存泄漏。

如果像下面这样直接在匿名内部类中使用Runnable或者Handler时就非常容易引起内存泄漏。由于Runnable执行的时间很可能超过Activity,Activity在onDestroy()后匿名内部类依旧存在,最终导致Activity泄露。

button.setOnClickListener(new View.OnClickListener() {
    
    @override
    public void onClick(View view) {
    
        new Thread(new Runnable() {
    
            @Override
            public void run() {
    
                // ......
            }
       }).start();
    }
});

匿名内部类引发的内存泄漏不易修改,因为没有办法获得该对象的引用,也就无法在Activity被销毁时通过引用清除这些资源。因此对于可能引发内存泄漏的匿名内部类来说,应该改为内部类实现。

3.2 多线程造成的内存泄漏

1. Runnable(Thread)

当异步线程持有外部Activity的引用时,如果Activity销毁时线程还没有执行完,就会导致内存泄漏。
解决办法很简单,只需要在Activity销毁之前终止线程即可。

2. AsyncTask

AsyncTaskHandler+Thread的封装,用于完成异步任务。我们在使用时,一般继承AsyncTask并重写doInBackground()方法和onPostExecute()方法,doInBackground()方法进行耗时操作,onPostExecute()方法在主线程更新UI。
其常见的内存泄漏原因与Runnable类似,也是由于AsyncTask未执行完时Activity被销毁,而AsyncTask又持有Activity的引用,导致Activity无法释放,引起内存泄漏。

对于AsyncTask造成的内存泄漏,推荐使用cancel+isCancelled来解决。
如果一个任务没有被执行并且cancel方法被调用,那么任务会立即取消且不会被执行。对于已经在执行的任务,cancel方法只能保证其onPostExecute()不会被执行,也就是说,即使调用了cancel方法,任务也不会立即停止,需要等待doInBackground()方法完成。cancel方法不会终止一个正在运行的线程,只是给它设置cancelled状态,通知该线程应该中断了。
因此给任务调用cancel方法后还要检查当前task的状态,保证其及时退出。

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

虽然有这样的解决办法,但是对于异步操作,这里更推荐RxJava。

3.3 视图造成的内存泄漏

1. WebView

在进行混合开发时,经常需要在Activity中嵌入WebView来访问前端页面,此时需要注意WebView的创建和回收问题。
在Activity中使用WebView时,推荐使用动态创建和回收的方式进行管理。在布局文件中定义一个ViewGroup,然后动态地将WebView添加到ViewGroup中。

@override
protected void onCreate(Bundle savedInstanceState) {
    
    mWebView = new WebView(this);
    // WebView settings
    mWebView.setWebViewClient(...);
    mWebView.setWebChromeClient(...);
    // 将 WebView 添加到布局中的 ViewGroup 中
    FrameLayout.LayoutParams layoutParams = new FrameLayout.LayoutParams(
                ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT);
    mWebViewLayout.addView(mWebView, layoutParams);
}

之后在Activity的onDestroy()方法中回收WebView相关资源。由于WebView内部存在component callbacks,该回调在onAttachedToWindow()方法中进行注册,并在onDetachedFromWindow()方法中进行反注册。为了顺利反注册该回调,需要在WebView执行destroy()之前将其从布局上移除。(具体见下方的参考2)

@override
protected void onDestroy() {
    
    // 从父容器移除 WebView 后再将其销毁
    if (mWebView != null) {
    
        mWebView.loadDataWithBaseURL(
                null, "", "text/html", "utf-8", "");
        mWebView.clearHistory();
        mWebView.setWebViewClient(null);
        mWebView.setWebChromeClient(null);       
        mWebViewLayout.removeView(mWebView);
        mWebView.destroy();
        mWebView = null;
    }
}
2. static view

如果某个View在初始化时需要消耗大量资源,并且要求其在Activity生命周期中不变,就可能将其修饰为static加载到视图树上。由于View在新建时就持有Activity的引用,因此Activity销毁时需要释放资源。

public View(Context context) {
    
    mContext = context; // 此时View已经持有Activity的引用
    // ......
}

面对这种情况,最好是将View设置为普通变量,可以避免这类内存泄漏。

3.4 广播、监听器等未反注册

这一类的内存泄漏主要与观察者模式有关,一般情况下是有多个观察者(Observer)对同一个被观察者(Observable)进行监听。
如果有一个Manager对观察者进行统一管理的话,那么观察者的对被观察者监听的注册反注册一定是成对出现的,不然就会出现内存泄漏。在监听器一节中会详细描述这种场景。

1. 广播

广播的主要流程如下:

1:广播接收者BroadcastReceiver通过Binder机制向AMS(Activity Manager Service)进行注册
2:广播发送者通过binder机制向AMS发送广播
3:AMS查找符合相应条件(IntentFilter/Permission等)的BroadcastReceiver,将广播发送到BroadcastReceiver(一般情况下是Activity)相应的消息循环队列中
4:消息循环执行拿到此广播,回调BroadcastReceiver中的onReceive()方法

根据上述流程,Activity在销毁之前应及时反注册,否则广播管理者会一直保留当前Activity的引用,而广播管理者的生命周期是整个Application,最终会导致内存泄漏。

2. 监听器

上面提过,如果存在一个统一的Manager对监听器进行管理的话,注册和反注册一定要成对出现,否则很容易出现内存泄漏的情况。下面来分析该场景。

假设当前存在一个监听器如下所示。

public interface MyListener {
    
    void run(...);
}

定义一个ListenerManager来对所有的监听器进行管理。

public class ListenerManager {
    
    // 单例模式
    private static final INSTANCE = new ListenerManager();
    // 存储所有的监听器
    private List<MyListener> mListeners = new CopyOnWriteArrayList<>();

    public static ListenerManager getInstance() {
    
        return INSTANCE;
    }

    // 注册监听器时将该监听器添加到列表中
    public void registerListener(MyListener listener) {
    
        if (listener == null) return;
        if (mListeners.contains(listener)) return;
        mListeners.add(listener);
    }

    // 反注册时将该监听器从列表中移除
    public void unRegisterListener(MyListener listener) {
    
        if (listener == null) return false;
        return mListeners.remove(listener);
    }

    public void run() {
    
        for (MyListener listener : mListeners) {
    
            listener.run(...);
        }
    }
}

在使用到该监听的Activity中添加如下代码。

public class TestActivity {
    
    private TestListener mTestListener;

    @override
    protected void onCreate(...) {
    
        // ...
        mTestListener = new TestListener();
        ListenerManager.getInstance().registerListener(mTestListener);
    }

    @override
    protected void onDestroy() {
    
        // ...
        ListenerManager.getInstance().unRegisterListener(mTestListener);
    }

    private class TestListener implements MyListener {
    
        @override
        void run(...) {
    
            // ...
        }
    }
}

可以看到,在Activity中使用了内部类的形式定义了监听器,随后在onCreate()方法中注册,并在onDestroy()中反注册。那么如果没有反注册会出现什么情况呢?

首先ListenerManager的生命周期比Activity要长,如果Activity未进行反注册,ListenerManager中的mListeners会一直持有TestListener对象的引用,又因为TestListener是内部类,它持有Activity的引用。
最终形成了ListenerManager->mListeners->mTestListener->Activity的引用链,导致Activity无法被释放,形成了内存泄漏。

3.5 其余情况

1. Handler

Handler作为Android的一种消息机制,通过HandlerMessageMessageQueueLooper四个类协调合作完成通信任务。
其中,Message是消息实体,包含硬件消息和软件消息;
MessageQueue是消息队列,主要的功能是向消息池投递消息和取走消息池的消息;
Handler是辅助类,主要功能是向消息池发送消息事件(Handler.sendMessage())和处理相应消息事件(Handler.handleMessage());
Looper是循环机制,不断循环执行将消息分发给目标处理者。

如果我们在Activity中创建非静态的Handler实例并重写handleMessage()方法,此时Handler隐式持有外部Activity的引用,而MessageQueue会持有Message引用,Message又持有Handler引用(Message需要知道自己会被发往哪个Handler)。
也就是说,如果Message不被消费,Activity就不会被释放,如果使用postDelayed,在信息被消费前关闭了Activity,就会造成内存泄漏。

面对这种情况,最好是在Activity执行onDestroy()时调用HandlerremoveCallbacksAndMessages清除所有信息;也可以选择将Handler定义为静态内部类,这样就不会持有外部Activity的引用了。

2. 资源未关闭

资源性对象(比如Cursor、File等)往往都做了一些缓冲,应该在Activity销毁时及时关闭或者注销,否则这些资源将不会被回收,造成内存泄漏。

3. 工具类生命周期问题

有时代码中会新建工具类用于完成一系列相同的操作,某些工具类在新建时需要传入Context,如下所示。

public class Utils {
    
    private Context mContext;
    public Utils(Context context) {
    
        mContext = context;
    }
}

有时候工具类对象是在Activity内部新建的,它的生命周期与Activity的生命周期相同,那么即使它持有context也不会引发内存泄漏问题。但是如果工具类的生命周期比Activity长(如单例),那么传入了哪个Activity的context,哪个Activity就会泄露。
正确的做法是使用ApplicationContext代替Context,使得工具类的生命周期与APP相同,就不会引发Activity的内存泄露。

不过如果该工具类只在某几个场景下用到呢?如果它的生命周期还是整个APP,虽然没有内存泄漏,但也是浪费了一部分内存。这时候就需要开发人员对工具类的生命周期进行管理,可以选择在合适的时候清除该工具类对象。

四、参考

  1. 公司大佬笔记
  2. WebView内存泄漏–解决方法小结
版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
本文链接:https://blog.csdn.net/ListerCi/article/details/100067943

智能推荐

PYQT5从数据库读取数据构建QTreeWidget节点_guardianff的博客-程序员秘密

PYQT5从数据库读取数据构建QTreeWidget节点 def sql_select(self): self.itemidlist = [] self.nolist = [] sql = 'select * from [ICItem]' # 实例化数据库连接查询类 ICItem_date = Select_Table() # 使用select_access方法获取数据库表中所有数据的二维列表

springboot+websocket环境下session会话信息在异步编程中如何同步shrio登录和会话信息_springboot websocket session_黄锦平的博客-程序员秘密

看到许多博客里都是通过前端建立websocket请求时将用户信息传递给后端并绑定到websocket的实例中,这样也能满足需求,但是针对粒度要达到会话级别的时候,同时系统采用异步编程时,这个做法就无法满足业务场景了,同时这对前端还有侵入性。另大多数博客都没讲清原理,此处为加深自己记忆,整理一份文档,助和我曾经一样的小白们快速理解。场景说明:1、如果系统收到请求后将数据交给线程池异步执行,并...

‘QMainWindow‘ object has no attribute ‘accept‘_陈 zv的博客-程序员秘密

根据一些教程使用python和pyqt5编写界面时候出现警告‘QMainWindow’ object has no attribute ‘accept’原教程代码为:import sysfrom PyQt5.QtWidgets import QApplication, QDialog, QMainWindowimport Ui_mainwinif __name__ == '__main__': app = QApplication(sys.argv) MainWindow =

关于运动控制系统软件架构设计_c#运动控制框架设计_dch4890164的博客-程序员秘密

运动控制系统软件架构设计的难点:一.客户的需求总是根据实际的需要不断的增加   由于工业生产的需要,需求总是不断的被提出,而且国内运动控制系统软件起步较晚,还处在不断的摸索当中,更多的时候还是仿照国外运动控制软件进行开发,由于软件开发在整个运动控制系统开发的被动性(相对于机械系统和电气系统,软件系统是最容易被修改的而且其成本也是最廉价的),基于以上因素在软件系统架构设计的时候必须考虑框

电脑如何开启卓越性能模式_w7卓越性能_わ雾锁晴空ぬ的博客-程序员秘密

电脑如何开启卓越性能模式电脑如何开启卓越性能模式我想大家平时玩游戏的时候都会有疑惑,为什么我买的电脑也不差,为什么会经常卡顿呢?有很多时候是因为没有开启卓越性能的原因,下面就给大家讲一下如何开启卓越性能吧。首先按Windows键或者点击桌面角落的开始然后搜索并打开控制面板打开电源或电池相关选项,我的是电源,其他的也有可能不一样,具体看自己电脑选择高性能再次点开开始菜单,搜索并打开开Windows PowerShell(管理员):输入powercfg -duplicateschem

OpenWRT LEDE固件安装_Phillweston的博客-程序员秘密

OpenWRT LEDE固件安装链接如下:【2020】最新编译OpenWrt X86-64纯净版软路由固件镜像下载 LEDE精简版-多功能版-旁路由固件不推荐使用koolshare版本的固件,bug很多,经常断流注意:一定看仔细到底烧录的是哪个盘,千万不要不看提示操作!烧录的时候,如果是硬盘里面烧录,推荐使用physdiskwrite,用etcher烧录会失败命令添加参数:physdiskwrite -u ,目的是取消磁盘2G大小限制被烧录的磁盘的所有分区都要删除,否则烧录仍然会失败

随便推点

关于苹果开发者证书的续费问题改动2021_苹果证书续费_豆趣编程的博客-程序员秘密

最近发现苹果开发者证书还有几天就到期了,但是登陆pc网站确找不到续费入口,而且也不提示续费,还不给发续费邮件网页注册的账号,只能通过开发者网站手动续费,信用卡支付,入口在到期前30天开放,过期后可以在任何时间随时续费。 App注册的账号,只能通过内购自动订阅续费,自动订阅完成后你可以随时取消订阅,不影响账号权益;如果取消订阅后想重新订阅,可以在会员到期后的一年内随时订阅。...

Xshell连接有跳板机(堡垒机)的服务器_牛奔的博客-程序员秘密

一、Xshell直连有跳板机的服务器跳板机IP:112.74.163.161 端口号: 22服务器IP:47.244.217.66 端口号:22 1. 新建跳板机会话点击连接,主机和端口号输入跳板机端口号。在用户身份验证,输入跳板机用户名和密码。点击隧道,选择Dynamic(SOCKS4/5),端口可随便输入一个跳板机未占用的端口号。...

痞子衡嵌入式:原来i.MXRT1xxx系列里也暗藏了Product ID寄存器_痞子衡的博客-程序员秘密

  大家好,我是痞子衡,是正经搞技术的痞子。今天痞子衡给大家介绍的是i.MXRT1xxx系列里暗藏的Product ID寄存器。  MCU 厂商在定义一个产品系列时,通常是会预先规划产品发展路线的(即会有一大波 MCU 型号面世,各型号间特性有差异),因此 MCU 内部一般都会有一个专门的只读寄存器用以存放 Product ID 值,应用程序可读取这个 ID 值来识别当前 MCU 型号,这样...

web 2.0/Ajax 后面的技术_weixin_34295316的博客-程序员秘密

从04年,Web 2.0正式被提出来之后。围绕业务模式,模型。web 2.0应用雨后春笋。比前几年吵的很热的SOA还要火爆。我google了一下,web 2.0的结果是SOA 的20倍。在技术领域,哪怕开发工具,好似不支持web 2.0就很落伍了一样。我最近听了一个delphi的讲座,delphi 2007很快推出,他居然也说要支持web2.0. 那么对web 2。0的支持,从技术角度讲有那些层次...

Python数模笔记-Sklearn(3)主成分分析_pca python sklearn 选择主成分数_分发吧的博客-程序员秘密

主成分分析(Principal Components Analysis,PCA)是一种数据降维技术,通过正交变换将一组相关性高的变量转换为较少的彼此独立、互不相关的变量,从而减少数据的维数。1、数据降维优惠券网站 https://www.cps3.cn/1.1 为什么要进行数据降维?  为什么要进行数据降维?降维的好处是以略低的精度换取问题的简化。  人们在研究问题时,为了全面、准确地反映事物的特征及其发展规律,往往要考虑很多相关指标的变化和影响。尤其在数据挖掘和分析工作中,前期收集数据阶段总是尽.

【转】JIRA JQL, 更灵活地跟踪缺陷、问题、任务。。。(2/4)_weixin_34306676的博客-程序员秘密

函数:为什么它们很酷?在JIRA中,字段存贮了与问题相关的数据。问题字段包含了:优先性、问题关键、问题详情等。函数本身有很多强大的功能,可以包含很多复杂的逻辑,但是这些复杂逻辑可以通过简单的方式表达出来。函数可以有选择地接收输入内容,并返回结果。比如,JIRA支持一个叫做membersof()的函数,如果用户是小组的一部分将会返回Tr...

推荐文章

热门文章

相关标签