APP性能-内存优化-内存管理认知
前言
作为一名Java程序员,我们不需要像C/C++那样为每一个new出来的对象手动delete/free释放内存。因为有GC(垃圾回收器)的自动回收机制会帮我们自动处理。正因为我们把这些操作交给了JVM,所以如果出现内存溢出和内存泄漏的情况,如果对JVM不熟悉,往往会很难找出问题所在,进而解决问题。所以要对内存使用进行优化,必须先熟悉Java的内存机制。
1.了解Java的内存管理
我们都知道android的App是由Java编写的,其实APP也是运行在一个虚拟机上的,所以这里就不得不提Java的内存管理。
根据《Java虚拟机规范(第2版)》的规定,Java虚拟机所管理的内存将会包括以下几个运行时数据区域,如下图所示:
程序计数器:一块较小内存区域,指向当前所执行的字节码。如果线程正在执行一个Java方法,这个计数器记录正在执行的虚拟机字节码指令的地址,如果执行的是Native方法,这个计算器值为空。
Java虚拟机栈:线程私有的,其生命周期和线程一致,每个方法执行时都会创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法出口等信息。
本地方法栈:与虚拟机栈功能类似,只不过虚拟机栈为虚拟机执行Java方法服务,而本地方法栈则为使用到的Native方法服务。
Java堆:是虚拟机管理内存中最大的一块,被所有线程共享,该区域用于存放对象实例,几乎所有的对象都在该区域分配。Java堆是内存回收的主要区域,因此也是内存泄漏发生的区域
方法区:与Java一样,是各个线程所共享的,用于存储已被虚拟机加载类信息、常亮、静态变量、即时编译器编译后的代码等数据。
运行时常量池,运行时常量池是方法区的一部分,Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池,用于存放编译期生成的各种字面量和符号引用。
1.1通过句柄访问对象
1.2 通过指针访问对象
通过上面两个例子就基本对Java的内存管理和访问使用有了个大概了解,有兴趣可以更深入了解内存管理
2.GC垃圾回收器
既然Java的内存释放交给了GC,那么我们就有必须要了解下GC的原理和影响
NOTE:Android的GC和Java的GC有一些区别,以后有机会单独把Android的GC单独拿出来汇总
2.1 GC的影响
- 内存泄露
- 程序暂停
- 程序吞吐量显著下降
- 响应时间变慢
2.2 对象存活判断
GC需要先判断对象是否存活,才能决定是否回收。判断对象是否存活有两种方式
引用计数:每个对象有一个引用计数属性,新增一个引用时计数加1,引用释放时计数减1,计数为0时可以回收。此方法简单,无法解决对象相互循环引用的问题。
可达性分析(Reachability Analysis):从GC Roots开始向下搜索,搜索所走过的路径称为引用链。当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的。不可达对象。
在Java语言中,GC Roots包括:
虚拟机栈中引用的对象。
方法区中类静态属性实体引用的对象。
方法区中常量引用的对象。
本地方法栈中JNI引用的对象
下图是GC Roots
2.3 垃圾收集算法
2.3.1 标记 -清除算法
“标记-清除”(Mark-Sweep)算法,如它的名字一样,算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收掉所有被标记的对象。之所以说它是最基础的收集算法,是因为后续的收集算法都是基于这种思路并对其缺点进行改进而得到的。
它的主要缺点有两个:一个是效率问题,标记和清除过程的效率都不高;另外一个是空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致,当程序在以后的运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
2.3.2 复制算法
“复制”(Copying)的收集算法,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。
这样使得每次都是对其中的一块进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。只是这种算法的代价是将内存缩小为原来的一半,持续复制长生存期的对象则导致效率降低。
2.3.3 标记-整理算法
复制收集算法在对象存活率较高时就要执行较多的复制操作,效率将会变低。更关键的是,如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选用这种算法。
根据老年代的特点,有人提出了另外一种“标记-整理”(Mark-Compact)算法,标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存
2.3.4 分代收集算法
GC分代的基本假设:绝大部分对象的生命周期都非常短暂,存活时间短。
“分代收集”(Generational Collection)算法,把Java堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用“标记-清理”或“标记-整理”算法来进行回收。
需要了解更多关于:GC算法
3.Android的内存管理
Android的ART和DVM都是使用paging和memory-paging来管理内存。这意味着一个App能修改的任何内存(不管是用来分配对象还是用来映射页)都只能常驻在RAM,而不能被移除。因此从app中彻底释放内存的唯一方法是,销毁持有的对象的引用,从而使被占用的内存空间可以被GC收集。这就产生了潜在的异常:当系统内存吃紧时,任何被映射的却没有修改的文件,例如代码,都可能被移除RAM(销毁)。
3.1 安卓的GC
托管内存环境,如ART或Dalvik虚拟机,可以跟踪每个内存分配。一旦确定一块内存不再被程序使用,它就可以将其释放回堆,而不需要程序员的任何干预。在托管内存环境中回收未使用内存的机制称为垃圾回收。垃圾收集有两个目标:在将来无法访问的程序中查找数据对象;并回收这些对象使用的资源。
Android的内存堆是一代的,这意味着根据正在分配的对象的预期使用寿命和大小,它可以跟踪不同的分配桶。例如,最近分配的对象属于新生代。当一个对象保持活跃的时间足够长时,它可以升格为老生代,然后就是永久代了。
每个堆生成都有自己的专用上限,即可以占用对象的内存量上限。任何时候一代人开始填满,系统执行一个垃圾收集事件,试图释放内存。垃圾收集的持续时间取决于它收集的对象是哪个代际(比如新生代,老生代,永久代)以及每一代中有多少活动对象。
即使这样GC速度相当快,仍然会影响应用的性能。你通常不会控制GC事件何时发生在你的代码内。系统具有运行的一套标准,用于确定何时执行GC。当满足标准时,系统停止执行进程并开始GC。如果GC发生在密集处理循环的中间,如动画或音乐播放,会增加处理时间。这种增加可能会潜在地推动应用程序中的代码执行超过建议的16ms阈值,以实现高效和平滑的帧渲染。
此外,代码流可能会执行各种各样的工作,强制GC事件更频繁地发生或使其持续时间超过正常。例如,如果在Alpha混合动画的每个帧期间在for循环的最内层代码分配多个对象,则可能会有大量的污染对象会在内存堆。在这种情况下,GC会执行多个GC事件,并降低应用程序的性能
3.2 共享内存
为了适配RAM,安卓系统通过进程来共享内存页
Android应用的进程都是从一个叫做Zygote的进程fork出来的。Zygote进程在系统启动并且载入通用的framework的代码与资源之后开始启动。为了启动一个新的程序进程,系统会fork Zygote进程生成一个新的进程,然后在新的进程中加载并运行应用程序的代码。这使得大多数的RAM pages被用来分配给framework的代码,同时使得RAM资源能够在应用的所有进程之间进行共享。
大多数static的数据被mmapped到一个进程中。这不仅仅使得同样的数据能够在进程间进行共享,而且使得它能够在需要的时候被paged out。常见的static数据包括Dalvik Code,app resources,so文件等。
大多数情况下,Android通过显式的分配共享内存区域(例如ashmem或者gralloc)来实现动态RAM区域能够在不同进程之间进行共享的机制。例如,Window Surface在App与Screen Compositor之间使用共享的内存,Cursor Buffers在Content Provider与Clients之间共享内存。
由于广泛使用共享内存,确定应用程序使用多少内存需要注意。正确确定应用程序内存使用的技巧见:Investigating Your RAM Usage
3.3 分配与回收内存
- 每个进程的Dalvik堆栈被限制在一个虚拟内存范围内。这就定义了逻辑堆栈的大小,它可以根据需要自增长(但只能增长到系统为每个应用定义的空间上限)。
- 堆栈的逻辑大小与堆栈使用的物理内存数量是不一样的。当检查应用的堆栈时,安卓会计算一个称为Proportional Set Size(PSS)的值,它用来解释被其他进程共享的脏和干净页——但也仅仅是按照有多少应用共享RAM,并按比例分摊得来的。这个PSS值的总大小才被系统认为是物理内存的大小。更多关于PSS的信息,Investigating Your RAM Usage
- Dalvik 堆栈不会整理堆栈的逻辑大小,这意味着安卓不会通过整理堆栈碎片回收空间。只有在堆栈的尾部有没有使用的空间时,安卓才会压缩逻辑堆的大小。但这并不意味着堆栈使用的物理内存不能被压缩。在GC工作过后,Dalvik 会遍历整个堆栈,找到无用的页,通过madvise函数把它们返回给内核。因此成对儿申请和回收大块内存会导致所有使用过的物理内存被回收。然而,回收小块内存可能效率更低,因为小块内存使用的页可能仍被还没释放的对象占用,进而导致该页无法被回收。
3.4 限制应用的内存
为了保持多任务处理的工作环境,安卓系统对每个应用的堆栈大小都有严格的限制。这个数值随着设备变化而变化,具体要看设备可用的RAM空间有多少。一旦你的应用占用的内存达到了堆栈上限,却仍试图申请更多内存,系统就会报OOM的错误提示。
在某些时候,你可能会想查询系统,以便准确地知道当前设备到底有多大的可用空间。例如,确定缓存多大的数据比较安全。你可以通过调用getMemoryClass()
方法得到一个以兆字节为单位的整型数字,表示你的应用可用的堆栈大小。
3.5 应用切换
1.当用户在App之间切换时,Android会保留在LRU缓存中的包括:不是前台的App和正在进行的前台服务比如音乐播放。例如,当用户首次启动App时,会为其创建一个进程;但是当用户离开App时,该进程不会退出。系统保持进程缓存。如果用户稍后返回到App,系统重新使用该过程,从而使应用切换更快。
2.如果您的App具有缓存进程,并且保留了当前不需要的内存,那么即使在用户未使用的情况下,您的应用也会影响系统的整体性能。当系统内存不足时,它会杀死保存在LRU缓存中的进程。系统还会计算使用最多内存的进程,以决定终止这些进程来释放RAM。
注意:当系统开始杀死LRU中的进程时,它主要从下往上工作。该系统还考虑哪些进程消耗更多的内存,从而在被杀掉之后为系统提供更多的内存。总体来说,在LRU列表中消耗的内存越少,进程有更多机会保留在LRU,以能够快速恢复。
这里可以看下安卓的5中进程:
处于low priority的进程很容易被杀掉,了解更多的processes-and-threads
4.调查RAM的使用情况
篇幅有限,更多查看:调查RAM使用情况