管理应用的内存可以分为两个部分内容:
1. 首先需要理解:How Android Manages App Processes and Memory Allocation?
2. 其次需要考虑:我们设计的应用如何管理内存? How Your App Should Manage Memory?
Random-access memory (RAM) is a valuable resource in any software development environment, but it's even more valuable on a mobile operating system where physical memory is often constrained. 移动设备的特点在于:物理内存容量有限。尽管Android的Dalvik虚拟机扮演了常规的垃圾回收(GC)的角色,但这并不意味着你可以忽视app的内存分配与释放的时机与地点。
为了GC能够从app中及时回收内存,我们需要注意避免内存泄露(通常由于在全局成员变量中持有对象引用而导致)并且在适当的时机(下面会讲到的lifecycle callbacks)来释放引用对象。对于大多数app来说,Dalvik的GC会自动把离开活动线程的对象进行回收。
主题一:Android操作系统如何管理内存分配和回收?
Android does not offer swap space for memory, but it does use paging and memory-mapping (mmapping) to manage memory. Android并没有为内存提供交换区(Swap Space),但是它有使用 Paging 与 Memory-Mapping(mmapping) 的机制来管理内存。这意味着任何你修改的内存(无论是分配新的对象还是去访问mmapping pages中的内容)都会贮存在RAM中,而且不能被paged out。因此唯一完整释放内存的方法是释放那些你可能hold住的对象的引用,当这个对象没有被任何其他对象所引用的时候,它就能够被GC回收了。只有一种例外是:如果系统想要在其他地方重用这个对象,这种情况下,系统中的其他引用仍然会Hold对象。
Android系统通过如下几种方式管理系统内存:
1. 共享内存区域:Sharing Memory
In order to fit everything it needs in RAM, Android tries to share RAM pages across processes. Android系统通过以下步骤实现进程间内存共享:
Each app process is forked from an existing process called Zygote. The Zygote process starts when the system boots and loads common framework code and resources (such as activity themes). To start a new app process, the system forks the Zygote process then loads and runs the app's code in the new process. This allows most of the RAM pages allocated for framework code and resources to be shared across all app processes.
为了启动一个新的程序进程,系统会fork Zygote进程生成一个新的进程,然后在新的进程中加载并运行app的代码。这使得大多数的RAM pages被用来分配给framework的代码,同时使得RAM资源能够在应用的所有进程中进行共享。
Most static data is mmapped into a process. This not only allows that same data to be shared between processes but also allows it to be paged out when needed. Example static data include: Dalvik code (by placing it in a pre-linked .odex file for direct mmapping), app resources (by designing the resource table to be a structure that can be mmapped and by aligning the zip entries of the APK), and traditional project elements like native code in .so files.
大多数static的数据被mmapped到一个进程中。这不仅仅使得同样的数据能够在进程间进行共享,而且使得它能够在需要的时候被paged out。静态数据包括:Dalvik 代码 (放在一个预链接好的 .odex 文件中以便直接mapping);App Resource资源(通过把资源表结构设计成便于mmapping的数据结构,另外还可以通过把APK中的文件做aligning的操作来优化);传统项目元素,比如 .so 文件中的本地代码。
在很多时候,Android通过显式地分配共享内存区域(例如:ashmem或gralloc)来实现一些动态RAM区域能够在不同进程间进行共享。例如:window surfaces在app与screen compositor之间使用共享的内存,cursor buffers在content provider与client之间使用共享的内存。
2. 分配和回收内存:Allocating and Reclaiming App Memory
每一个进程的Dalvik Heap都有一个受限的虚拟内存范围。这就是逻辑上讲的Heap-Size,它可以随着需要进行增长,但是系统为它定义一个上限值。
逻辑上讲的heap size和实际物理上使用的内存数量是不等的,Android会计算一个叫做Proportional Set Size(PSS)的值,它记录了那些和其他进程进行共享的内存大小。(假设共享内存大小是10M,一共有20个Process在共享使用,根据权重,可能认为其中有0.3M才能真正算是你的进程所使用的)
Dalvik heap与逻辑上的heap size不吻合,这意味着Android并不会去做heap中的碎片整理用来关闭空闲区域。Android仅仅会在heap的尾端出现不使用的空间时才会做收缩逻辑heap size大小的动作(Android can only shrink the logical heap size when there is unused space at the end of the heap.)。但是这并不是意味着被heap所使用的物理内存大小不能被收缩。在垃圾回收之后,Dalvik会遍历heap并找出不使用的pages,然后使用madvise(系统调用)把那些pages返回给kernal。因此,成对的allocations与deallocations大块的数据可以使得物理内存能够被正常的回收。然而,回收碎片化的内存则会使得效率低下很多,因为那些碎片化的分配页面也许会被其他地方所共享到。
3. 限制应用的内存使用量:Restricting App Memory
为了维持多任务的功能环境,Android为每一个app都设置了一个硬性的heap size限制。准确的heap size限制会因为不同设备的不同RAM大小而各有差异。如果你的app已经到了heap的限制大小并且再尝试分配内存的话,会引起OutOfMemoryError的错误。
在一些情况下,你也许想要查询当前设备的heap size限制大小是多少,然后决定cache的大小。可以通过getMemoryClass()来查询,方法会返回一个整数,表明你的应用的heap size限制是多少Mb。
4. 切换应用:Switching Apps
Android并不会在用户切换不同应用时候做交换内存(swap space)的操作。Android会把那些不包含foreground组件的进程放到LRU cache(least-recently used)中。例如,当用户刚开始启动了一个应用,系统会为它创建了一个进程,但是当用户离开这个应用,此进程并不会立即被销毁。系统会把这个进程放到cache中,如果用户后来再回到这个应用,此进程就能够被完整恢复,从而实现应用的快速切换。
如果你的应用中有一个被缓存的进程,这个进程会占用暂时不需要使用到的内存;这个暂时不需要使用的进程(它被保留在内存中),会对系统的整体性能有影响。因此当系统开始进入低内存状态时,它会由系统根据LRU的规则与其他因素选择综合考虑之后决定杀掉某些进程。为了保持你的进程能够尽可能长久的被缓存,请参考下面的章节学习何时释放你的引用。
主题二:应用该如何合理使用内存?
There are many ways you can design and write code that lead to more efficient results, through aggregation of the same techniques applied over and over. 我们可以使用多种设计与实现方式,他们有着不同的效率,即使这些方式只是相同技术的不断组合与演变。
You should apply the following techniques while designing and implementing your app to make it more memory efficient.
方法一:珍惜Service资源 Use Service Sparingly
如果你的应用需要在后台使用service,除非它被触发并执行一个任务,否则其他时候service都应该是停止状态。另外需要注意当这个service完成任务之后因为停止service失败而引起的内存泄漏。
当你启动一个service,系统会倾向为了保留这个service而一直保留service所在的进程。这使得进程的运行代价很高,因为系统没有办法把service所占用的RAM空间腾出来让给其他组件,另外service还不能被paged out。这减少了系统能够存放到LRU缓存当中的进程数量,它会影响app之间的切换效率。它甚至会导致系统内存使用不稳定,从而无法继续保持所有目前正在运行的service。
限制你的service的最好办法是使用IntentService, 它会在处理完交代给它的intent任务之后尽快结束自己。更多信息,请阅读Running in a Background Service.
当一个Service已经不再需要的时候还继续保留它,这对Android应用的内存管理来说是最糟糕的错误之一。因此千万不要贪婪的使得一个Service持续保留。不仅仅是因为它会使得你的应用因为RAM空间的不足而性能糟糕,还会使得用户发现那些有着常驻后台行为的应用并且可能卸载它。
方法二:当UI隐藏时释放内存 Release memory when your user interface becomes hidden
当用户切换到其它应用导致本应用UI不再可见时,应该释放本应用UI上所占用的所有内存资源。在这个时候释放UI资源可以显著的增加系统缓存进程的能力,它会对用户体验有着很直接的影响。(什么是UI资源?包含什么内容?)
为了能够接收到用户离开你的UI时的通知,你需要实现Activtiy类里面的onTrimMemory()回调方法。你应该使用这个方法来监听到TRIM_MEMORY_UI_HIDDEN级别的回调,此时意味着你的UI已经隐藏,你应该释放那些仅仅被你的UI使用的资源。
请注意:你的应用仅仅会在所有UI组件的被隐藏的时候接收到onTrimMemory()的回调并带有参数TRIM_MEMORY_UI_HIDDEN。这与onStop()的回调是不同的,onStop会在Activity的实例隐藏时会执行,例如当用户从你的app的某个activity跳转到另外一个activity时前面Activity的onStop()会被执行。因此你应该实现onStop回调,并且在此回调里面释放activity的资源,例如释放网络连接,注销监听广播接收者。除非接收到onTrimMemory(TRIM_MEMORY_UI_HIDDEN))的回调,否则你不应该释放你的UI资源。这确保了用户从其他activity切回来时,你的UI资源仍然可用,并且可以迅速恢复activity。
方法三:当内存紧张时,释放部分内存
在你的app生命周期的任何阶段,onTrimMemory的回调方法同样可以告诉你整个设备的内存资源已经开始紧张。你应该根据onTrimMemory回调中的内存级别来进一步决定释放哪些资源。
TRIM_MEMORY_RUNNING_MODERATE:本app正在运行并且不会被列为可杀死的。但是设备此时正运行于低内存状态下,系统开始触发杀死LRU Cache中的Process的机制。
TRIM_MEMORY_RUNNING_LOW:本app正在运行且没有被列为可杀死的。但是设备正运行于更低内存的状态下,你应该释放不用的资源用来提升系统性能(但是这也会直接影响到你的app的性能)。
TRIM_MEMORY_RUNNING_CRITICAL:本app仍在运行,但是系统已经把LRU Cache中的大多数进程都已经杀死,因此你应该立即释放所有非必须的资源。如果系统不能回收到足够的RAM数量,系统将会清除所有的LRU缓存中的进程,并且开始杀死那些之前被认为不应该杀死的进程,例如那个包含了一个运行态Service的进程。
如果本应用已经被cached了,你可能会接受到从onTrimMemory()中返回的下面的值之一:
TRIM_MEMORY_BACKGROUND: 系统正运行于低内存状态并且你的进程正处于LRU缓存名单中最不容易杀掉的位置。尽管你的app进程并不是处于被杀掉的高危险状态,系统可能已经开始杀掉LRU缓存中的其他进程了。你应该释放那些容易恢复的资源,以便于你的进程可以保留下来,这样当用户回退到你的app的时候才能够迅速恢复。
TRIM_MEMORY_MODERATE:系统正运行于低内存状态并且你的进程已经已经接近LRU名单的中部位置。如果系统开始变得更加内存紧张,你的进程是有可能被杀死的。
TRIM_MEMORY_COMPLETE:系统正运行与低内存的状态并且你的进程正处于LRU名单中最容易被杀掉的位置。你应该释放任何不影响你的app恢复状态的资源。
需要注意到的是:当系统开始清除LRU缓存中的进程时,尽管它首先按照LRU的顺序来操作,但是它同样会考虑进程的内存使用量。因此消耗越少的进程则越容易被留下来。
方法四:知道你可以使用多少内存
正如前面提到的,每一个Android设备都会有不同的RAM总大小与可用空间,因此不同设备为app提供了不同大小的heap限制。你可以通过调用getMemoryClass()来获取你的app的可用heap大小估计值。如果你的app尝试申请更多的内存,会出现OutOfMemory的错误。在一些特殊的情景下,你可以通过在manifest的application标签下添加largeHeap=true的属性来声明一个更大的heap空间。如果你这样做,你可以通过getLargeMemoryClass()来获取到一个更大的heap size。
然而,能够获取更大heap的设计本意是为了一小部分会消耗大量RAM的应用(例如一个大图片的编辑应用)。不要轻易的因为你需要使用大量的内存而去请求一个大的heap-size。只有当你清楚的知道哪里会使用大量的内存并且为什么这些内存必须被保留时才去使用large heap. 因此请尽量少使用large heap。使用额外的内存会影响系统整体的用户体验,并且会使得GC的每次运行时间更长。在任务切换时,系统的性能会变得大打折扣。
另外, large heap并不一定能够获取到更大的heap。在某些有严格限制的机器上,large heap的大小和通常的heap size是一样的。因此即使你申请了large heap,你还是应该通过执行getMemoryClass()来检查实际获取到的heap大小。
方法五:避免Bitmaps引发的内存浪费
当你加载一个bitmap时,仅仅需要保留适配当前屏幕设备分辨率的数据即可,如果原图高于你的设备分辨率,需要做缩小的动作。请记住,增加bitmap的尺寸会对内存呈现出2次方的增加,因为X与Y都在增加。
方法六:使用优化的数据容器
利用Android Framework里面优化过的容器类,例如SparseArray, SparseBooleanArray与LongSparseArray。通常的HashMap的实现方式更加消耗内存,因为它需要一个额外的实例对象来记录Mapping操作。另外SparseArray更加高效在于他们避免了对key与value的自动装箱操作,并且避免了装箱后的解箱。
方法七:注意内存开销
对你所使用的语言与库的成本与开销有所了解,从开始到结束,在设计你的app时谨记这些信息。通常,表面上看起来无关痛痒(innocuous)的事情也许实际上会导致大量的开销。
Enums often require more than twice as much memory as static constants. You should strictly avoid using enums on Android. 避免使用Enum枚举类,其内存开销通常是static constants的2倍
Every class in Java (including anonymous inner classes) uses about 500 bytes of code. Java中的每个类大概消耗500字节内存
Every class instance has 12-16 bytes of RAM overhead. 每个类的实例会消耗12-16字节内存
Putting a single entry into a HashMap requires the allocation of an additional entry object that takes 32 bytes (see the previous section about optimized data containers). 往HashMap添加一个Entry,额外需要一个占用32字节的Entry对象
方法八:注意代码抽象 Be careful with code abstractions
通常,开发者使用抽象作为"好的编程实践",因为抽象能够提升代码的灵活性与可维护性。然而,抽象会导致一个显著的开销:通常他们需要同等量的代码用于可执行。那些代码会被Map到内存中。如果你的抽象没有显著的提升效率,应该尽量避免他们。
方法九:为序列化数据使用 Nano Protobufs
Protocol buffers是由Google为序列化结构数据而设计的,一种语言无关,平台无关,具有良好扩展性的协议。类似XML,却比XML更加轻量,快速,简单。如果你需要为你的数据实现协议化,你应该在客户端的代码中总是使用nano protobufs。通常的协议化操作会生成大量繁琐的代码,这容易给你的app带来许多问题:增加RAM的使用量,显著增加APK的大小,更慢的执行速度,更容易达到DEX的字符限制。
方法十:避免使用依赖注入框架
使用类似Guice或者RoboGuice等framework injection包是很有效的,因为他们能够简化你的代码。然而,那些框架会通过扫描你的代码执行许多初始化的操作,这会导致你的代码需要大量的RAM来mapping代码,而且mapped pages会长时间的被保留在RAM中。
方法十一:谨慎使用第三方Library
很多开源的library代码都不是为移动网络环境而编写的,如果运用在移动设备上,这样的效率并不高。当你决定使用一个第三方library的时候,你应该针对移动网络做繁琐的迁移与维护的工作。即使是针对Android而设计的library,也可能是很危险的,因为每一个library所做的事情都是不一样的。例如,其中一个lib使用的是nano protobufs, 而另外一个使用的是micro protobufs。那么这样,在你的app里面就有2种protobuf的实现方式。这样的冲突同样可能发生在输出日志,加载图片,缓存等等模块里面。
同样不要陷入为了1个或者2个功能而导入整个library的陷阱。如果没有一个合适的库与你的需求相吻合,你应该考虑自己去实现,而不是导入一个大而全的解决方案。
方法十二:考虑如何优化整体性能
官方有列出许多优化整个app性能的文章:Best Practices for Performance。这篇文章就是其中之一。有些文章是讲解如何优化app的CPU使用效率,有些是如何优化app的内存使用效率。你还应该阅读optimizing your UI来为layout进行优化。同样还应该关注lint工具所提出的建议,进行优化。
方法十三:使用ProGuard来剔除不需要的代码
ProGuard能够通过移除不需要的代码,重命名类,域与方法等方对代码进行压缩,优化与混淆。使用ProGuard可以使得你的代码更加紧凑,这样能够使用更少mapped代码所需要的RAM。
方法十四:对最终的APK使用Zipalign
在编写完所有代码,并通过编译系统生成APK之后,你需要使用zipalign对APK进行重新校准。如果你不做这个步骤,会导致你的APK需要更多的RAM,因为一些类似图片资源的东西不能被mapped。需要指出的是:Google Play Store不接受没有被 zipaligned 的应用。
方法十五:分析应用的RAM使用情况
一旦你获取到一个相对稳定的版本后,需要分析你的app整个生命周期内使用的内存情况,并进行优化,更多细节请参考Investigating Your RAM Usage.
方法十六:使用多进程
如果合适的话,有一个更高级的技术可以帮助你的app管理内存使用:通过把你的app组件切分成多个组件,运行在不同的进程中。这个技术必须谨慎使用,大多数app都不应该运行在多个进程中。因为如果使用不当,它会显著增加内存的使用,而不是减少。当你的app需要在后台运行与前台一样的大量的任务的时候,可以考虑使用这个技术。
一个典型的例子是创建一个可以长时间后台播放的Music Player。如果整个app运行在一个进程中,当后台播放的时候,前台的那些UI资源也没有办法得到释放。类似这样的app可以切分成2个进程:一个用来操作UI,另外一个用来后台的Service.
你可以通过在manifest文件中声明'android:process'属性来实现某个组件运行在另外一个进程的操作:
<service android:name=".PlaybackService" android:process=":background" />
使用adb工具查看应用程序的Heap-Size,及其他内存情况:
C:UsersAdministrator>adb shell dumpsys meminfo com.fenix Applications Memory Usage (kB): Uptime: 1151754 Realtime: 1151754 ** MEMINFO in pid 5301 [com.fenix] ** Pss Private Private Swapped Heap Heap Heap Total Dirty Clean Dirty Size Alloc Free ------ ------ ------ ------ ------ ------ ------ Native Heap 0 0 0 0 12288 7625 4662 Dalvik Heap 1985 1764 0 0 8405 5495 2910 Dalvik Other 352 352 0 0 Stack 240 240 0 0 Ashmem 7 0 0 0 Other dev 6 0 4 0 .so mmap 970 120 0 0 .apk mmap 81 0 16 0 .ttf mmap 21 0 0 0 .dex mmap 1644 0 1640 0 .oat mmap 979 0 0 0 .art mmap 869 436 0 0 Other mmap 5 4 0 0 EGL mtrack 2460 2460 0 0 GL mtrack 4424 4424 0 0 Unknown 4308 4168 0 0 TOTAL 18351 13968 1660 0 20693 13120 7572 Objects Views: 15 ViewRootImpl: 1 AppContexts: 3 Activities: 1 Assets: 3 AssetManagers: 3 Local Binders: 8 Proxy Binders: 15 Parcel memory: 2 Parcel count: 10 Death Recipients: 0 OpenSSL Sockets: 0 SQL MEMORY_USED: 0 PAGECACHE_OVERFLOW: 0 MALLOC_SIZE: 0
Tips: This leads to an important conclusion: If you are going to split your app into multiple processes, only one process should be responsible for UI. Other processes should avoid any UI, as this will quickly increase the RAM required by the process (especially once you start loading bitmap assets and other resources). It may then be hard or impossible to reduce the memory usage once the UI is drawn.