美文网首页性能优化—Android安卓面试
Android性能调优(3) —内存管理与垃圾回收

Android性能调优(3) —内存管理与垃圾回收

作者: godliness | 来源:发表于2018-05-07 08:53 被阅读24次

    一、初识内存管理

    在性能优化的专题探讨中:内存问题应该是最复杂和令人头疼的一部分。虽然编写Android应用程序大部分采用Java语言,而在虚拟机自动内存管理机制的帮助下,不再需要为每一个new操作去写配对delete/free代码,不容易出现内存泄漏和内存溢出问题,不过正因如此,当发生内存问题如:内存泄漏、内存抖动等情况,如果对内存管理机制不熟悉,这对与分析问题以及排查都是较为困难的工作。

    二、Java内存区域

    由于编写Android应用程序大部分采用Java语言,所以不得不提到Java内存管理。

    Java虚拟机在执行Java程序的过程中会把他所管理的内存划分为若干个不同的数据区域,这些区域都有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而存在,有些区域则依赖用户线程的启动和结束而建立和销毁。

    Java虚拟机所管理的内存将会包括以下几个运行时数据区域,Java虚拟机在执行Java程序时会把它所管理的内存区域划分为若干个不同的数据区,这些区域都有着各自的用途:

    1、程序技术器

    此区域是一块较小的区域,可以把它看做:当前线程所执行的字节码行号指示器。字节码解释器就通过改变这个计数器的值来选取一下条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等。

    程序计数器是线程私有,每个线程都都有自己的程序计数器,彼此之间不能相互影响。

    注:此内存区域是唯一在Java虚拟机规范中没有规定任何OutOfMemoryError的区域。

    2、虚拟机栈

    与程序计数器一样,Java虚拟机栈也是线程私有的。它的生命周期与线程相同,虚拟机栈是描述Java方法执行的内存模型。每个方法在执行时都会创建一个栈帧,用于存储:局部变量表、操作数栈、动态链接、方法出口等。随着方法的执行到结束,就对应着一个栈帧在虚拟机栈中入栈出栈的过程。

    在Java虚拟机规范中,对该区域有两种异常情况:如果线程请求的栈深度大于虚拟机所允许的深度,将会抛出StackOverflowError异常;如果虚拟机栈可以动态扩展,如果扩展时无法申请到足够的内存,就会抛出OutOfMemoryError异常。

    2.1)局部变量表:

    用于存储编译期各种基本数据类型,对象引用类型。

    2.2)操作数栈:

    每个栈帧都包含一个被叫做操作数栈的后进先出的栈,叫操作栈。或者操作数栈。

    主要起到的作用:

    2.1.1)栈桢刚创建时,里面的操作数栈是空的。

    2.1.2)Java虚拟机提供指令来让操作数栈对一些数据进行入栈操作,比如可以把局部变量表里的数据、实例的字段等数据入栈。

    2.1.3)同时也有指令来支持出栈操作。

    2.1.4)向其他方法传参的参数,也存在操作数栈中。

    2.1.5)其他方法返回的结果,返回时存在操作数栈中。

    2.3)动态链接:

    一个方法调用另一个方法,或者一个类使用另一个类的成员变量时,总得知道被调用是谁?(你可以不认识它本身,但调用它就需要知道他的名字)。符号引用就相当于名字,这些被调用者的名字就存放在Java字节码文件里。

    名字是知道了,但是Java真正运行起来的时候,是不能靠这个名字(符号引用)就能找到相应的类和方法的,需要解析成相应的直接引用,利用直接引用来准确地找到。

    简单点就是:符号引用转换成直接引用的过程

    2.4)方法出口:

    方法在执行完毕之后,要返回到它的调用者。

    返回一个值给调用它的方法,方法正常完成发生在一个方法执行过程中遇到了方法返回的字节码指令的时候,使用哪种返回指令取决于方法返回值的数 据类型(如果有返回值的话)。

    3、本地方法栈

    本地方法栈与虚拟机栈发挥的作用非常类似,它们之间的区别不过是虚拟机栈为虚拟机执行Java方法服务,而本地方法栈为虚拟机使用的Native方法服务。

    与虚拟机栈一样,本地方法栈区域也会抛出StackOverflowError与OutOfMemoryError异常。

    4、Java堆

    Java堆是Java虚拟机所管理的内存中最大的一块区域,Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建,用于存储对象实例,几乎所有的对象实例都在这里分配内存。Java虚拟机规范中描述:所有的对象实例以及数组都要在堆上分配,但是这种方式并不绝对。

    现在大部分收集器都采用分代手机算法,所有Java对中还可以细分为:新生代和老年代;再细致有Eden空间、From Survivor空间、To Survivor空间。

    从内存分配的角度来看,线程共享的Java堆中可能划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer, TLAB),不过无论如何划分,都与存放内容无关,无论哪个区域,存储的都仍然是对象实例,进一步划分的目的是为了更好地回收内存,或者更快的分配内存。

    5、方法区

    方法区与Java堆一样,是各个线程共享的内润区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译后的代码等数据,虽然Java虚拟机规范中把方法区域描述为堆的一个逻辑部分,但是它却是一个别名叫做Non-Heap(非堆),目的应该是Java堆进行区分。

    Java虚拟机规范对方法区的限制非常宽松,除了和Java堆一样的不需要连续内存和可以选择固定大小或者可扩展外,还可以选择不实现垃圾收集,相对而言,垃圾收集行为在这个区域是比较少出现的,但并非数据进入了方法区就如永久代名字一样“永久”存在了,这块区域的内存回收目标主要是针对常量池的回收和对类型卸载,一般来说:这个区域的垃圾回收“成绩”或者性价比比较低。尤其是类型卸载条件相当苛刻。但是这部分回收是有必要。

    6、运行时常量池

    运行时常量池是方法区的一部分,Class文件中除了有类的版本、字段、方法、接口等信息描述外,还有一项信息是常量池,用于存放编译生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。

    运行时常量池相对于Class文件常量池的另一个重要特征是具备动态性,Java语言并不要求常量一定只有编译期才能产生。

    运行时常量池是方法区域的一部分,自然受到方法区内存限制,当常量池无法再申请到内存时会抛出OutOfMemoryError异常。

    7、其他

    直接内存

    该部分并不是虚拟机运行时数据的一部分,也不是Java虚拟机规范中定义的区域,但是这部分内存也被频繁地使用,而且也可能导致OutOfMemoryError异常。

    在JDK1.4中新加入的NIO类,引入了一种基于通道与缓冲区的IO方式,它可以使用Native函数直接分配堆外内存。

    虽然它不会受到Java堆大小的限制,但是既然是内存,肯定还是会受到本机总内存大小以及处理器寻址空间的限制。

    三、对象探秘

    1、对象的创建

    当虚拟机遇到一条new指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行类加载过程(类加载部分不在此处详细叙述)。

    在类加载检查通过后,接下来虚拟机将为新生代对形象分配内存,对象所需要内存的大小在加载完成之后便可完全确定,为对象分配空间的任务等同与把一块确定大小的内存从Java对中划分出来。

    执行new指令之后会接着执行方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。

    2、对象的内存布局

    对象的内存布局可以分为3块区域:对象头,实例数据和对其填充。

    对象头:该部分主要包括两部分信息,第一部分用于存储对象自身的运行时数据,如哈希吗、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳。另外一部分是类型指针,既对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。

    实例数据:该部分是对象真正存储的有效信息,也是程序代码中所定义的各种字段内容。

    对其填充:该部分区域并不是必然存在的,也没有特别的含义,它仅起着占位符的作用。换句话说对象的大小必须是8字节的倍数,而对象头部分正好是8字节的倍数,因此,当对象实例数据部分没有对其时,就需要通过对其填充来补全。

    3、对象的访问定位

    建立对象是为了使用对象,Java程序需要通过栈省的reference数据来操作堆上的具体对象,由于reference类型在Java虚拟机规范中只规定了一个指向对象的引用,并没有定义这个引用应该通过何种方式去定位、访问堆中的对象的具体位置,所以对象访问方式也是取决于虚拟机实现而定的。目前主流的访问方式有使用句柄和直接指针两种。

    通过句柄访问:

    此时Java堆中将会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息。

    通过指针直接访问:

    此时Java堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,而reference中存储的直接就是对象地址。

    这两种方式有各自的优势,使用句柄访问最大的好处就是reference中的存储是稳定的句柄地址,在对象被移动(垃圾收集后整理)时只会改变句柄中的实例数据指针,而reference本身不需要修改。

    使用直接指针访问方式的最大好处就是速度更快,它节省了一次指针定位的时间开销,由于对象的访问在Java中非常频繁,因此这类开销积少成多也是一项非常可观的执行成本。

    四、OutOfMemoryError与StackOverflowError

    在Java虚拟机规范中,除了程序计数器,虚拟机内存的其他几个运行时区域都有发生OutOfMemoryError异常的可能。

    1、Java堆溢出:

    Java堆用于存储对象实例,只要不断的创建对象,并且保证GC Roots到对象之间有可达路径来避免垃圾回收机制清楚这些对象,那么在对象数量到达最大的容量限制后就会产生内存溢出异常。

    2、虚拟机栈和本地方法栈溢出:

    如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出StackOverflowError异常。

    如果虚拟机在扩展栈时无法申请到足够的内存空间,则抛出OutOfMemoryError异常。

    3、创建线程导致内存溢出异常:

    4、方法区和运行时常量池溢出:

    String.intern()是一个Native方法,它的作用是:如果字符串常量池中已经包含一个等于此String对象的字符串,则返回代表池中这个字符串的String对象;否则将此String对象包含的字符串添加到常量池中,并且返回此String对象的引用。

    五、垃圾回收

    垃圾回收并不是Java专享,垃圾收集最早诞生于Lisp语言(1960年),Lisp语言也是第一门真正使用内存动态分配和垃圾收集技术的语言。垃圾收集(GC)主要为我们完成三件事情:

    1)哪些内存需要回收?

    2)什么时候回收?

    3)如何回收?

    1、GC Roots

    在Java堆中存放着几乎所有的实例对象,垃圾收集器对堆区进行回收之前,第一件事情就是要确定这些对象之中哪些还“活着”,哪些已经“死亡”,既不能再被任何途径使用的对象。

    1.1)引用计数算法

    给对象中添加一个引用计数器,每当有一个地方引用该对象时,计数器值加1;引用失效时,计数器值减1;任意时刻计数器为0的对象就是不可能再被使用的,表示该对象不存在引用关系。

    这种方法的特点:

    优点:实现简单,判定效率也很高;

    缺点:难以解决对象之间相互循环引用导致计数器值不等于0的问题。

    1.2)可达性分析算法

    判断是否能够追踪到GC Roots

    在Java语言中可以作为GC Roots的对象主要有:

    1)虚拟机栈(栈帧中的本地变量表)中的引用对象

    2)方法区中类静态属性引用的对象

    3)方法区中常量引用的对象

    4)本地方法栈(JNI)中应用的对象

    1.3)引用类型

    无论是通过引用计数算法判断对象的引用数量,还是通过可达性分析算法判断对象的引用是否可达,判断对象是否存活都与“引用”有关。

    在JDK1.2之后,Java引用类型分为四种:强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phantom Reference)。

    强引用:类似于Object obj = new Object()就是强引用类型,只要强引用类型存在,垃圾收集器永远都不会回收掉被引用的对象。

    软引用:被软引用关联的对象,在系统个将要发生内存溢出异常之前;将会把这些对象列进回收范围之中进行第二次回收。

    弱引用:被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否够使用,都会回收掉只被弱引用关联的对象。

    虚引用:如果一个对象仅存在虚引用,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。

    为对象关联虚引用的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。

    2、垃圾收集算法

    2.1)标记-清除算法

    如同名字一样分为两个步骤:“标记”和“清除”两个阶段,首先标记出所有要回收的对象,在标记完成之后统一回收被标记的对象。

    它的主要问题有:“标记”和“清除”两个阶段效率都不高;另一个问题是:标记-清除之后会产生不连续的内存空间,空间碎片太多可能会导致无法找到一块较大的内存区域以至于在分配大对象无法满足而不得不触发下一次GC。

    2.2)复制算法

    为了解决效率问题,一种“复制”算法就产生了。它将可用内存划分为大小相等的两份,每次只使用其中一份,当这块内存满了,就将还存活的对象复制到另一块上面,然后再一次清理掉使用过的内存空间。

    它解决了内存回收空间碎片的问题,实现简单,运行高效。但是这种算法的代价是将内存缩小为原来的一半,这个代价未免有些高了。

    注意:随着现在商业虚拟机的发展,关于复制算法在空间利用率上有新的划分方式。

    2.3)标记-整理算法

    复制算法在对象存活率较高的时就要进行较多的赋值操作,效率会降低。更关键是如果不想浪费掉50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以老年代一般不采用这种算法。

    故:标记-整理算法,标记过程仍然与“标记-清除”算法一样,但不是直接将可回收对象进行回收,而是让所有存活的对象都向一端移动。

    这种算法解决了“标记-清除”过程中产生的空间碎片。

    2.4)分代收集算法

    无论是一般的JVM还是DVM,不会只使用一种垃圾收集算法。它会根据内存的划分实现不同的收集算法。

    当前商业虚拟机的垃圾收集都采用分代收集算法。分代的垃圾回收策略,是基于不同的对象的生命周期是不一样的。因此,不同生命周期的对象可以采取不同的收集方式,以便提高回收效率。

    现在主流的做法是将Java堆被分为新生代和老年代;

    新生代又被进一步划分为Eden和Survivor区, Survivor由From Space和To Space组成

    这样划分的好处是为了更快的回收内存,根据不同的分代执行不同的回收算法;

    新生代:

    新建的对象都是用新生代分配内存,当Eden满时,会把存活的对象转移到两个Survivor中的一个,当一个Survivor满了的时候会把不满足晋升的对象复制到另一个Survivor。

    晋升的意思是对象每经历一次Minor GC (新生代中的gc),年龄+1,年龄达到设置的一个阀值后,被放入老年代。

    两个Survivor的目的是避免碎片。如果只有一个Survivor,那Survivor被执行一次gc之后,可能对象是A+B+C。经历一次GC后B被回收。则会A| |C,造成碎片

    老年代:

    用于存放新生代中经过N次垃圾回收仍然存活的对象。

    老年代的垃圾回收称为Major GC。整堆包括新生代与老年代的垃圾回收称之为Full GC。

    一般来说,在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,所以一般选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。

    而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用“标记-清理”或“标记-整理”算法来进行回收。

    3、垃圾收集器

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

    垃圾收集器主要有:

    Serial串行收集器

    最基本,历史最悠久的收集器。曾经是jvm新生代的唯一选择。看名字就知道这个收集器是一个单线程的。在进行垃圾收集时,必须暂停其他所有的工作线程,直到收集结束才能继续执行。

    它的缺点也很明显,会‘Stop The World’。也就是会把我们的程序暂停。

    但是单线程也意味着它非常简单高效,没有多余的线程交互,专心收垃圾就可以了。所以在client版本的java中是默认的新生代收集器。

    ParNew 收集器

    Serial收集器的多线程版本,除了使用多线程进行垃圾收集之外。其他的行为和Serial一样。

    ParNew收集器是server版本的虚拟机中首选的新生代收集器。因为除了Serial就他可以和CMS配合。

    Parallel Scavenge收集器

    同样是新生代的收集器,也同样是使用复制算法的,并行的多线程收集器。而它与ParNew等其他收集器差异化的地方在于,它的关注点在控制吞吐量,也就是cpu用于运行用户代码事件于cpu总消耗时间的比值。所以吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间)。虚拟机总共运行100分钟,其中垃圾回收花掉1分钟,则吞吐量为 99/99+1 = 99%。

    而吞吐量越高表示垃圾回收时间占比越小,cpu利用效率越高。

    所以这个收集器也被称为”吞吐量收集器”. 高吞吐量为目标,即减少垃圾收集时间,让用户代码获得更长的运行时间;

    Serial Old收集器

    很重要的一点看名字。老年代版本的串行收集器,使用标记整理算法。

    Parallel Old收集器

    还是看名字。多线程采集,标记整理算法。

    CMS 收集器

    Concurrent Mark Sweep收集器是一种以获得最短回收停顿事件为目标的收集器,也称为并发低停顿收集器或低延迟垃圾收集器;从名字也能看出使用的是标记清除算法

    CMS是一款优秀的收集器,它的主要优点在名字上已经体现出来了:并发收集低停顿

    但是它的缺点在于:

    1)造成CPU资源紧张:

    从图中可以看到会比其他收集器多开线程

    2)无法处理浮动垃圾

     由于CMS并发清理阶段用户线程还在运行着,伴随程序运行自然就还会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS无法在当次收集中处理掉它们,只好留待下一次GC时再清理掉。这一部分垃圾就称为“浮动垃圾”。

    因此CMS收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,需要预留一部分空间提供并发收集时的程序运作使用。

    要是CMS运行期间预留的内存无法满足程序需要,就会出现一次“Concurrent Mode Failure”失败,这时虚拟机将启动后备预案:临时启用Serial Old收集器来重新进行老年代的垃圾收集,这样停顿时间就很长了。

    3)内存回收之后会产生空间碎片

    来源“标记—清除”算法。

    G1收集器

    Garbage-First收集器是当今收集器技术发展最前沿的成果之一,是一款面向服务端应用的垃圾收集器。

    图中可以看到和 CMS差不多,但是G1的采集范围是整个堆(新生代老生代)。他把内存堆分成多个大小相等的独立区域,在最后的筛选回收的时候根据这些区域的回收价值和成本决定是否回收掉内存。

    六、Android内存管理

    众所周知:Android并没有直接采用JVM,这是为什么?

    Java 虚拟机是一个规范,任何实现该规范的虚拟机都可以用来执行 Java 代码。Android就是觉得现在使用的JVM用着不爽(其实主要有两个原因:1、版权问题;2、性能问题,无法满足移动平台需求),由于 Androd 运行在移动设备上,内存以及电量等诸多方面跟一般的 PC 设备都有本质的区别 ,一般的 JVM 没法满足移动设备的要求,所以自己根据这个规范开发了一个Dalvik 虚拟机。

    Android系统的ART和Dalvik虚拟机扮演了常规的内存垃圾自动回收的角色, 使用paging 和 memory-mapping来管理内存,这意味着不管是因为创建对象还是使用使用内存页面造成的任何被修改的内存,都会一直存在于内存中,App唯一释放内存的方法就是释放App持有的对象引用,使GC可以回收。

    内存回收

    Dalvik虚拟机主要使用标记清除算法,也可以选择使用拷贝算法。这取决于编译时期:

    荐:http://androidxref.com/4.4_r1/xref/dalvik/vm/Dvm.mk

    ART 是在 Android 4.4 中引入的一个开发者选项,也是 Android 5.0 及更高版本的默认 Android 运行时。google已不再继续维护和提供 Dalvik 运行时,现在 ART 采用了其字节码格式。ART 有多个不同的 GC 方案,这些方案包括运行不同垃圾回收器。默认方案是 CMS。

    荐: https://source.android.com/devices/tech/dalvik/gc-debug?hl=zh-cn

    七、总结

    关于内存这部分的内容与知识它的信息量是非常大的,而且内容相对抽象不太容易理解。但是我们仍然要去理解和领悟这部分内容,不仅仅是在遇到该部分问题时无从下手,更重要的是提升自我技术能力。

    另外学习是个长期的过程,知识也在不断的累积。本人在学习过程中也深刻领悟到“好记性不如烂笔头”,故将其以博客的形式记录下来。供后续参考以及不断地完善。

    参考《深入理解Java虚拟机》

    荐:https://juejin.im/post/58b18e442f301e0068028a90

    相关文章

      网友评论

        本文标题:Android性能调优(3) —内存管理与垃圾回收

        本文链接:https://www.haomeiwen.com/subject/inxmrftx.html