Android进阶——性能优化之内存管理机制和垃圾采集回收机制(六)

引言

虽然Java 不需要像C/C++本地语言那样在程序运行到需要一个动态分配的变量时,必须自己向系统申请取得堆中的一块所需大小的存储空间,用于存储该变量。当不再使用该变量时,也就是它的生命结束时,要显式释放它所占用的存储空间,从一定程度上来说Java 虚拟机的GC机制会帮助我们进行内存管理,但是由于技术、Android系统自身的一些Bug或者编程习惯常常导致所开发的应用潜藏着出现内存泄漏的问题,而且一般测试还不易发现,但是不应该被忽视,于是我也是结合自身的经验和查阅大量资料对于相关知识进行总结。

一、内存泄漏和内存溢出概述

内存泄漏(memory leak)是指堆内存中的长生命周期的对象持有短生命周期对象的引用,尽管短生命周期对象已经不再需要,但是由于长生命周期对象持有短生命周期对象的引用导致它没有及时被GC回收的情况,通俗来说就是该被释放的对象没有释放,因为它的引用还被其他对象持有(后面会解释为什么)内存泄漏会导致可用内存慢慢变少,让程序慢慢变卡,达到一定程度之后,甚至还可能导致臭名昭著的内存溢出(out of memory,简称oom),而内存溢出指的是程序申请的内存超出了系统所能分配的范围,比如说栈满还执行入栈操作导致上溢;栈空了还执行出栈操作导致的下溢;又比如申请了Integer类型的空间用于存放Long类型的数据等等,显而易见,内存泄漏积累下去会导致内存溢出OOM,但是并不是导致OOM的唯一原因。

二、Java运行时内存模型

Android原生开发以Java为主,而Java程序都是运行在Java虚拟机(JVM)之上的且内存全权交给虚拟机去管理,那虚拟机的运行时内存模型是如何构成的?堆和栈,相信很多人都能脱口而出,但这只是对内存粗略的一种划分,其中”堆”对应内存模型的Java堆”栈”则是指虚拟机栈,但是实际上Java运行时内存模型比这复杂多了,在SUN制定的Java虚拟机规范中,运行时内存模型,分为线程私有共享数据区两大类,其中线程私有的数据区包含程序计数器虚拟机栈本地方法区,所有线程共享的数据区包含Java堆方法区(又称为静态存储区,方法区内还有一个常量池),如下图所示这里写图片描述
其中私有内存区伴随着线程的产生而产生,一旦线程中止,私有内存区也会自动消除,因此我们在本文中讨论的内存回收主要是针对共享内存区。

1、线程私有数据区

1.1、程序计数器PC

程序计数器PC(Program Counter Register)是一块较小的内存空间,可以看作所执行字节码的行号指示器,字节码解释器就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,比如循环、跳转、异常处理等这些基础功能都需要依赖这个计数器来完成。Java的多线程是抢占式的调用,即任何一个确定的时刻,CPU都只会执行一条线程,而具体哪一条线程也是不确定的。所以为了线程切换后能够回到正确的执行位置,每个线程都需要一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,因此这块区域是”线程私有”的内存。当线程正在执行一个Java方法时,PC计数器记录的是正在执行的虚拟机字节码的地址;当线程正在执行的一个Native方法时,PC计数器则为空(Undefined)。值得注意的是这一块的内存区域是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError的区域。

1.2、虚拟机栈

和程序计数器一样,虚拟机栈(Java Virtual Machine Stacks)也是线程私有的,它的生命周期与线程相同。虚拟机栈描述的是Java方法执行的内存模型,每个方法(不包含native方法)执行的同时都会创建一个栈帧(Stack Frame) 用于存储局部变量表操作数栈动态链接方法出口等信息,当方法被执行时,方法体内的局部变量的基本数据类型和引用都存储于栈中都在栈上创建,并在方法执行结束时这些局部变量所持有的内存将会自动被释放。栈内存分配运算内置于处理器的指令集中,效率很高,但是分配的内存容量有限,Java虚拟机规范规定该区域有两种异常:

  • StackOverFlowError:当线程请求栈深度超出虚拟机栈所允许的深度时抛出 (递归函数)
  • OutOfMemoryError:当Java虚拟机动态扩展到无法申请足够内存时抛出 (OOM)

1.2 本地方法栈

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

2、所有线程共享数据区

2.1、Java堆

Java 又称 动态内存分配是应用程序在运行期通过new请求操作系统分配给自己的向高地址扩展的数据结构,是不连续的Java虚拟机管理的最大的一块内存区域,所以引用的对象实体成员变量(包括基本数据类型,引用和引用的对象实体)数组数据全部存储于堆中。( 因为它们属于类,类对象终究是要被new出来使用的,它们属于方法中的变量,生命周期随方法而结束)堆内存是由存活和死亡的对象组成的。存活的对象是应用可以访问的,不会被垃圾回收。死亡的对象是应用不可访问尚且还没有被垃圾收集器回收掉的对象。一直到垃圾收集器把这些对象回收掉之前,他们会一直占据堆内存空间。用一句话总结堆的作用:程序运行时动态申请某个大小的内存空间。 Java 堆内存在不使用时将会由 Java 垃圾回收器来负责回收,所以也是垃圾回收GC(Garbage Collection)的主战场(GC堆,垃圾堆),在Java虚拟机中该区域可抛出OutOfMemoryError。

public class Test {
    int k = 0;
    Test obj = new Test();

    public void run() {
        int n = 100;
        Test obj2 = new Test();
    }
}
Test tmp = new Test();

上例中Test 类的局部变量 n 和引用变量 obj2 都是存在于栈中,但 obj2 指向的对象是存在于堆上的; tmp 指向的对象实体存放在堆上,包括这个对象的所有成员变量 k 和 obj,而它自己存在于栈中,另外在堆中还分为新生代老年代

GC类型 说明
Minor GC 发生在新生代,频率高,速度快(大部分对象活不过一次Minor GC)
Major GC 发生在老年代,速度慢(Major GC会伴随至少一次 Minor GC,因此也不必过多纠结于到底是哪种GC,还有Full GC和Minor GC等价一说)
Full GC 清理整个堆空间()
  • 新生代—— HotSpot JVM把按照8:1:1的比例把新生代分为:1个Eden区和2个Survivor区(分别叫From和To)。通常新创建的对象都会被分配到Eden区(一些大对象特殊处理),这些对象经过第一次Minor GC(新生代采用空闲指针的方式来控制GC触发,指针保持最后一个分配的对象在新生代区间的位置,当有新的对象要分配内存时,用于检查空间是否足够,不够就触发GC)后,如果仍然存活,将会被移到Survivor区,对象在Survivor区中每熬过一次Minor GC,年龄就会增加1岁,当它的年龄增加到一定程度时,就会被移动到年老代中。
    因为年轻代中的对象基本都是朝生夕死的(80%以上),所以在年轻代的垃圾回收算法使用的是复制算法,复制算法的基本思想就是将内存分为两块,每次只用其中一块,当这一块内存用完,就将还活着的对象复制到另外一块上面。复制算法不会产生内存碎片。在GC开始的时候,对象只会存在于Eden区和名为“From”的Survivor区,Survivor区“To”是空的。紧接着进行GC,Eden区中所有存活的对象都会被复制到“To”,而在“From”区中,仍存活的对象会根据他们的年龄值来决定去向。年龄达到一定值(年龄阈值,可以通过-XX:MaxTenuringThreshold来设置)的对象会被移动到年老代中,没有达到阈值的对象会被复制到“To”区域。经过这次GC后,Eden区和From区已经被清空。这个时候,“From”和“To”会交换他们的角色,即新的“To”就是上次GC前的“From”,新的“From”就是上次GC前的“To”。不管怎样,都会保证名为To的Survivor区域是空的。Minor GC会一直重复这样的过程,直到“To”区被填满,“To”区被填满之后,会将所有对象移动到年老代中。

  • 老年代——用于存放新生代中经过N次垃圾回收仍然存活的对象,如果某个对象经历了几次垃圾回收之后还存活,就会被存放到老年代中,而且老年代的空间一般比新生代大,
    老年代的垃圾回收称为Major GC。整堆包括新生代与老年代的垃圾回收称之为Full GC。通常当我们创建一个对象后,它会按照以下流程放到对应的内存区域:
    这里写图片描述

2.2、方法区

Hotspot JVM用永久带来实现方法区而已,但是永久代不等于方法区,方法区主要存放的是已被虚拟机加载的类信息常量静态数据编译器编译后的代码等数据,这块方法区内存在程序编译时就已经分配好,并且在程序整个运行期间都存在。Java虚拟机规范对这一块区域的限制非常宽松,不同的虚拟机实现也不同,相对而言垃圾回收在这个区域比较少的出现。根据Java虚拟机规范,当方法区无法满足内存分配需求时,会抛出oom异常。

2.3、运行时常量池

运行时常量池隶属方法区的一部分,用于存放编译器生成的各种字面量(与Java语言的常量概念相近,包含文本字符串、声明为final的常量值等)和符号引用(编译语言层面包括类和接口的全限定名、字段的名称和描述符和方法的名称和描述符)。运行时常量池除了编译期产生的Class文件的常量池,还可以在运行期间,将新的常量加入常量池,比如String类的intern()方法。

三、Java内存的管理概述

Java的内存管理就是对象的分配和释放问题。在 Java 中我们通过关键字 new 为每个对象申请内存空间 (基本类型除外),所有的对象都在堆 (Heap)中分配空间,由GC根据具体回收机制执行对象的释放任务。把内存的管理分为了两条支线:程序完成内存的申请分配GC 完成内存的释放(Java程序员不需要通过调用函数来释放内存,但GC只能回收无用并且不再被其它对象引用的那些对象所占用的空间),这确实简化了Java程序员的工作。但同时也加重了JVM的工作。( Java 程序运行速度较慢的原因之一)因为GC 为了能够正确释放对象,GC为了更加准确地、及时地释放对象就需要监控每一个对象的运行状态(包括对象的申请、引用、被引用、赋值等,而释放对象的根本原则就是该对象不再被引用)
这里写图片描述

四、GC回收机制概述

对于我们程序员来说,GC基本是透明的、不可见的。虽然可以调用运行GC的函数(比如System.gc(),但是根据Java语言规范定义, 该函数不保证JVM的垃圾收集器一定会执行)因为不同的JVM实现者可能使用不同的算法管理GC。通常GC的线程的优先级别较低,JVM调用GC的策略也各不相同,有的是内存使用到达一定程度时GC才开始工作;也有定时执行的;也有的是平缓执行GC;还有的是中断式执行GC,所以在应用层面我们不需要关心这些,也难以影响和改进。为了更好理解 GC 的工作原理,我们可以将对象考虑为有向图的顶点,将引用关系考虑为图的有向边,有向边从引用者指向被引对象,在这个有向图中,根顶点可达的对象都是有效对象,GC将不回收这些对象。如果某个对象 (连通子图)与这个根顶点不可达(注意,该图为有向图),那么我们认为这个(这些)对象不再被引用,可以被 GC 回收。
这里写图片描述

1、确定对象是否活着的方法

堆中几乎存放着Java世界中所有的对象实例,GC在对堆回收之前,首要做的就是确定这些对象哪些还“存活”着,哪些对象已经“死去”(即不可能再被任何途径使用的对象)

1.1、引用计数算法(Reference Counting)

引用计数是垃圾收集器中的早期策略(大名鼎鼎的Glide里就采用了这种算法来标记使用和需要回收的对象),堆中每个对象实例都有一个引用计数。当一个对象被创建时,且将该对象实例分配给一个变量,该变量计数设置为1。当任何其它变量被赋值为这个对象的引用时,计数加1(a = b,则b引用的对象实例的计数器+1),当一个对象实例的某个引用超过了生命周期或者被设置为一个新值时,对象实例的引用计数器减1。任何引用计数器为0的对象实例可以被当作垃圾收集。当一个对象实例被垃圾收集时,它引用的任何对象实例的引用计数器减1。虽然引用计数收集器可以很快的执行,交织在程序运行中。对程序需要不被长时间打断的实时环境比较有利。但无法检测出循环引用。如父对象有一个对子对象的引用,子对象反过来引用父对象。这样,他们的引用计数永远不可能为0.

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;
        Syetem.gc();
    }
}

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

1.2、可达性分析算法(Rearchability Analysis)

又有根搜索算法一说,是来判定对象是否存活的朱刘算法之一,GC Root Tracing 算法思路就是通过一系列的名为”GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连,即从GC Roots到这个对象不可达,则证明此对象是不可用的
这里写图片描述
其中粉红色的对象为GC Root不可达对象,可以被回收,其他的对GC Root可达,不会被回收。但是即使在可达性分析算法中不可达的对象,也并非一定要死。要真正宣告一个对象死亡,至少要经过两个阶段:在可达性分析的时候,要是不可达,那么它会被第一次标记并且进行一次筛选,筛选的条件是此对象有没有必要执行finalize()方法,当对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过。那么,此对象就没有必要执行finalize()方法。当一个对象被判定为有必要执行finalize()方法的时候,它就会被放到一个F-Queue的队列中,并且稍后会有一个由虚拟机自动创建的线程来执行它,而且在执行它的时候,虚拟机不保证会等待它运行结束,这是因为要是finalize()里面有死循环或者什么的就麻烦了,就会堵塞队列,有时会导致垃圾回收系统崩溃。稍后GC还会再次对F-Queue进行小规模地第二次标记,如果这次成功被标记,那么它基本上真的就会被回收了。因为finalize()不保证被完全执行,所以我们在开发中,最好不要用它来关闭资源什么的,因为try finally能更好地完成这项工作。可以作为GC Root的对象

  • 虚拟机栈(本地变量表)中正在运行使用的引用
  • 方法区中静态属性引用的对象,通常静态变量会直接被GC Root引用,在创建对象的时候会直接指向GC Root
  • 方法区中常量引用的对象
  • 本地方法栈(Native方法)JNI中引用的对象(Native对象)
  • 活着的Thread

2、GC垃圾采集回收算法

在对对象进行回收前需要对垃圾进行采集,不同的虚拟机实现可能使用不同的垃圾收集算法,不同的收集算法的实现也不尽相同,不同的算法各有各的优劣势。

2.1、标记–清除算法Mark-Sweep

顾名思义先标记再清除,当对象被统一标记后,再统一清除回收,这是最基本的收集算法,
这里写图片描述
但是一存在些问题,算法会首先标记出所有需要回收的对象,在标记完成后统一回收被标记的对象。但不会进行对象的移动,而是直接回收不存活的对象,因此会造成内存碎片。比如我们回收后,如果我需要创建一个占了10个格子的内存大小的对象,就造成了OOM,虽然整体上有这么大的内存可以使用,但是没有连续的这么大的内存。

2.2、标记–压缩(整理)算法Mark-Compact

复制算法在对象存活率较高的时候效率就很低了,所以在一般存活率很高的老年代里就不宜采用复制算法了,
这里写图片描述
因而在标记清除算法基础上,开发了一种适合老年代回收的标记–压缩算法,在标记清除的基础上做了一些优化,首先从根节点开始标记所有不可达的对象,然后将所有可达的对象移动到内存的一端,最后清除所有不可达的对象

2.3、复制算法Copying

它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉
这里写图片描述
但每次能用的内存只有一半,代价有点高,现在商用虚拟机都采用这种算法来回收新生代,研究发现,新生代中的对象每次回收都基本上只有10%左右的对象存活,所以需要复制的对象很少,效率还不错。每次都是对其中的一块进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。只是这种算法的代价是将内存缩小为原来的一半,持续复制长生存期的对象则导致效率降低。复制收集算法在对象存活率较高时就要执行较多的复制操作,效率将会变低。更关键的是,如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况。

2.4、分代收集算法

无论是一般的JVM还是DVM,不会只使用一种垃圾收集算法。它会根据内存的划分实现不同的收集算法。当前商业虚拟机的垃圾收集都采用分代收集算法,分代收集是基于不同的对象的生命周期是实现的具体的回收策略,因此不同生命周期的对象可以采取不同的收集方式,以便提高回收效率。而在Java程序运行的过程中会产生大量的对象,每个对象有着不同的生命周期,有的对象生命周期较长(如Android中的Application、启动的Service等),而有的对象生命周期较短(如一些函数内部new出来的String对象)假如不区分对象存活时间,每次垃圾回收都会回收整个堆空间,那么消耗的时间就会相对较长,而且对于存活时间较长的对象进行的扫描工作等都是徒劳。于是引入分代思想,将对象进行代的划分(分为新生代和老年代,新生代又被进一步划分为Eden和Survivor区, Survivor由From Space和To Space组成新生代对象存活率低,就用复制算法;年老代存活率高,就用标记–清除算法或者标记–整理算法)并把不同生命周期的对象存放在不同的代上使用不同的垃圾回收方式。
这里写图片描述

2.5、分区算法

分区算法是将整个堆空间分成很多个连续的不同的小空间,每个小空间独立使用,独立回收。为了更好的控制GC停顿时间,可以根据目标停顿时间合理地回收若干个小区间,而不是整个堆空间,从而减少GC停顿时间。

3、垃圾收集器

垃圾采集算法是内存回收的概念,那么垃圾收集器就是内存回收的具体实现。Java虚拟机规范对如何实现垃圾收集器没有任何规定,所以不同的厂商、不同版本的虚拟机提供的垃圾收集器可能会有很大差别。
这里写图片描述

3.1、Serial串行收集器

Serial串行收集器是最基本的历史最悠久的单线程收集器,曾经是JVM新生代的唯一选择,因此在进行垃圾收集时,必须暂停其他所有的工作线程,直到收集结束才能继续执行,可以通过java -version查看所用的收集器版本
这里写图片描述

3.2、ParNew 收集器

ParNew 收集器是Serial收集器的多线程版本,除了使用多线程进行垃圾收集之外,其他的行为和Serial一样,ParNew收集器是Server版本的虚拟机中首选的新生代收集器,因为除了Serial只有它可以和CMS收集器配合。
这里写图片描述

3.3、Parallel Scavenge收集器

是新生代使用复制算法的并行的多线程收集器。和ParNew等其他收集器的区别在于,它主要控制吞吐量( 吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间) )即CPU用于运行用户代码事件于CPU总消耗时间的比值,假如虚拟机总共运行100分钟,其中垃圾回收花掉1分钟,则吞吐量为 99/99+1 = 99%;而吞吐量越高表示垃圾回收时间占比越小,CPU利用效率越高,所以这个收集器也被称为”吞吐量收集器”,高吞吐量为目标即减少垃圾收集时间,让用户代码获得更长的运行时间。

3.4、Serial Old收集器

老年代版本的串行收集器,使用标记整理算法
这里写图片描述

3.5、Parallel Old收集器

老年代版本的并行的多线程收集器,使用,标记整理算法。
这里写图片描述

3.6、CMS 收集器

CMS 收集器(Concurrent Mark Sweep收集器)是一种以获得最短回收停顿事件为目标的收集器,又称为并发低停顿收集器或低延迟垃圾收集器,采用标记清除算法,Dalvik虚拟机主要使用标记清除算法,也可以选择使用拷贝算法。这取决于编译时期DVM使用的算法,Google在 Android 4.4 中引入的一个开发者选项——ART ( Android 5.0 及更高版本的默认 Android 运行时)。目前Google已不再继续维护和提供 Dalvik 运行时,现在 ART 采用了其字节码格式,而ART 有多个不同的 GC 方案,这些方案包括运行不同垃圾回收器,默认方案是 CMS收集器。
Android默认使用的垃圾收集器

这里写图片描述
CMS工作步骤比较复杂:

  1. 初始标记(CMS initial mark)——仅标记一下GC Roots能直接关联到的对象,速度很快,但需要”Stop The World”;
  2. 并发标记(CMS concurrent mark)——进行GC Roots 追踪的过程,刚才产生的集合中标记出存活对象,应用程序也在运行,并不能保证可以标记出所有的存活对象;
  3. 重新标记(CMS remark)——为了修正并发标记期间因用户程序继续运作而导致标记变动的那一部分对象的标记记录;需要”Stop The World”,且停顿时间比初始标记稍长,但远比并发标记短;采用多线程并行执行来提升效率;
  4. 并发清除(CMS concurrent sweep)——回收所有的垃圾对象;

由于整个过程中耗时最长的并发标记和并发清除过程收集器线程都可以与用户线程一起工作,所以从总体上来说,CMS收集器的内存回收过程是与用户线程一起并发执行的。CMS是一款优秀的收集器,它的主要优点在名字上已经体现出来了:并发收集、低停顿。但是存在一些问题:

  • 造成CPU资源紧张,因为比其他收集器多开线程
  • 无法处理浮动垃圾,由于CMS并发清理阶段用户线程还在运行着,伴随程序运行自然就还会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS无法在当次收集中处理掉它们,只好留待下一次GC时再清理掉,这一部分垃圾就称为“浮动垃圾”。因此CMS收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,需要预留一部分空间提供并发收集时的程序运作使用。要是CMS运行期间预留的内存无法满足程序需要,就会出现一次“Concurrent Mode Failure”失败,这时虚拟机将启动后备预案:临时启用Serial Old收集器来重新进行老年代的垃圾收集,这样停顿时间就很长了。
  • 因为采用标记清除算法,所以会可能产生大量内存碎片

3.7、G1收集器

Garbage-First收集器是当今收集器技术发展最前沿的成果之一,是一款面向服务端应用的垃圾收集器,G1手机器人和 CMS差不多,但是G1的采集范围是整个堆(新生代老生代)。他把内存堆分成多个大小相等的独立区域,在最后的筛选回收的时候根据这些区域的回收价值和成本决定是否回收掉内存。
这里写图片描述
由于篇幅问题内存泄漏、内存抖动检测和解决留到下一篇,未完待续…

猜你喜欢

转载自blog.csdn.net/crazymo_/article/details/80210657