原文地址:http://blog.csdn.net/jinzhuojun/article/details/13297447
虽然摩尔定律让我们的计算机硬件得以以指数速度升级,但反摩尔定律又不断消减这些升级所带来的好处。其原因之一就是面对硬件的更新换代,程序员似乎不用再对内存“精打细处“了。而近年来随着穿戴式设备和大数据平台的兴起(一个是内存本身受限,一个是对内存的需求巨大),让内存的有效利用又成为了值得开发人员关注的热点。
《Small MemorySoftware: Patterns For System With Limited Memory 》(http://www.smallmemory.com/ , 中文名为《内存受限系统之软件开发:针对内存受限系统而整理的模式》)一书探讨了编程中有效利用内存的方法。全书分为五个部分,分别从架构,次级存储,压缩,数据结构和内存分配进行了阐述。下面是一些读书笔记,留作备忘。
Small Architecture(小内存架构)
Memory Limit(Fixed-sized Heap,Memory Partitions):让系统中的每个组件负责自己的内存,为每个组件设置配额,一旦用完即分配失败。这种模式主要是为了防止系统中某一组件耗光系统所有内存的情况发生,而缺点是容易引起内存浪费,因为会出现系统中一些组件还有空闲内存而另外一些内存分配失败的情况。实现上主要有三种方法:截获内存申请释放操作,独立堆和独立进程。
Small Interface:当整个系统被分成若干个组件,而每个组件又独立管理自己的内存使用,那么当内存数据在这些组件间进行传递时,就需要定义接口(或者说协议)。在组件间交换数据有三种方式:Lending(客户传对象给系统组件), Borrowing(系统组件传对象给客户)和Stealing(可双向,同时对象所有权也发生变化)。另外组件间的数据传递也经常会通过Iterator这种增量式的形式来进行。
Partial Failure(Graceful degradation,Feast and Famine):当系统申请内存失败时,让系统在一个“降级模式”下继续运行而不是直接退出。如字体加载失败就用系统字体,图片加载失败就用占位符代替。那些因为内存不足而无法进行的计算可以不执行或是缓存起来以后执行。当内存不那么紧张时,系统要能恢复到先前的模式。值得注意的是,处理Partial Failure本身也需要内存,因此需要预分配这部分内存(称为Rainy day fund)。后面的Multiple representation和ApplicationSwitching都可作为其实现形式。
Captain Oates(Cache Release):当整个系统内存紧张时,砍掉不必要或是次要的组件,亦或是清空一些缓存得以让整个系统继续运行。它的核心思想是牺牲那些次要的功能来留全那些主要的功能。Captain Oates和Partial Failure中很多技术是通用的,前者描述其它组件内存不足时本组件该干什么,后者描述当前组件内存不足时本组件该干什么。
Read-Only Memory(Use the ROM):有些数据是不常更新的,如可执行代码,常量字符串,资源文件等。把这些数据放在只读存储区域中,至少有两个好处:其一是可以通过共享节约内存;另一好处是要换出到次级存储时不用写回操作,从而提高了效率。如果需要更新这些只读数据可以用后面会提到的Copy-on-write或者Hooks。
Hooks(Vector table, Jumptable, Patch table, Interrupt table):想要修改只读存储区域上的数据,可以通过间接访问只读内存的方法,中间用位于可写区域的Hook做跳板。Hook的一个例子就是GOT表,代码段被加载在只读段中,而GOT表加载在可写段中,因为它是要在运行时被Dynamic linker修改的。这样当代码段中有外部函数调用时,会通过GOT表来跳转。这样就实现了代码段不变而改变程序逻辑的目的。
Secondary Storage (次级存储)
Application Switching(Phases, ProgramChaining, Command Scripts):假设系统提供很多功能而用户不会同时用到,就可以把系统分为很多个独立的可执行程序,等用户需要哪个再起哪个,每次只运行一个。这类似于Unix命令的哲学,把一系列功能专一的独立工具组合连接起来完成强大的功能。程序之间可通过参数,磁盘和环境变量等转递信息。要传输复杂的对象还可能会用到Memento模式。
Data File Pattern(Batch Processing,Filter, Temporary File):如果要处理的数据太多,没法或是没必要一次全部载入,就一批批从次级存储放进内存处理。例如要将一个无法一次放入内存的大数据排序,可以先将之切分使之能够载入内存,然后分别进行归并排序,直到所有数据排序完成。
Resource File Pattern:资源文件一般包括字体,图标,字符串,布局和配置信息等。这些东西一般不会被改写,且在程序运行的任何时候都有可能用到,但用完后即可以被丢到次级存储器上,下次要用可以再去磁盘上取。
Packages(Components, LazyLoading, Dynamic Loading, Code Segmentation):将整个系统分成很多功能包,要什么功能的时候再动态加载。加载可以以单独进程形式也可以动态链接,亦或手工加载进内存,它们各有利弊。如浏览器插件,Java中的class和Linux中的driver都是利用了这种动态加载的概念。实际要注意的是binary级的匹配问题。Abstract Factory模式可用来统一客户和包实现的接口。Proxy模式可用于包的自动加载和卸载。
Paging Pattern(Virtual Memory,Persistence, Backing Store, Paging OO DBMS):基于空间局部性,将程序最近常用的数据(Working set)保留在内存中,其余放在次级存储器中,要用时再读进来。它在形式上按粒度从小到大有Demanding page(页级), Object Oriented Databases(对象级)和Swapping(进程数据级)。典型的例子当然就是操作系统中的页式管理了。
Compression(压缩)
Table CompressionPattern(Nibble Coding, HuffmanCoding):由于数据中每个元素出现概率不一样(信息量不一样)或者说重要性程度不一样,因此如果对原始数据进行重新编码,可以达到压缩的目的。典型的例子如Huffman编码(基于字符出现频率不一样)和JPEG(基于人眼对色彩的敏感程度不一样)。
Difference CodingPattern(Delta Coding, RunLength Encoding):差分编码,即利用数据(尤其是音、视频等流数据)的前后相关性进行压缩编码,如MPEG格式。我们知道压缩视频中一般有关键帧和非关键帧之分,关键帧可被独立解码,而非关键帧只存有差分信息。差分编码的实现技术有Delta Coding,Run Length Encoding,LossyDifference Compression,此外还需为传错而考虑重同步问题。
Adaptive CompressionPattern:在压缩前或者压缩中分析数据来得到最好的压缩参数或实时调整参数。与前两种方式相比自适应算法处理时间更长,因此不适用于实时任务。如gzip, bzip2压缩方法就使用了该模式。
Small Data Structure(小内存数据结构)
Packed Data(Bit Packing) :一方面我们有时会用过大的类型存储数据,另一方面计算机体系一般会将数据进行对齐存储从而优化读写效率,有时这会导致内存利用效率的降低。我们可以通过将数据结构进行pack来减少内存的使用。缺点是它会较大地影响性能。注意将用句柄代替指针也能节省一部分内存,因为句柄一般都比指针小,可以用更小的数据类型存储。
Sharing (Normalisation):内存共享,节约内存。共享的东西在内存中仅需要一份拷贝。有些是明显可以共享的,就像动态库libc.so,有些由于内容重复可以共享的,如Java中的String使用了Flyweight模式,对重复字符串只存储一份。简单的共享可以用静态全局变量,Sigleton模式或者LazyInitialization实现。复杂点的可以用Shared cache,即把所有的共享对象放入数据库(cache),使用时用key查询来得到。
Copy-on-Write:共享的东西需要改动时,就将之拷贝一份,再进行读写。像Linux中对于共享页在页表中是标识成只读的,当对其进行写操作时,就会发生page fault,从而把控制权交还kernel进行Copy-on-write的处理。Copy-on-write常用Proxy模式实现,以实现过程对客户透明。
Embedded Pointer:对于一些用指针组建的数据结构(如链表,树,图等),将前后指针放在要存储的目标对象中,不仅能节省维护数据结构本身的内存,而且能在遍历时节省临时内存(如栈,队列)的使用。实现起来有三种方式:继承,内联和预处理。像Linux中的list_head就是用了内联嵌入指针。另外相关的技术还有Pointer Differences和Pointer reversal。
Multiple Representation:和Template Method及Strategy模式的作用差不多,只是这儿用在内存利用上了。即准备多套实现,根据内存的使用情况进行切换。该模式一大优点是对客户透明。例子如Java和STL中的容器类,它们接口统一,但对内存的使用策略却不同。
Memory Allocation(内存分配)
原则上只要能满足需求,能用简单的就用简单的。内存分配主要就是和两个问题作斗争:一是碎片问题(解决方法有固定分配,拷贝压缩,池式管理等);二是内存不足问题(解决方法有固定客户内存大小,产生错误信号,降低质量,删除旧对象,延迟申请和忽略问题等)。
Fixed Allocation(Static Allocation,Pre-allocation):初始化时分配固定大小内存,程序执行时不再动态分配,而初始化时可以动态也可以静态分配。这样做的好处是不会出现分配失败的情况,系统可预测性好,且内存分配高效,坏处是不够灵活。
Variable Allocation(Dynamic Allocation):当需要的时候再分配内存,适合预先不知道数据大小的情况(如通用库)。它的缺点也是Fixed Allocation的优点:即分配可能失败(此时可以考虑引入Partial Failure),分配操作会影响性能(将Fixed Allocation与之结合,就有了Pooled Allocation),需要自己释放内存(可用Referencecounting和Garbage Collection),另外动态分配还更容易产生碎片(可用Compaction压缩已有对象,MemoryDiscard删除临时对象)。
Memory Discard(Stack Allocation,Scratchpad):适用于临时用的内存。在操作前从栈、堆或是预分配区域中进行分配,操作完了就一起丢弃。好处是内存的分配和释放都只需要移动下指针即可,简单粗暴高效。函数调用过程中栈帧的处理就是该模式的例子。另一种少见的情况就是要用一个现成的但有内存泄露问题的组件,可以在加载它之前为之分配临时内存,卸载时把它的内存全部统一清空。用Memory Discard的时候要格外小心临时对象所持有的外部资源引用,在释放内存前要先释放这些资源。
Pooled Allocation(Memory Pool):内存池适用于大小类似,但次数较为频繁的内存分配请求。通常做法是先预分配一块内存(通过Fixed Allocation),将其按固定大小切分成为内存池,用空闲队列进行管理。程序执行过程中需要内存了就从里边拿,释放后也放回其中(Variable Allocation)。该模式结合了Fixed Allocation和VariableAllocation的优点。Linux中的Slab就是个例子。
Compaction(Manged Table, MemoryHandles, Defragmentation):压缩主要用来处理Variable Allocation产生的碎片问题。当系统跑了很长一段时间后,剩下很多不连续的空闲内存,当用户申请一块大的内存时,申请就容易失败。比较简单的方法就是拷贝数据让它们整一块儿去。注意由于指针的存在,这些指针在压缩过程中需要被及时更新,该问题一般可用间接引用(即用句柄或对象表代替指针)来解决。
Reference Counting:主要用于共享对象的回收,使这些对象在没人使用时被自动回收。大体思路是在对象中记录该对象的引用数来判断其是否是垃圾,当引用数为0时说明无人引用,可以被清除。和GarbageCollection相比,它的优点是本身的overhead被分摊到整个执行过程,因此对用户的实时响应影响不大,且一旦对象变垃圾立即会被清除;缺点是需要程序员来主动维护引用计数,且影响系统性能,另外要处理环形引用比较麻烦。
Garbage Collection(Mark-sweep GarbageCollection, Tracing Garbage Collection):也是主要用来处理共享对象的回收。系统在一定时候(一般是现有内存不足时)触发垃圾回收,然后从一些根结点(通常为栈变量,全局及静态变量,外部库的引用等)出发遍历所有被引用对象。这样剩下的就是可以被清除的垃圾了。清除垃圾有两种做法:Mark-sweep GC和Copying GC,实际中它们可结合使用(像mono中的SGen)。Garbage Collection的优点是更加自动,无需用户参于,且总体性能损失相对更小;缺点是回收时会有停顿现象,另外对Finalization(一般用于释放文件句柄或设备等外部资源)的支持会比较麻烦(一般做法是把它们加入到待办队列,然后到单独线程去调用,但这样它们被调用的时间就不好预测,容易使系统变得不稳定)。由于该模式和Reference Counting各有利弊,有些时候系统中它们会同时出现。