深入理解内存泄漏

7,263 阅读16分钟

一、JVM内存模型

常见jvm内存模型,主要分为堆区,本地方法栈,虚拟机栈,程序计数器,和方法区。如下图所示: image.png

(1)程序计数器

每个线程都会有自己私有的程序计数器(PC)。可以看作是当前线程所执行的字节码的行号指示器。 也可以理解为下一条将要执行的指令的地址或者行号。字节码解释器就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、 循环、 跳转、 异常处理、 线程上下文切换,线程恢复时,都要依赖PC.

  • 如果线程正在执行的是一个Java方法,PC值为正在执行的虚拟机字节码指令的地址
  • 如果线程正在执行的是Native方法,PC值为空(未定义)

(2)虚拟机栈(VM Stack)

简介

VM Stack也是线程私有的区域。他是java方法执行时的字典:它里面记录了局部变量表、 操作数栈、 动态链接、 方法出口等信息。 **在《java虚拟机规范》一书中对这部分的描述如下:**栈帧( Frame)是用来存储数据和部分过程结果的数据结构,同时也被用来处理动态链接 (Dynamic Linking)、 方法返回值和异常分派( Dispatch Exception)。栈帧随着方法调用而创建,随着方法结束而销毁——无论方法是正常完成还是异常完成(抛出了在方法内未被捕获的异常)都算作方法结束。栈帧的存储空间分配在 Java 虚拟机栈( §2.5.5)之中,每一个栈帧都有自己的局部变量表( Local Variables, §2.6.1)、操作数栈( OperandStack, §2.6.2)和指向当前方法所属的类的运行时常量池( §2.5.5)的引用。 VM-Stack 说白了,VM Stack是一个栈,也是一块内存区域。 所以,他是有大小的。虽然有大小,但是一般而言,各种虚拟机的实现都支持动态扩展这部分内存。

  • 如果线程请求的栈深度太大,则抛出StackOverflowError
  • 如果动态扩展时没有足够的大小,则抛出OutOfMemoryError以下代码肯定会导致StackOverflowError:

StackOverflowError

public static void method() {
    method();
}

public static void main(String[] args) {
    method();
}

Exception in thread "main" java.lang.StackOverflowError
    at xxx.xxx.xxx.method(JavaVMStackSOF.java:10)

(3)本地方法栈

Java 虚拟机实现可能会使用到传统的栈(通常称之为“ C Stacks”)来支持 native 方法( 指使用 Java 以外的其他语言编写的方法)的执行,这个栈就是本地方法栈( Native MethodStack)。 VM Stack是为执行java方法服务的,此处的Native Method Stack是为执行本地方法服务的。 此处的本地方法指定是和具体的底层操作系统层面相关的接口调用了(这部分太高高级了,不想深究……)。 《java虚拟机规范》中没有对这部分做具体的规定。所以就由VM的实现者自由发挥了。 有的虚拟机(比如HotSpot)将VM Stack和Native Method Stack合二为一,所以VM的另一种内存区域图就如下面所示了: image.png

(4)方法区

方法区是由所有线程共享的内存区域。 方法区存储的大致内容如下:

  • 每一个类的结构信息
    • 运行时常量池( Runtime Constant Pool)
    • 字段和方法数据
    • 构造函数和普通方法的字节码内容
  • 类、实例、接口初始化时用到的特殊方法

以下是本人对《java虚拟机规范》一书中对方法区的介绍的总结:

  • 在虚拟机启动的时候被创建
  • 虽然方法区是堆的逻辑组成部分,但是简单的虚拟机实现可以选择在这个区域不实现垃圾收集
  • 不限定实现方法区的内存位置和编译代码的管理策略
  • 容量可以是固定大小的,也可以随着程序执行的需求动态扩展,并在不需要过多空间时自动收缩。
  • 方法区在实际内存空间中可以是不连续的
  • Java 虚拟机实现应当提供给程序员或者最终用户调节方法区初始容量的手段
    • 对于可以动态扩展和收缩方法区来说,则应当提供调节其最大、最小容量的手段
  • 如果方法区的内存空间不能满足内存分配请求,那 Java 虚拟机将抛出一个OutOfMemoryError 异常

(5)堆

简介

在 Java 虚拟机中,堆( Heap)是可供各条线程共享的运行时内存区域,也是供所有类实例和数组对象分配内存的区域。 以下是本人对《java虚拟机规范》一书中对Java堆的介绍的总结:

  • 在虚拟机启动的时候就被创建
  • 是所有线程共享的内存区域
  • 存储了被自动内存管理系统所管理的各种对象
  • Java 堆的容量可以是固定大小的,也可以随着程序执行的需求动态扩展,并在不需要过多空间时自动收缩
  • Java 堆所使用的内存不需要保证是连续的
  • 如果实际所需的堆超过了自动内存管理系统能提供的最大容量,那 Java 虚拟机将会抛出一个OutOfMemoryError 异常
  • 实现者应当提供给程序员或者最终用户调节 Java 堆初始容量的手段
  • 所有的对象实例以及数组都要在堆上分配
  • 至于堆内存的详细情况,将在后续的GC相关文章中介绍。

堆内存中的OutOfMemoryError以下示例代码肯定导致堆内存溢出:

public static void main(String[] args) {
    ArrayList<Integer> list = Lists.newArrayList();
    while (true) {
        list.add(1);
    }
}

无限制的往list中添加元素,无论你的堆内存分配的多大,都会有溢出的时候。

java.lang.OutOfMemoryError: Java heap space

二、内存优化工具

(1)LeakCanary

介绍

leakCanary这是一个集成方便, 使用便捷,配置超级简单的框架,实现的功能却是极为强大的线下内存检测工具。 screenshot-2.0.png

如何使用

dependencies {
  // debugImplementation because LeakCanary should only run in debug builds.
  debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.7'
}

在项目集成之后,在Android Studio Logcat日志通过筛选LeakCanary可以看到如下日志,标志LeakCanary已经安装成功,并且已经启动。 image.png

内存泄漏

我们经常会用到很多单例的工具类,往往这些单例也是经常容易发生内存泄漏的地方。下面我们模拟一下单例工具类造成内存泄漏的情况,封装一个ToastUtils的单例类,内部持有context的引用,这样在页面销毁之后依然持有context的引用,造成无法销毁,从而造成内存泄漏。

object ToastUtils {
    private var context: Context? = null

    fun toast(context: Context, text: String) {
        this.context =context
        Toast.makeText(context, text, Toast.LENGTH_LONG)
    }
}

在控制台和手机上都可以看到内存泄漏的信息。如下所示: image.pngimage.png 手机上可以明显看到内存泄漏的列表,通过点击item即可看到详细的堆栈信息。

====================================
    1 APPLICATION LEAKS
    
    References underlined with "~~~" are likely causes.
    Learn more at https://squ.re/leaks.
    
    76243 bytes retained by leaking objects
    Signature: 409f986871fac1acf6527c76b4d658d03ffa8e11
    ┬───
    │ GC Root: Local variable in native code
    │
    ├─ android.os.HandlerThread instance
    │    Leaking: NO (PathClassLoader↓ is not leaking)
    │    Thread name: 'LeakCanary-Heap-Dump'
    │    ↓ Thread.contextClassLoader
    ├─ dalvik.system.PathClassLoader instance
    │    Leaking: NO (ToastUtils↓ is not leaking and A ClassLoader is never leaking)
    │    ↓ ClassLoader.runtimeInternalObjects
    ├─ java.lang.Object[] array
    │    Leaking: NO (ToastUtils↓ is not leaking)
    │    ↓ Object[].[620]
    ├─ com.caichen.article_caichen.ToastUtils class
    │    Leaking: NO (a class is never leaking)
    │    ↓ static ToastUtils.context
    │                        ~~~~~~~
    ╰→ com.caichen.article_caichen.LeakActivity instance
    ​     Leaking: YES (ObjectWatcher was watching this because com.caichen.article_caichen.LeakActivity received
    ​     Activity#onDestroy() callback and Activity#mDestroyed is true)
    ​     Retaining 76.2 kB in 1135 objects
    ​     key = 42b604fb-b746-48f5-8ff5-f56cd01a570e
    ​     watchDurationMillis = 5204
    ​     retainedDurationMillis = 190
    ​     mApplication instance of android.app.Application
    ​     mBase instance of androidx.appcompat.view.ContextThemeWrapper
    ====================================

同时通过控制台也可以看到详细的信息。

如何分析

    ├─ com.caichen.article_caichen.ToastUtils class
    │    Leaking: NO (a class is never leaking)
    │    ↓ static ToastUtils.context
    │                        ~~~~~~~
    ╰→ com.caichen.article_caichen.LeakActivity instance

通过堆栈信息,大致会得到内存泄漏的大致引用调用路径,最终定位到ToastUtils类中,存在内存泄漏的地方就是内部的context变量,因为被单例对象所持有那么他引用的context和单例的生命周期相同。所以当页面消失时,无法被垃圾回收器销毁从而造成内存泄漏。

(2)Profile Memory

介绍

Profile Memory是 Android Profiler 中的一个组件,可帮助您识别可能会导致应用卡顿、冻结甚至崩溃的内存泄漏和内存抖动。它显示一个应用内存使用量的实时图表,让您可以捕获堆转储、强制执行垃圾回收以及跟踪内存分配。

如何使用

如需打开内存性能分析器,请按以下步骤操作:

  1. 依次点击 View > Tool Windows > Profiler(您也可以点击工具栏中的 Profile 图标 )。
  2. 从 Android Profiler 工具栏中选择要分析的设备和应用进程。如果您已通过 USB 连接设备但系统未列出该设备,请确保您已启用 USB 调试
  3. 点击 MEMORY 时间轴上的任意位置以打开内存性能分析器。

如图所示: image.png 点击Memory选项可以进入内存性能分析器界面, image.png 右上角分别展示出jvm对应的内存的情况。 内存计数中的类别如下:

  • Total:内存占用的总和值
  • Java:从 Java 或 Kotlin 代码分配的对象的内存。
  • Native:从 C 或 C++ 代码分配的对象的内存。即使您的应用中不使用 C++,您也可能会看到此处使用了一些原生内存,因为即使您编写的代码采用 Java 或 Kotlin 语言,Android 框架仍使用原生内存代表您处理各种任务,如处理图像资源和其他图形。
  • Graphics:图形缓冲区队列为向屏幕显示像素(包括 GL 表面、GL 纹理等等)所使用的内存。(请注意,这是与 CPU 共享的内存,不是 GPU 专用内存。)
  • Stack:您的应用中的原生堆栈和 Java 堆栈使用的内存。这通常与您的应用运行多少线程有关。
  • Code:您的应用用于处理代码和资源(如 dex 字节码、经过优化或编译的 dex 代码、.so 库和字体)的内存。
  • Others:您的应用使用的系统不确定如何分类的内存。
  • Allocated:您的应用分配的 Java/Kotlin 对象数。此数字没有计入 C 或 C++ 中分配的对象。如果连接到搭载 Android 7.1 及更低版本的设备,只有在内存性能分析器连接到您运行的应用时,才开始此分配计数。因此,您开始分析之前分配的任何对象都不会被计入。但是,Android 8.0 及更高版本附带一个设备内置性能剖析工具,该工具可跟踪所有分配,因此,在 Android 8.0 及更高版本上,此数字始终表示您的应用中待处理的 Java 对象总数。

内存抖动

什么是内存抖动,内存抖动就是内存在短暂时间频繁的GC和分配内存,导致内存不稳定。体现在profile中就是内存曲线呈现锯齿状的形状。 image.png

影响

  1. 频繁的创建对象造成内存碎片和不足
  2. 碎片的内存无法被分配从而容易导致内存溢出
  3. 频繁GC导致应用性能下降

如何分析

  1. 通过点击Record然后可以记录出内存片的内存信息

image.png

  1. 通过分析可以看出,string,char,int和StringBuilder都是占用内存比较多的对象
  2. 分析源码可以看出,在handleMessage中不断的进行创建intArray的对象,然后handlerMessage执行完毕之后创建的对象进行销毁,所以内存曲线呈现出来的形状呈锯齿状。
  3. 其中可以看出int占用的内存并没有string占有的内存大,那是因为通过log日志打印的字符串,通过StringBuilder进行了拼接,所以string和stringBuilder占用的内存比Int占用的内存还要大。
private fun initHandler() {
        handler = Handler(Looper.getMainLooper(), Handler.Callback {
            for (index in 0..1000) {
                val asc = IntArray(100000) { 0 }
                Log.d("YI", "$asc")
            }
            handler?.sendEmptyMessageDelayed(0, 300)
            true
        })

        handler?.sendEmptyMessageDelayed(0, 300)
    }

(3)Memory Analyzer

介绍

Eclipse Memory Analysis Tools (MAT) 是一个分析 Java堆数据的专业工具,用它可以定位内存泄漏的原因。

如何使用

  1. 通过As导出的Heap文件不能直接使用需要通过SDK/platform-tools中的Hprof可执行文件将androidhea文件进行转化。
  2. 打开MAT程序,通过Open heap打开文件。

image.pngimage.png

如何分析

  1. 通过筛选类进行查看实例

image.png

  1. 通过包名进行查看实例

image.png 图中1处可可以指定分类的方式,图中2处可以找到自己包名下面的类,图中3处可以看到所有的包名下的实例和个数。可以看出图中LeakActivity的实例的个数11个,我们可以大致猜测出这个Activity是泄漏了。 接着我们查看Dominator Tree然后可以看出,罗列出leakActivity的所有的实例。 image.png 右键选中,然后点击Merge Shortest Paths to GC Roots可以看到引用路径。LeakActivity的引用呗Toastutils所持有。然后通过检查源码,可以看出内部context持有外部的引用,ToastUtils又是单例造成了activity的泄漏。 image.png

三、常见内存泄漏问题及解决方法

(1)非静态内部类默认持有外部类的引用会导致内存泄漏

静态内部类与非静态内部类之间存在一个最大的区别,就是非静态内部类在编译完成之后会隐含地保存着一个引用,该引用是指向创建它的外围类,但是静态内部类却没有。 代码:

public class Outer {
    
    private void outerDo() {}
    
    class Inter {
        
        private void innerDo() {
            // 内部类可以直接访问外部类成员,原因在于隐式持有了一个外部类引用
            outerDo();
            // Outer.this 就是内部类隐式持有的外部类引用
            Outer.this.outerDo();
        }
    }
}

如果Inter的实例为静态的会导致内存泄漏。 解决方法:将Inter改成静态内部类

(2)Handler持有当前类的Context对象,导致对象无法释放

例如:

public class MainActivity extends AppCompatActivity {

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

    private void start() {
        Message msg = Message.obtain();
        msg.what = 1;
        mHandler.sendMessage(msg);
    }

    private Handler mHandler = new Handler() {
        @Override
        public void handleMessage(Message msg) {
            if (msg.what == 1) {
                // 做相应逻辑
            }
        }
    };
}

熟悉Handler消息机制的都知道,mHandler会作为成员变量保存在发送的消息msg中,即msg持有mHandler的引用,而mHandler是Activity的非静态内部类实例,即mHandler持有Activity的引用,那么我们就可以理解为msg间接持有Activity的引用。msg被发送后先放到消息队列MessageQueue中,然后等待Looper的轮询处理(MessageQueue和Looper都是与线程相关联的,MessageQueue是Looper引用的成员变量,而Looper是保存在ThreadLocal中的)。那么当Activity退出后,msg可能仍然存在于消息对列MessageQueue中未处理或者正在处理,那么这样就会导致Activity无法被回收,以致发生Activity的内存泄露。 解决办法:静态内部类+弱引用 例如:

public class MainActivity extends AppCompatActivity {

    private static class ParseHandler extends XyHandler<MainActivity> {

        private ParseHandler(MainActivity activity) {
            super(activity);
        }

        @Override
        protected void handleMessage(Message msg, MainActivity activity) {
            switch (msg.what) {
                case 0:
                    activity.doSomething();
                    break;
                default:
                    break;
            }
        }
    }

    private Handler mHandler;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        mHandler = new ParseHandler(this);
        start();
    }

    private void start() {
        Message msg = Message.obtain();
        msg.what = 1;
        mHandler.sendMessage(msg);
    }
    
    public void doSomething(){
        
    }
}

public abstract class XyHandler<T> extends Handler {

    private WeakReference<T> mWeak;

    public XyHandler(T t) {
        mWeak = new WeakReference<>(t);
    }

    @Override
    public void handleMessage(Message msg) {
        if (mWeak == null || mWeak.get() == null) {
            return;
        }
        handleMessage(msg, mWeak.get());
        super.handleMessage(msg);
    }

    protected abstract void handleMessage(Message msg, T t);

}

mHandler通过弱引用持有Activity时,在GC操作时,Activity就会被正常回收。 关于消息发送机制看博客:Android系统源码分析--消息循环机制,上面的解决办法也可以在进一步封装,可以看博客最后的Handler正确使用方法。 关于强引用、软引用、弱引用、虚引用可以看文章:内存泄漏:使用弱应用处理外部类引用

(3) 静态对象引用,无法释放对象导致内存泄漏

这种情况不常遇到,一般有些新手会犯这个错误。这种就是一些对象的引用是静态的,导致无法回收。 例如:

public class MainActivity extends AppCompatActivity {

    privite static MainActivity mMainActivity;

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

还有自定义View或者ViewGroup中也是出现这种状况,导致内存无法释放,出现内存溢出。对于这种Context不能用静态引用,如果需要调用这些对象内的方法可以将对应方法代码提取出来。

(4) 线程内引用有生命周期的外部对象

线程一般是指Thread和AsyncTask

Thread,例如:

public class MainActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        new Thread(new Runnable() {
            @Override
            public void run() {
                // 模拟相应耗时逻辑
                try {
                    Thread.sleep(2000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }).start();
    }
}

AsyncTask,例如:

public class MainActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        new AsyncTask<Void, Void, Void>() {
            @Override
            protected Void doInBackground(Void... params) {
                // 模拟相应耗时逻辑
                try {
                    Thread.sleep(2000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                return null;
            }
        }.execute();
    }
}

我们在刚开始写代码的时候都是像上面一样写线程和异步任务,但是这种方式使用Thread和AsyncTask都是匿名内部类对象,默认持有外部类Activity的引用,在Activity回收时可能会有Thread和AsyncTask没有执行完的情况,所以可能会造成内存泄漏。

(5)资源未关闭或者未释放造成的内存泄漏

IO流、File流或者数据库、Cursor等资源在使用完成后要及时关闭,如果没有及时关闭,会导致缓冲对象一直被占用,不能得到释放,发生内存泄漏。Bitmap未回收。

(6)单例引用Context导致内存泄漏

例如:

public class AppSettings {

    private static AppSettings sInstance;
    private Context mContext;

    private AppSettings(Context context) {
        this.mContext = context;
    }

    public static AppSettings getInstance(Context context) {
        if (sInstance == null) {
            sInstance = new AppSettings(context);
        }
        return sInstance;
    }
}

单例模式的生命周期会和整个应用的生命周期一样,如果在使用单例模式时传入的是Activity或者Service等声明周期短于Application的声明周期时,都会造成内存泄漏。因此我们传入的Context应该是Application的Context,这样生命周期就和Application的声明周期一样,避免造成内存泄漏

(7)广播未及时注销造成内存泄漏

public class MainActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        this.registerReceiver(mReceiver, new IntentFilter());
    }

    private BroadcastReceiver mReceiver = new BroadcastReceiver() {
        @Override
        public void onReceive(Context context, Intent intent) {
            // 接收到广播需要做的逻辑
        }
    };

    @Override
    protected void onDestroy() {
        super.onDestroy();
        this.unregisterReceiver(mReceiver);
    }
}

广播是非静态内部类,会持有Activity引用,而广播注册会将广播对象注册到系统内部,如果没有取消注册,那么系统中会存在Activity的应用,因此不能释放Activity,造成内存泄漏。 解决办法:就是在OnDestroy方法中取消注册广播。

(8)集合类泄漏

集合类如果仅仅有添加元素的方法,而没有相应的删除机制,导致内存被占用。如果这个集合类是全局性的变量 (比如类中的静态属性,全局性的 map 等即有静态引用或 final 一直指向它),那么没有相应的删除机制,很可能导致集合所占用的内存只增不减。

(9)WebView内存泄漏

原文里说的webview引起的内存泄漏主要是因为org.chromium.android_webview.AwContents 类中注册了component callbacks,但是未正常反注册而导致的。 org.chromium.android_webview.AwContents 类中有这两个方法 onAttachedToWindow 和 onDetachedFromWindow;系统会在attach和detach处进行注册和反注册component callback; 在onDetachedFromWindow() 方法的第一行中:

if (isDestroyed()) return;,

如果 isDestroyed() 返回 true 的话,那么后续的逻辑就不能正常走到,所以就不会执行unregister的操作;我们的activity退出的时候,都会主动调用 WebView.destroy() 方法,这会导致 isDestroyed() 返回 true;destroy()的执行时间又在onDetachedFromWindow之前,所以就会导致不能正常进行unregister()。 然后解决方法就是:让onDetachedFromWindow先走,在主动调用destroy()之前,把webview从它的parent上面移除掉。

ViewParent parent = mWebView.getParent();
if (parent != null) {
    ((ViewGroup) parent).removeView(mWebView);
}
mWebView.destroy();

完整的activity的onDestroy()方法:

@Override
protected void onDestroy() {
    if( mWebView!=null) {
        // 如果先调用destroy()方法,则会命中if (isDestroyed()) return;这一行代码,需要先onDetachedFromWindow(),再
        // destory()
        ViewParent parent = mWebView.getParent();
        if (parent != null) {
            ((ViewGroup) parent).removeView(mWebView);
        }
        mWebView.stopLoading();
        // 退出时调用此方法,移除绑定的服务,否则某些特定系统会报错
        mWebView.getSettings().setJavaScriptEnabled(false);
        mWebView.clearHistory();
        mWebView.clearView();
        mWebView.removeAllViews();
        mWebView.destroy();
    }
    super.on Destroy();
}

本文字作者:自如大前端研发中心-邹鑫