内存优化——内存泄露

Java内存模型
在曾经的sun公司 制定的java虚拟机规范中,运行时内存模型,分为线程私有和共享数据区两大类,其中线程私有的数据区包含程序计数器、虚拟机栈、本地方法区,所有线程共享的数据区包含Java堆、方法区,在方法区内有一个常量池。

Java内存模型

  • 程序计数器PC:
    程序计数器PC是一块较小的内存空间,在多线程的时候CPU特定的时间只会执行一个线程的代码,当A线程执行到第5行代码的时候切换到B线程,在切换到A线程执行第6行代码,这个记录就是通过计数器来记录。记录虚拟机字节码的地址。如果执行的是native方法就是null。

  • 虚拟机栈
    它的生命周期与线程相同。虚拟机栈描述的是java方法执行的内存模型。每个方法(不包含native方法)执行的同时都会创建一个栈帧 用于存储局部变量表、操作数栈、动态链接、方法出口等信息。
    Java虚拟机规范规定该区域有两种异常:
    1.StackOverFlowError:当线程请求栈深度超出虚拟机栈所允许的深度时抛出 (递归函数)
    2.OutOfMemoryError:当Java虚拟机动态扩展到无法申请足够内存时抛出 (OOM)

  • 本地方法栈
    本地方法栈是为虚拟机使用到的Native方法提供内存空间。有些虚拟机的实现直接把本地方法栈和虚拟机栈合二为一,比如主流的HotSpot虚拟机。
    异常(Exception):Java虚拟机规范规定该区域可抛出StackOverFlowError和OutOfMemoryError。

  • Java堆
    Java堆,是Java虚拟机管理的最大的一块内存,也是GC的主战场,里面存放的是几乎所有的对象实例和数组数据。

  • 方法区
    方法区主要存放的是已被虚拟机加载的类信息、常量、静态变量、编译器编译后的代码等数据。Java虚拟机规范对这一块区域的限制非常宽松,不同的虚拟机实现也不同,相对而言垃圾回收在这个区域比较少的出现。根据java虚拟机规范,当方法区无法满足内存分配需求时,会抛出oom异常。

  • 运行时常量池
    运行时常量池是方法区的一部分,用于存放编译器生成的各种字面量和符号引用。运行时常量池除了编译期产生的Class文件的常量池,还可以在运行期间,将新的常量加入常量池,比较String类的intern()方法

  • 字面量:与Java语言层面的常量概念相近,包含文本字符串、声明为final的常量值等。

  • 符号引用:编译语言层面的概念,包括以下3类:
    1、类和接口的全限定名
    2、字段的名称和描述符
    3、方法的名称和描述符
    属于方法区一部分,所以和方法区一样,会oom

局部变量的基本数据类型和引用存储于栈中,引用的对象实体存储于堆中。
——因为它们属于方法中的变量,生命周期随方法而结束。
成员变量全部存储于堆中(包括基本数据类型,引用和引用的对象实体)
——因为它们属于类,类对象终究是要被new出来使用的。

我们说的内存泄露,是针对,也只针对堆内存,他们存放的就是引用指向的对象实体。

堆中几乎存放着Java世界中所有的对象实例,垃圾收集器在对堆回收之前,第一件事情就是要确定这些对象哪些还“存活”着,哪些对象已经“死去”

确定对象是否活着的方法有:
1、引用计数算法
1.1算法分析 
引用计数是垃圾收集器中的早期策略。在这种方法中,堆中每个对象实例都有一个引用计数。当一个对象被创建时,且将该对象实例分配给一个变量,该变量计数设置为1。当任何其它变量被赋值为这个对象的引用时,计数加1(a = b,则b引用的对象实例的计数器+1),当一个对象实例的某个引用超过了生命周期或者被设置为一个新值时,对象实例的引用计数器减1。任何引用计数器为0的对象实例可以被当作垃圾收集。当一个对象实例被垃圾收集时,它引用的任何对象实例的引用计数器减1。
1.2优缺点
优点:
  引用计数收集器可以很快的执行,交织在程序运行中。对程序需要不被长时间打断的实时环境比较有利。
缺点:
  无法检测出循环引用。如父对象有一个对子对象的引用,子对象反过来引用父对象。这样,他们的引用计数永远不可能为0.
1引用计数算法无法解决循环引用问题,例如:

public class Main {
    public static void main(String[] args) {
        MyObject object1 = new MyObject();
        MyObject object2 = new MyObject();
        
        object1.object = object2;
        object2.object = object1;
        
        object1 = null;
        object2 = null;
    }
}

最后面两句将object1和object2赋值为null,也就是说object1和object2指向的对象已经不可能再被访问,但是由于它们互相引用对方,导致它们的引用计数器都不为0,那么垃圾收集器就永远不会回收它们。

2、可达性分析算法(主流方法)



可达性分析算法中,通过一系列的gc root为起始点,从一个GC ROOT开始,寻找对应的引用节点,找到这个节点以后,继续寻找这个节点的引用节点,当所有的引用节点寻找完毕之后,剩余的节点则被认为是没有被引用到的节点,即无用的节点。
java中可作为GC Root的对象有
  1.虚拟机栈(本地变量表)中正在运行使用的引用
  2.方法区中静态属性引用的对象
  3. 方法区中常量引用的对象
4.本地方法栈JNI中引用的对象(Native对象)
上图中objD与objE到GC ROOT不可达,所以可以被回收。而其他的对gc root可达。

在说到内存的问题,我们都会提到一个关键词:引用

  • 强引用就是在程序代码中普遍存在的,比如”Object obj = new Object()”这种引用,只要强引用还在,垃圾收集器就不会回收被引用的对象。
  • 软引用用来定义一些还有用但并非必须的对象。对于软引用关联着的对象,在系统将要内存溢出之前,会将这些对象列入回收范围进行第二次回收,如果回收后还是内存不足,才会抛出内存溢出。
  • 弱引用也是用来描述非必须对象。但他的强度比软引用更弱一些。被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当垃圾收集器回收时,无论内存是否足够,都会回收掉被弱引用关联的对象。
  • 虚引用也称为幽灵引用或者幻影引用,是最弱的引用关系。一个对象的虚引用根本不影响其生存时间,也不能通过虚引用获得一个对象实例。虚引用的唯一作用就是这个对象被GC时可以收到一条系统通知。

在Android应用的开发中,为了防止内存溢出,在处理一些占用内存大而且生命周期较长的对象时候,可以尽量应用软引用和弱引用技术。

对于软引用和弱引用的选择,
如果只是想避免OutOfMemory异常的发生,则可以使用软引用。如果对于应用的性能更在意,想尽快回收一些占用内存比较大的对象,则可以使用弱引用。另外可以根据对象是否经常使用来判断选择软引用还是弱引用。如果该对象可能会经常使用的,就尽量用软引用。如果该对象不被使用的可能性更大些,就可以用弱引用。

内存泄漏就是 :堆内存中的长生命周期的对象持有短生命周期对象的引用,尽管短生命周期对象已经不再需要,但是因为长生命周期对象持有它的引用而导致不能被回收,这就是Java中内存泄露的根本原因。

排查内存泄漏问题

在android中我们执行一段代码,比如进入了一个新的页面(Activity),这时候我们的内存使用肯定比在前一个页面大,而在界面finish返回后,如果内存没有回落,那么很有可能就是出现了内存泄漏。

从内存监控工具中观察内存曲线,是否存在不断上升的趋势且不会在程序返回时明显回落。这种方式可以发现最基本,也是最明显的内存泄露问题,对用户价值最大,操作难度小,性价比极高。因为他能发现很明显很严重的内存泄漏问题

我们可以通过AS的Memory Profile或者DDMS中的heap观察内存使用情况。

工具的使用

在Android Studio我们可以点击下面图标运行APP



然后我们能看到


点击memory后



① 强制执行垃圾收集事件的按钮。
② 捕获堆转储的按钮。
③ 记录内存分配的按钮。
④ 放大时间线的按钮。
⑤ 跳转到实时内存数据的按钮。
⑥ 事件时间线显示活动状态、用户输入事件和屏幕旋转事件。
⑦ 内存使用时间表,其中包括以下内容:
每个内存类别使用多少内存的堆栈图,如左边的y轴和顶部的颜色键所示。
虚线表示已分配对象的数量,如右侧y轴所示。

如果不知道哪儿出现了内存泄漏的时候,我们打开一堆的页面最后返回主页,然后gc(点上面的①),再dump下内存查看。


dump

然后等待一段时间会出现:



这个页面想要分析出什么很难,最多打开看一眼刚刚我们进入的Activity是否因为我们退出而回收。先按照包名来分组,把页面向下滚动一下。

Alloc Cout : 对象数(对象数异常就很可能存在泄露)
Shallow Size : 对象占用内存大小
Retained Set : 对象引用组占用内存大小(包含了这个对象引用的其他对象)

在某些android studio中



这个自动分析任务包含了两个内容,一个是检测Activity的泄漏,一个是检测重复字符串。
点击运行分析:

这里出现了MainActivity的泄漏。并且观察到这个MainActivity可能不止一个对象存在,可能是我们上次退出程序的时候发生了泄漏,导致它不能回收。

android studio 3.3中
点击一个存在泄露的对象,如某个activity,会出现如下:


不仅显示出每个具体的对象,在下面Reference还显示出在哪里被引用了,能直接定位到问题代码,比之前的版本要更强大了。

当然一次dump可能并不能发现内存泄漏,可能每次我们dump的结果都不同,那么就需要多试几次,然后结合代码来排查。
如果这里还不能确定发生了内存泄漏。
我们这时候可以借助一些更专业的工具Mat(Memory Analyzer Tool)来进行内存的分析,Memory Analyzer Tool基于eclipse,可以在eclispe上安装mat插件,可以直接下载:
http://www.eclipse.org/mat/downloads.php
先把这个内存快照保存为hprof文件。

保存快照按钮

android studio 3.3把鼠标放在Heap Dump上
3.3保存快照

在使用mat之前我们需要把快照hprof文件转换成MAT标准的hprof文件。转换工具在SDK/platform-tools/hprof-conv,把SDK/platform-tools配置环境变量,在快照文件目录运行工具。

-z 排除不是app的内存,比如Zygote
生成MAT标准的hprof文件:
hprof-conv -z src dst

生成之后多一个hprof文件

打开下载好的MAT:


在File→Open Heap Dump打开生成的hprof文件

圆形图显示的内存占用情况。
但是我们要分析具体哪里内存泄露了还需要看直方Histogram:
点击直方图Histogram
点击直方图Histogram

结合android Studio上面提示MainActivity的泄露,我们在Regex搜索MainActivity
搜索结果

右键一个MainActivity

List objects引用了哪些对象
Show objects by class 被哪些对象引用
我们选择第三个Merge Shortest Path to GC Roots
由前面的原理可知,内存要被回收就要跟GC Root断开,所以没有被回收的对象我们需要看看被哪些引用了。

有排除软引用、弱引用、虚引用。我们选着三个都排除的。

全部展开之后,就能看见对象被谁引用了,导致没有被回收。至于具体是什么原因导致的,还需要结合代码,具体分析。
LeakCanary库也能检测内存泄漏,但只能起到辅助作用
https://github.com/square/leakcanary

内存泄漏常见原因:

  • 集合类
    集合类如果仅仅有添加元素的方法,而没有相应的删除机制,导致内存被占用。如果这个集合类是全局性的变量 (比如类中的静态属性,全局性的 map 等即有静态引用或 final 一直指向它),那么没有相应的删除机制,很可能导致集合所占用的内存只增不减。如ButterKnife中的LinkedHashMap(BINDINGS)就存在这个问题。
  • 静态成员
    Static成员作为gc root,如果一个对象被static声明,这个对象会一直存活直到程序进程停止。
  • 单例模式
    不正确使用单例模式是引起内存泄露的一个常见问题,单例对象在被初始化后将在 JVM 的整个生命周期中存在(以静态变量的方式),如果单例对象持有外部对象的引用,那么这个外部对象将不能被 JVM 正常回收,导致内存泄露。



    如果传递Activity作为Context来获得单例对象,那么单例持有Activity的引用,导致Activity不能被释放。
    不要直接对 Activity 进行直接引用作为成员变量,如果允许可以使用getApplicationContext()。
    如果不得不需要Activity作为Context,可以使用弱引用WeakReference,相同的,对于Service 等其他有自己声明周期的对象来说,直接引用都需要谨慎考虑是否会存在内存泄露的可能。

  • 未关闭/释放资源
    BraodcastReceiver,ContentObserver,FileObserver,Cursor,Callback等在 Activity onDestroy 或者某类生命周期结束之后一定要 unregister 或者 close 掉,否则这个 Activity 类会被 system 强引用,不会被内存回收。
  try {
            FileOutputStream fos = new FileOutputStream("a.txt");
            fos.write("test".getBytes());
            fos.close();
        } catch (Exception e) {
            e.printStackTrace();
        }

现在这段代码是存在隐患的,因为如果write发生异常那么这个fos会因为没有close造成内存泄漏。
所以正确的方式应该是:

FileOutputStream fos = null;
        try {
            fos = new FileOutputStream("a.txt");
            fos.write("test".getBytes());
        } catch (Exception e) {
            e.printStackTrace();
        }finally {
            if (fos !=null){
                try {
                    fos.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
  • Handler
    只要 Handler 发送的 Message 尚未被处理,则该 Message 及发送它的 Handler 对象将被线程 MessageQueue 一直持有。特别是handler执行延迟任务。所以,Handler 的使用要尤为小心,否则将很容易导致内存泄露的发生。
  private Handler mHandler = new Handler(){
        @Override
        public void handleMessage(Message msg) {
            super.handleMessage(msg);
            //TODO
        }
    };

这种创建Handler的方式会造成内存泄漏,由于mHandler是Handler的非静态匿名内部类的实例,所以它持有外部类Activity的引用,我们知道消息队列是在一个Looper线程中不断轮询处理消息,那么当这个Activity退出时消息队列中还有未处理的消息或者正在处理消息,而消息队列中的Message持有mHandler实例的引用,mHandler又持有Activity的引用,所以导致该Activity的内存资源无法及时回收,引发内存泄漏,所以另外一种做法为:

   private MyHandler mHandler = new MyHandler(this);
    private static class MyHandler extends Handler{
        private WeakReference<Context> reference;
        private MyHandler(Context context){
            reference = new WeakReference<>(context);
        }

        @Override
        public void handleMessage(Message msg) {
            super.handleMessage(msg);
            MainActivity activity = (MainActivity) reference.get();
            if (activity !=null){
                //TODO
            }
        }
    }

这样虽然避免了Activity泄漏,不过Looper线程的消息队列中还是可能会有待处理的消息,所以我们在Activity的Destroy时或者Stop时应该移除消息队列中的消息。

 @Override
    protected void onDestroy() {
        super.onDestroy();
        mHandler.removeCallbacksAndMessages(null);
    }

这样写代码比较麻烦,还可以使用weakHandler库,跟Handler一样的用法。
https://github.com/badoo/android-weak-handler

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

推荐阅读更多精彩内容