Android 内存优化(一)

exels-photo-32364

内存(RAM)对于任何一个软件开发环境都是种非常珍贵的资源,而对于移动操作系统来讲的话,则会显得更加珍贵,因为手机的硬件条件相对于 PC 毕竟是比较落后的。在说 Android APP 的内存优化之前,必须先了解 Java 的内存管理机制,以及在此基础上 Android 是如何对内存进行管理的。


Java 内存管理

在内存管理方面,与 C、C++ 手动管理内存相比,Java 拥有自动管理内存机制,从而帮助程序员提高编码效率,同时不容易出现内存泄漏和内存溢出问题。Java 的自动管理内存机制就是其拥有垃圾收集机制(Garbage Collection,简称 GC),能自动清理不需要的实例对象,回收内存空间。不过,也正是因为如此,一旦出现内存泄漏或溢出方面的问题,如果不理解虚拟机的内存管理机制,那么将很难排查问题。

说一个概念,Java虚拟机(JVM), 可以简单理解为一种技术思想,虚拟技术理念,而 JVM具体的实现,则存在很多。

我们平时开发,查看 Java 版本时,会出现这些:

1
2
3
4
➜ ~ java -version
java version "1.8.0_121"
Java(TM) SE Runtime Environment (build 1.8.0_121-b13)
Java HotSpot(TM) 64-Bit Server VM (build 25.121-b13, mixed mode)

注意最后一行,「HotSpot」 ,HotSpot VM 是 JVM 的一种实现, 包含了服务器版和桌面应用程序版, 现时由Oracle维护并发布。所有接下来的探究,大部分都是基于这个 「HotSpot」。


运行时数据区域

首先说一下 Java 的内存分配,Java 虚拟机在执行 Java 程序的过程中,会把它所管理的内存划分为若干个不同的数据区域。这些区域都有各自的用途,以及创建和销毁的时间。

VM 运行时数据

  • 方法区 ( Method Area ):方法区存放的是被虚拟机加载的类信息、常量、静态变量、即使编译器编译后的代码等,运行时常量池也在该区域,所有线程共享区域。虽然Java 虚拟机规范把方法区描述为的一个逻辑部分,但是它却有一个别名叫做Non-Heap(非堆)
  • 虚拟机栈 ( VM Stack ):每个方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息,线程私有区域。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。
  • 本地方法栈 ( Native Method Stack):与虚拟机栈类似,区别是虚拟机栈为虚拟机执行Java方法服务,本地方法栈为虚拟机使用到的Native方法服务。
  • 堆 ( Heap):JVM管理的内存中最大的一块,所有线程共享;用来存放对象实例,几乎所有的对象实例都在堆上分配内存;此区域也是垃圾收集机制(Garbage Collection)主要的作用区域,内存泄漏就发生在这个区域。
  • 程序计数器 ( Program Counter Register):可看做是当前线程所执行的字节码的行号指示器,各线程拥有各自独立的程序计数器。如果线程在执行Java方法,这个计数器记录的是正在执行的虚拟机字节码指令地址;如果执行的是Native方法,这个计数器的值为空(Undefined)。此内存区域是唯一一个在 Java 虚拟机规范中没有规定任何内存溢出(OutOfMemoryError) 情况的区域。

以上,就是 JVM 内存的分配情况,而 Java 的 GC 主要的作用区域就是 堆(Heap)


对象存活的依据

在堆里面存放着 Java 中几乎所有的对象实例,GC 在对堆进行回收前,首先要确定的是这些对象中,哪些还「存活」,哪些已经「死去」(即不可能再被任何途径使用的对象)。

这里存在两种算法:引用计数算法可达性分析算法


引用计数算法

引用计数算法(Reference Counting)就是给对象添加一个引用计算器,每当有一个地方引用它时,计算器值就加1;当引用失效是,计数器值就减1;任何时刻计数器为 0 的对象就是不可能再被使用的。

引用计数算法的实现简单,判定效率也很高,但它很难解决对象之间相互循环引用的问题,因此,主流的 Java 虚拟机并没有选择引用计数算法来管理内存,而是采用可达性分析算法


可达性分析算法

可达性分析算法 ( Reachability ) 的基本思路就是通过一系列的称为 「GC Roots」的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到 GC Roots 没有任何引用链相连时,则证明此对象是不可用的,所有它们将会被判定为是可回收的对象。

GC Roots

在 Java 语言中,可作为 GC Roots 的对象包括以下几种:

  • 虚拟机栈中引用的对象
  • 方法区中类静态属性引用的对象
  • 方法区中常量引用的对象
  • 本地方法栈中 JNI 引用的对象

这也是 HotSpot 使用的判定对象存活的算法。

现在,我们知道哪些对象应该被回收,接下来再聊一聊具体如何回收,以及回收所使用用的算法。


分代收集算法

由于现在的 GC 基本都采用 分代收集 的算法(即:根据对象的存活周期的不同将内存划分为几块,根据各区域的特点采用最适当的收集算法),所以存在一个与之对应的 Generational Heap Memory 内存模型

eneration Heap Memor

Generational Heap Memory 模型将内存划分成三个主要区域,其中 JVM 堆分为新生代和老年代:

  • Young Generation 新生代
    • 一般 new 对象会先存放在此,( 注意:大对象会直接进入老年代 )
    • 该区域的内存管理使用 Minor GC ( 小GC )
    • 更进一步分成Eden、From Survivor 和 To Survivor 三个部分
  • Old Generation 老年代
    • 新生代中执行小粒度的 GC 存活下来的「老」对象。
    • 该区域的内存管理使用 Major GC ( 大GC )
  • Permanent Generation 持久代
    • 持久代就是方法区,主要存放的是 Java 类的类信息。
    • HotSpot 虚拟机的设计团队选择把GC 分代收集扩展至方法区,或者说使用持久代来实现方法区而已。对于其他虚拟机(如BEA JRockit、IBM J9 等)来说是不存在持久代的概念的。

这三个区域存在明显的层级关系,所以此模型也可以成为三级Generation的内存模型

在三级Generation内存模型中,每一个区域的大小都是有固定值的,当进入的对象总大小到达某一级内存区域阀值的时候就会触发GC机制,进行垃圾回收,腾出空间以便其他对象进入。

小GC 执行非常频繁, 而且速度特别快;
大GC 一般会比 小GC 慢十倍以上。


复制收集算法

Young Generation 新生代中,存放的是最近被新创建对象,此区域最大的特点就是创建的快,被销毁的也很快,每次垃圾收集时都会发现有大量对象死去,只有少量存活,因此选用效率较高的 复制(Copying)的收集算法。它的原理就是,将内存按容量划分为大小相等的两块,每次只使用其中的一块,当这一块内存将用完了,就将还存活着的对象复制到另一块内存上面,然后再把已使用过的内存空间一次清理掉。

opyin

具体的流程如下:

  1. 每当我们使用 new 创建一个对象时, 这个对象会被分配到 新生代Eden 区域(大对象除外)

    new object

  2. 当Eden区域内存被分配完时, 小GC 程序被触发小 GC

    引用可达的对象 会移到 Survivor(幸存者)区域–S0, 然后清空Eden区域, 此时引用不可达的对象会直接删除, 内存回收。

    51999-3ccdda6d0fae500

  3. 当Eden区域再次分配完后, 小GC执行, 引用可达的对象 会移到 Survivor(幸存者)区域,而引用不可达的对象会跟随Eden的清空而删除回收。

    需要注意的是, 这次 引用可达的对象 移动到的是 S1 的幸存者区.
    而且, S0区域也会执行小GC, 将其中还引用可达的对象移动到S1区, 且年龄+1. 然后清空S0, 回收其中引用不可达的对象。此时, 所有引用可达的对象都在S1区, 且S1区的对象存在不同的年龄。

    51999-bc737169e99d3d5

    当Eden第三次满时, S0和S1的角色互换了,依此循环。

    51999-aca606170dba22b


标记—整理收集算法

Old Generation 老年代中,因为对象存活率高、没有额外空间对它进行分配担保,因此使用标记—整理算法(Mark-Compact) 来进行回收。「标记—整理算法」是基于「标记—清除算法」升级而来的,先说一下标记—清除算法(Mark-Sweep),如同它的名字一样,算法分为「标记」和「清除」两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。

记—清除算

标记—整理算法 的标记过程仍然与 标记—清理算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。

记—整理算法

这种方法既避免了碎片的产生,又不需要两块相同的内存空间,因此,其性价比比较高。

新生代对象会很快被回收,使用复制算法,空间换时间。
老年代对象存活更久,使用标记—整理算法,时间换空间。


Android 内存管理

由于 Android 是建立在 Linux 系统之上的,所以 Android 系统继承了 Linux 的进程隔离机制与最小权限原则,并且在原有 Linux 的进程管理基础上对 UID 的使用做了改进,形成了 Android 应用的「沙盒」机制。

在 Android 系统中,应用(通常)都在一个独立的沙箱中运行,即每一个 Android 应用程序都在它自己的进程中运行,都拥有一个独立的 Dalvik 虚拟机实例。Dalvik 经过优化,允许在有限的内存中同时高效地运行多个虚拟机的实例,并且每一个 Dalvik 应用作为一个独立的 Linux 进程执行。

普通的 Linux 中启动的应用通常和登陆用户相关联,同一用户的 UID 相同。但是 Android 中给不同的应用都赋予了不同的 UID,这样不同的应用将不能相互访问资源。对应用而言,这样会更加封闭,安全。

简单点说就是在 Android 中每一个应用相当与一个 Linux 中的用户, 默认情况下运行在一个独立进程中的,而这个独立进程正是从 Zygote 孵化出来的 VM 进程,也就是说,每个 App 是运行在独立的 VM 空间的。


App 的内存分配

对于每个 App 进程来说,堆 (Heap)内存被限制在一个虚拟的内存区间内,且定义了逻辑上使用的 「Heap Size」, 这个 「Heap Size」 在系统限制的最大值之内,是随着应用的使用情况而变化的。

注意,Heap 内存的逻辑大小和实际物理内存的大小是不相同的。我们在使用 Memory Monitor 等内存分析工具分析内存时,会看到一个叫做 Proportional Set Size (PSS) 的值,这个值才是系统认为的你的 App 所占用的物理内存大小,也就是实际物理内存大小,统计包括了你的应用进程所占用的内存大小,以及共享内存中占用的内存大小(比例分配方式计算)。

Android 是一个多任务系统,为了保证多任务的运行,Android 给每个 App 可使用的堆 (Heap)大小设定了一个限定值。这个值是系统设置的prop值,系统编译时内置的,保存在system/build.prop中。一般国内的手机厂商都会做修改,根据手机配置不同而不同。

Android 在 4.4 之前一直使用的 Dalvik 虚拟机作为 App 的运行 VM 的,4.4 中引入了 ART 作为开发者备选,5.0起正式将ART作为默认VM了。

ART 和 Dalvik 都是使用 分页paging内存映射 memory-mapping(mmapping) 来管理内存的。 这就意味着,任何被分配的内存都会持续存在,唯一的释放这块内存的方式就是释放对象引用,让 GC 程序来回收内存。


GC 的监听

GC 操作主要是由系统决定的,但是我们可以监听系统的 GC 过程,以此来分析我们应用程序当前的内存状态。

以下分别是 dalvikvm 虚拟机 和 ART 虚拟机在 GC 时打印的日志:

1
2
3
D/dalvikvm: <GC_Reason> <Amount_freed>, <Heap_stats>, <External_memory_stats>,<Pause_time>
I/art:<GC_Reason>,<Amount_freed>,<LOS_Space_Status>,<Heap_stats>,<Pause_time>,<Total_time>
  • GC Reason:GC触发原因
    • GC_CONCURRENT:当已分配内存达到某一值时,触发并发GC;
    • GC_FOR_MALLOC:当尝试在堆上分配内存不足时触发的GC;系统必须停止应用程序并回收内存;
    • GC_HPROF_DUMP_HEAP: 当需要创建HPROF文件来分析堆内存时触发的GC;
    • GC_EXPLICIT:当明确的调用GC时,例如调用System.gc()或者通过DDMS工具显式地告诉系统进行GC操作等;
    • GC_EXTERNAL_ALLOC: 仅在API级别为10或者更低时(新版本分配内存都在Dalvik堆上)**
  • Amount freed GC:回收的内存大小
  • Heap stats:堆上的空闲内存百分比 (已用内存)/(堆上总内存)
  • External memory stats: API级别为10或者更低:(已分配的内存量)/ (即将发生垃圾的极限)
  • Pause time:这次GC操作导致应用程序暂停的时间。关于这个暂停的时间,在2.3之前GC操作是不能并发进行的,也就是系统正在进行GC,那么应用程序就只能阻塞住等待GC结束。而自2.3之后,GC操作改成了并发的方式进行,就是说GC的过程中不会影响到应用程序的正常运行,但是在GC操作的开始和结束的时候会短暂阻塞一段时间。

ART 的日志与dalvikvm 相比,多了LOS_Space_Status,LOS 是 Large Object Space,大对象占用的空间,这部分内存并不是分配在堆上的,但仍属于应用程序内存空间,主要用来管理 Bitmap 等占内存大的对象,避免因分配大内存导致堆频繁 GC。

Android 在 2.3 的版本当中进行过一次优化,在 2.3 之前 GC 操作是不能并发进行的,也就是系统正在进行 GC,那么应用程序就只能阻塞住,等待 GC 结束。虽说这个阻塞的过程并不会很长,也就是几百毫秒,但是用户在使用我们的程序时还是有可能会感觉到略微的卡顿。

而自 2.3 之后,GC 操作改成了并发的方式进行,就是说 GC 的过程中不会影响到应用程序的正常运行,但是在GC操作的开始和结束的时候会短暂阻塞一段时间,优化到这种程度,用户已经是完全无法察觉到了。


在了解 Android 内存管理后,后续的文章,将根据 Android 实际场景,再结合一些工具来优化内存。


参考

Android GC 那点事

Android App优化之内存优化(序)

Android性能优化(三)之内存管理

【Android 性能优化】—— 详解内存优化的来龙去脉

Android最佳性能实践(一)——合理管理内存

内存泄露从入门到精通三部曲之基础知识篇

内存泄露从入门到精通三部曲之排查方法篇


坚持分享技术,但行好事,莫问前程 ~^o^~