Android应用优化技术(1501210910 胡煌)
摘要
内存在任何软件开发环境中都是一种非常有价值的资源,尤其当在移动设备的物理内存收到限制时,内存资源变得更加有价值。
尽管Android的Dalvik虚拟机会定期进行垃圾回收,但是这并不意味着你的应用可以忽略对内存的分配和回收。为了方便垃圾收集器回收应用所占的内存,你应该在程序中避免内存泄露的问题,以及在合适的时候释放对象。对于大多数应用而言,Dalvik虚拟机的垃圾收集器接管后者,当相关对象在应用程序当前活动线程之外的作用域时,系统会回收你分配的内存。这篇文章将会解释Android应用如何管理进程和内存分配,以及如何在Android开发的时候减少内存使用。
关键字
Android;应用内存;内存管理;优化技术;
1. Android内存管理机制
想要进行Android应用的内存优化,那就必须先了解Android进行内存管理的机制。Android内存中并没有交换区,但确实使用分页式技术和页表机制来管理内存。这意味着任何你修改过的内存,无论是通过分配新对象或者访问页表,都会在内存保存持久的对象。因此,应用程序唯一能够完全释放内存的方式是在程序中释放对象的引用,使得这块区域能被垃圾收集器回收。但是有一个例外,任何映射过的文件,在没有修改时,例如代码,如果系统要在其他地方使用内存的话,这些文件能被换出内存的页表。
首先我们来看一下Android的应用程序是如何在Dalvik虚拟机上运行的。为了使得Android应用程序能够安全且快速地运行,Android中每一个应用程序都有一个专门的Dalvik虚拟机实例来运行,每一个应用程序都运行在自己的进程中,它由Zygote服务进程孵化出来,当系统启动和加载通用框架代码和资源时,Zygote服务进程就启动。而Android为不同类型的进程分配了不同的内存使用上限,如果程序在运行过程中出现了内存泄露,导致应用程序使用的内存超过设定的上限值,这时候系统就会将应用程序视为内存泄露,从而进程被系统kill,但是仅仅是该应用程序的进程被kill,并不会影响其他进程。
由于Android应用由Java语言编写,所以Android的内存管理与Java的内存管理十分相似。在Java中所有通过new来分配的对象,都存储在Java堆空间中。而对象的释放则由垃圾回收器来完成。在C/C++中的内存管理机制是“谁污染,谁治理”,但在Java中内存回收的工作交给垃圾回收器GC来处理,GC回收的机制采用有向图原理。Java将引用关系考虑为图的有向边,有向边从引用者指向引用对象。线程对象可以作为有向图的起始顶点,该图就是从起始顶点开始的一棵树,根顶点可以到达的对象都是有效对象,GC不会回收这些对象。如果某个对象与根节点之间不可达,那么我们就认为这个对象不再被引用了,可以被GC进行回收。
Android共享内存
在前面已经提到了,每一个应用程序的进程都从Zygote服务进程中孵化出来,并且在Android系统启动加载通用框架代码和资源时,Zygote服务进程会启动。为了开启一个新的应用程序进程,系统会孵化Zygote在新的进程中加载和运行应用程序的代码。这个过程中,大多数分配给通用框架代码和资源的内存页表能在所有应用程序的进程中共享。
大多数静态数据都被映射到一个进程中,这不仅使得相同的数据能在进程之间共享,也能允许当需要的时候被换出。静态数据包括:Dalvik虚拟机代码(通过放在预链接的代码文件即为.odex文件,作为直接映射),应用程序的资源(通过设计资源表是可以被映射的结构和对准APK的拉链条目),以及传统的项目元素如.so这样的静态链接库文件。
在许多场景下,Android在进程之间通过显示地分配共享内存区域的方法,来共享相同的动态内存(要么是采用ashem或者gralloc)。例如,在窗口渲染在应用和屏幕排版之间使用共享内存,光标缓冲区在content provider之间使用共享内存。
由于共享内存的其他使用方法,决定了你的应用程序要消耗多大的内存,这是一个值得关注的问题。这里有几点关于Android如何给应用分配和回收内存,你需要知道。
- alvik虚拟机的heap区是限制在一个单一的虚拟内存范围内。这使得逻辑heap区能随着需要的增长(上限是系统给每个应用程序定义的最大内存)
- heap区的逻辑大小与heap区实际使用的物理内存的大小并不一样,当Android在监控应用的heap区时,Android会计算出一个叫做PSS值,它能说明脏页表和空白页表能够在进程之间共享。
- Dalvik虚拟机heap区并不会压缩heap区的实际大小,这意味着Android不会整理heap区来关闭空间。当在heap的尾部区域存在没有使用的空间时,Android只能缩减heap区的逻辑大小。但是这并不意味着heap区所使用的物理内存不能缩小。在进行垃圾回收之后,Dalvik虚拟机会扫描heap区,寻找没有使用的内存页表,并将这些没有使用的内存页表返回给内核。因此,对大的内存块进行成对的分配和回收会导致回收所有在使用的物理内存。然而从比较小的内存区域回收内存,会变得效率很低,原因在于分配给小区域的页表可能还在被其他没有被释放的对象共享。
控制应用的内存使用
针对上面这些问题,在开发工作中需要对应用程序使用的内存进行严格地控制。为了维护一个功能化的多任务环境,Android给每个应用程序在heap区的大小进行了严格地限制。确切的heap区小大的值取决于你的设备有多大的物理内存可以使用。如果你的应用程序达到了heap容量的上限,并且还在继续尝试分配更多的内存空间,这时会出现OutOfMemoryError错误。在某些场合下,你会先查询系统当前有多大的heap区域能够使用,例如,判断多大的数据保存在缓存中是安全的,这时你可以通过调用getMemoryClass()方法查询系统,这个方法会返回一个整数,代表你的应用程序能够申请到的大小。
当用户在Android应用之间进行切换时,并不是使用交换区的方法,而是将进入后台的应用进程放到一个LRU的缓存中。例如,当用户第一次启动应用程序时,系统会为它创建一个进程,但是当用户离开这个应用程序时,进程并不会被kill掉,系统会将它保存在缓存区,如果当用户下一次回到这个应用程序的时候,进程会被重新唤起,这样在切换应用程序会更快。
如果你的应用程序有一个在缓存中的进程,并且它会在内存中保留当前不需要的内存。即使用户当前并没有使用它。这限制了系统的整体性能。因此,当系统的可用内存较小时,会销毁LRU缓存中的进程,也会考虑哪一个进程对内存的使用量最大。为了尽可能久地在缓存中保留进程,你可以参考接下来的几点关于什么时候释放内存的建议。
应用程序如何进行优化
你应该在整个开发阶段考虑到内存的限制,包括在应用设计阶段。下面有些在程序开发过程中必备的几点常用经验。
(1)避免创建不必要的对象。就像世界上没有免费的午餐,世界上也没有免费的对象。虽然gc为每个线程都建立了临时对象池,可以使创建对象的代价变得小一些,但是分配内存永远都比不分配内存的代价大。如果你在用户界面循环中分配对象内存,就会引发周期性的垃圾回收,用户就会觉得界面像打嗝一样一顿一顿的。所以,除非必要,应尽量避免尽力对象的实例。下面的例子将帮助你理解这条原则:当你从用户输入的数据中截取一段字符串时,尽量使用substring函数取得原始数据的一个子串,而不是为子串另外建立一份拷贝。这样你就有一 个新的String对象,它与原始数据共享一个char数组。 如果你有一个函数返回一个String对象,而你确切的知道这个字符串会被附加到一个StringBuffer,那么,请改变这个函数的参数和实现方式, 直接把结果附加到StringBuffer中,而不要再建立一个短命的临时对象。
一个更极端的例子是,把多维数组分成多个一维数组:
int数组比Integer数组好,这也概括了一个基本事实,两个平行的int数组比 (int,int)对象数组性能要好很多。同理,这试用于所有基本类型的组合。如果你想用一种容器存储(Foo,Bar)元组,尝试使用两个单独的 Foo[]数组和Bar[]数组,一定比(Foo,Bar)数组效率更高。(也有例外的情况,就是当你建立一个API,让别人调用它的时候。这时候你要注重对API接口的设计而牺牲一点儿速度。当然在API的内部,你仍要尽可能的提高代码的效率)
总体来说,就是避免创建短命的临时对象。减少对象的创建就能减少垃圾收集,进而减少对用户体验的影响。
(2)对常量使用static final修饰符。让我们来看看这两段在类前面的声明:
static int intVal = 42;
static String strVal = "Hello, world!";
现在,类不再需要clinit方法,因为在成员变量初始化的时候,会将常量直接保存到类文件中。用到intVal的代码被直接替换成42,而使用strVal的会指向一个字符串常量,而不是使用成员变量。
将一个方法或类声明为final不会带来性能的提升,但是会帮助编译器优化代码。举例说,如果编译器知道一个getter方法不会被重载,那么编译器会对其采用内联调用。你也可以将本地变量声明为final,同样,这也不会带来性能的提升。使用“final”只能使本地变量看起来更清晰些(但是也有些时候这是必须的,比如在使用匿名内部类的时候)。
(3)合理利用本地方法。本地方法并不是一定比Java高效。最起码,Java和native之间过渡的关联是有消耗的,而JIT并不能对此进行优化。当你分配本地资源时 (本地堆上的内存,文件说明符等),往往很难实时的回收这些资源。同时你也需要在各种结构中编译你的代码(而非依赖JIT)。甚至可能需要针对相同的架构 来编译出不同的版本:针对ARM处理器的GI编译的本地代码,并不能充分利用Nexus One上的ARM,而针对Nexus One上ARM编译的本地代码不能在G1的ARM上运行。当你想部署程序到存在本地代码库的Android平台上时,本地代码才显得尤为有用,而并非为了Java应用程序的提速。
(4)多线程解决复杂计算。占用CPU较多的数据操作尽可能放在一个单独的线程中进行,通过handler等方式把执行的结果交于UI线程显示。特别是针对的网络访问,数据库查询,和复杂的算法。目前Android提供了AsyncTask,Hanlder、Message和Thread的组合。对于多线程的处理,如果并发的线程很多,同时有频繁的创建和释放,可以通过concurrent类的线程池解决线程创建的效率瓶颈。另外值得注意的是,应用开发中自定义View的时候,交互部分,千万不要写成线程不断刷新界面显示,而是根据TouchListener事件主动触发界面的更新。
(5)使用线程池。线程池,分为核心线程池和普通线程池,下载图片等耗时任务放置在普通线程池,避免耗时任务阻塞线程池后,导致所有异步任务都必须等待。
(6)选择合适的数据格式传输形式。其中Tree Parse 是DOM解析 Event/Stream是SAX方式解析。很明显,使用流的方式解析效率要高一些,因为DOM解析是在对整个文档读取完后,再根据节点层次等再组织起来。而流的方式是边读取数据边解析,数据读取完后,解析也就完毕了。在数据格式方面,JSON和Protobuf效率明显比XML好很多,XML和JSON大家都很熟悉。从上面的图中可以得出结论就是尽量使用SAX等边读取边解析的方式来解析数据,针对移动设备,最好能使用JSON之类的轻量级数据格式为佳。
参考文献
http://developer.android.com/training/articles/memory.html#YourApp http://www.cnblogs.com/zyw-205520/archive/2013/02/17/2914190.html