操作系统与内存管理

1字数 10457阅读 6921

操作系统对内存的管理

没有内存抽象的年代

在早些的操作系统中,并没有引入内存抽象的概念。程序直接访问和操作的都是物理内存。比如当执行如下指令时:mov reg1,1000

这条指令会毫无想象力的将物理地址1000中的内容赋值给寄存器。不难想象,这种内存操作方式使得操作系统中存在多进程变得完全不可能,比如MS-DOS,你必须执行完一条指令后才能接着执行下一条。如果是多进程的话,由于直接操作物理内存地址,当一个进程给内存地址1000赋值后,另一个进程也同样给内存地址赋值,那么第二个进程对内存的赋值会覆盖第一个进程所赋的值,这回造成两条进程同时崩溃。

没有内存抽象对于内存的管理通常非常简单,除去操作系统所用的内存之外,全部给用户程序使用。或是在内存中多留一片区域给驱动程序使用

无内存抽象存在的问题

  1. 用户程序可以访问任意内存,容易破坏操作系统,造成崩溃

  2. 同时运行多个程序特别困难

内存抽象:地址空间

  1. 基址寄存器与界限寄存器可以简单的动态重定位,每个内存地址送到内存之前,都会自动加上基址寄存器的内容。

  2. 交换技术把一个进程完全调入内存,使该进程运行一段时间,然后把它存回磁盘。空闲进程主要存在磁盘上,所以当他们不运行时就不会占用内存。

为什么要有地址空间?

首先直接把物理地址暴露给进程会带来严重问题

  1. 如果用户程序可以寻址内存的每个字节,就有很大的可能破坏操作系统,造成系统崩溃

  2. 同时运行多个程序十分困难 地址空间创造了一个新的内存抽象,地址空间是一个进程可用于寻址内存的一套地址的集合。每个进程都有一个自己的地址空间,并且这个地址空间独立于其它进程的地址空间。使用基址寄存器和界限器可以实现。

虚拟内存

虚拟内存是现代操作系统普遍使用的一种技术。前面所讲的抽象满足了多进程的要求,但很多情况下,现有内存无法满足仅仅一个大进程的内存要求(比如很多游戏,都是10G+的级别)。在早期的操作系统曾使用覆盖(overlays)来解决这个问题,将一个程序分为多个块,基本思想是先将块0加入内存,块0执行完后,将块1加入内存。依次往复,这个解决方案最大的问题是需要程序员去程序进行分块,这是一个费时费力让人痛苦不堪的过程。后来这个解决方案的修正版就是虚拟内存。

虚拟内存的基本思想是,每个进程有用独立的逻辑地址空间,内存被分为大小相等的多个块,称为(Page).每个页都是一段连续的地址。对于进程来看,逻辑上貌似有很多内存空间,其中一部分对应物理内存上的一块(称为页框,通常页和页框大小相等),还有一些没加载在内存中的对应在硬盘上。

由上图可以看出,虚拟内存实际上可以比物理内存大。当访问虚拟内存时,会通过MMU(内存管理单元)去匹配对应的物理地址,而如果虚拟内存的页并不存在于物理内存中,会产生缺页中断,从磁盘中取得缺的页放入内存,如果内存已满,还会根据某种算法将磁盘中的页换出。

虚拟内存和物理内存的匹配是通过页表实现,页表存在MMU中,页表中每个项通常为32位,既4byte,除了存储虚拟地址和页框地址之外,还会存储一些标志位,比如是否缺页,是否修改过,写保护等。可以把MMU想象成一个接收虚拟地址项返回物理地址的方法。

因为页表中每个条目是4字节,现在的32位操作系统虚拟地址空间会是2的32次方,即使每页分为4K,也需要2的20次方 * 4字节 = 4M的空间,为每个进程建立一个4M的页表并不明智。因此在页表的概念上进行推广,产生二级页表,二级页表每个对应4M的虚拟地址,而一级页表去索引这些二级页表,因此32位的系统需要1024个二级页表,虽然页表条目没有减少,但内存中可以仅仅存放需要使用的二级页表和一级页表,大大减少了内存的使用。

引入多级页表的原因是避免把全部页表一直存在内存中

虚拟地址和物理地址匹配规则

虚拟页号可用做页表的索引,以找到该虚拟页面对应页表项。由页表项可以找到页框号。然后把页框号拼接到偏移量的高位端,以替换虚拟页号,形成送往内存的物理地址。

页表的目的是把虚拟页面映射为页框,从数学的角度来说,页表是一个函数,它的参数是,虚拟页号,结果是物理页框号。通过这个函数可以把虚拟地址中的虚拟页面域替换为页框域,从而形成物理地址。

页面置换算法

地址映射过程中,若在页面中发现所要访问的页面不再内存中,则产生缺页中断。当发生缺页中断时操作系统必须在内存选择一个页面将其移出内存,以便为即将调入的页面让出空间。如果要换出的页面在内存驻留期间已经被修改过,就必须把它写回磁盘以更新该页面在磁盘的副本;如果该页面没有被修改过,那么它在磁盘上的副本已经是最新的,不需要回写。直接用调入的页面覆盖掉被淘汰的页面就可以了。而用来选择淘汰哪一页的规则叫做页面置换算法。

因为在计算机系统中,读取少量数据硬盘通常需要几毫秒,而内存中仅仅需要几纳秒。一条CPU指令也通常是几纳秒,如果在执行CPU指令时,产生几次缺页中断,那性能可想而知,因此尽量减少从硬盘的读取无疑是大大的提升了性能。而前面知道,物理内存是极其有限的,当虚拟内存所求的页不在物理内存中时,将需要将物理内存中的页替换出去,选择哪些页替换出去就显得尤为重要,如果算法不好将未来需要使用的页替换出去,则以后使用时还需要替换进来,这无疑是降低效率的,让我们来看几种页面替换算法。

最优页面置换算法(Optimal Page Replacement Algorithm)

最饥饿页面置换算法是将未来最久不使用的页替换出去,这听起来很简单,但是无法实现。根据页面被访问前所需要的指令数作为标记,根据指令数的由多到少进行置换,这个方法对评价页面置换算法很有用,但它在实际系统中却不能使用,因为无法真正的实现。这种算法可以作为衡量其它算法的基准。

最近最少使用页面置换算法(Least Recently Used)

通常在前几条指令中使用频繁的页面很可能在后面几条指令中页频繁使用。LRU算法就是在缺页发生时首先置换最长时间未被使用的页面。优秀但是难以实现。

最近未使用页面置换算法(Not Recently Used Replacement Algorithm)

在最近的一个时钟周期内,淘汰一个没有被访问的已修改页面,近似 LRU 算法,NRU 只是更粗略些。

这种算法给每个页一个标志位,R表示最近被访问过,M表示被修改过。定期对R进行清零。这个算法的思路是首先淘汰那些未被访问过R=0的页,其次是被访问过R=1,未被修改过M=0的页,最后是R=1,M=1的页。

先进先出的页面置换算法(First-In First-Out Page Replacement Algorithm)

这种算法的思想是淘汰在内存中最久的页,这种算法的性能接近于随机淘汰。可能抛弃重要的页面,并不好。

第二次机会页面置换算法(Second Chance Page Replacement Algorithm)

这种算法是在FIFO的基础上,为了避免置换出经常使用的页,增加一个标志位R,如果最近使用过将R置1,当页将会淘汰时,如果R为1,则不淘汰页,将R置0.而那些R=0的页将被淘汰时,直接淘汰。这种算法避免了经常被使用的页被淘汰。

时钟替换算法(Clock Page Replacement Algorithm)

虽然改进型FIFO算法避免置换出常用的页,但由于需要经常移动页,效率并不高。因此在改进型FIFO算法的基础上,将队列首位相连形成一个环路,当缺页中断产生时,从当前位置开始找R=0的页,而所经过的R=1的页被置0,并不需要移动页。

下表是上面几种算法的简单比较:

算法 描述
最佳置换算法 无法实现,最为测试基准使用
最近不常使用算法 和LRU性能差不多
先进先出算法 有可能会置换出经常使用的页
改进型先进先出算法 和先进先出相比有很大提升
最久未使用算法 性能非常好,但实现起来比较困难
时钟置换算法 非常实用的算法

分页系统中的设计问题

  1. 在任何分页式系统中,都需要考虑两个主要的问题:虚拟地址到到物理地址的映射必须非常快;如果虚拟地址空间很大,页表也会很大。

  2. 局部分分配策略与全局分配策略,怎样在相互竞争的可运行进程之间分配内存.

    • 局部分配为每个进程分配固定的内存片段,即使有大量的空闲页框存在,工作集的增长也会颠簸。

    • 全局分配在进程间动态地分配页框,分配给各个进程的页框数是动态变化的。给每个进程分配一个最小的页框数使无论多么小的进程都可以运行,再需要更大的内存时去公共的内存池里去取。

    • FIFO LRU 既适用于局部算法,也适用于全局算法。WSClock 工作集更适用于局部算法。

  3. 负载控制 , 即使使用了最优的页面置换算法,最理想的全局分配。当进程组合的工作集超出内存容量时,就可能发生颠簸。这时只能根据进程的特性(IO 密集 or CPU 密集)将进程交换到磁盘上。

  4. 页面大小 的确定不存在全局最优的结果,小页面减少页面内内存浪费,但是小页面,意味着更大的页表,更多的计算转换时间。现在一般的页面大小是 4KB 或 8KB

  5. 地址空间太小,所以分离指令空间,数据空间

  6. 共享页面,在数据空间和指令空间分离的基础上很容易实现程序的共享,linux 采取了 copy on write 的方案,也有数据的共享。

  7. 共享库,多个程序并发,如果有一个程序已经装在了共享库,其他程序就没有必要再进行装载,减少内存浪费。而且共享库并不会一次性的装入内存,而是根据需要以页面为单位进行装载的。共享时不使用绝对地址,使用相对偏移量的代码(位置无关代码 position-independent code)

  8. 共享库是内存映射文件的一种特例,核心思想是进程可以发起一个系统调用,将一个文件映射到其虚拟地址空间的一部分,在多数实现中在映射共享的页面时不会实际读入页面的内容,而是在访问时被每次一页的读入,磁盘文件被当作后背存储。当进程退出或显示的接触文件时,所有被改动的页面会被写入到文件中。

  9. 清除策略,发生缺页中断时有大量的空闲页框,此时的分页系统在最佳状态,有一个分页守护(paging daemon)的后台进程,它在多数时候睡眠,但会被定期唤醒,如果空闲页框过少,分页守护进程通过预定的页面置换算法选择页面换出内存。

  10. 虚拟内存接口,在一些高级系统中,程序员可以对内存映射进行控制,允许控制的原因是为了允许两个或多个进程共享一部分内存。页面共享可以用来实现高性能的消息传递系统。

Linux内存管理

内核空间

页(page)是内核的内存管理的基本单位

struct page {
 page_flags_t flags;  页标志符
 atomic_t _count;    页引用计数
 atomic_t _mapcount;     页映射计数
 unsigned long private;    私有数据指针
 struct address_space *mapping;    该页所在地址空间描述结构指针,用于内容为文件的页帧
 pgoff_t index;               该页描述结构在地址空间radix树page_tree中的对象索引号即页号
 struct list_head lru;        最近最久未使用struct slab结构指针链表头变量
 void *virtual;               页虚拟地址
};
  • flags:页标志包含是不是脏的,是否被锁定等等,每一位单独表示一种状态,可同时表示出32种不同状态,定义在<linux/page-flags.h>

  • _count:计数值为-1表示未被使用。

  • virtual:页在虚拟内存中的地址,对于不能永久映射到内核空间的内存(比如高端内存),该值为NULL;需要事必须动态映射这些内存。

尽管处理器的最小可寻址单位通常为字或字节,但内存管理单元(MMU,把虚拟地址转换为物理地址的硬件设备)通常以页为单位处理。内核用struct page结构体表示每个物理页,struct page结构体占40个字节,假定系统物理页大小为4KB,对于4GB物理内存,1M个页面,故所有的页面page结构体共占有内存大小为40MB,相对系统4G,这个代价并不高。

内核把页划分在不同的区(zone)

总共3个区,具体如下:

描述 物理内存(MB)
ZONE_DMA DMA使用的页 <16
ZONE_NORMAL 可正常寻址的页 16 ~896
ZONE_HIGHMEM 动态映射的页 >896
  • 执行DMA操作的内存必须从ZONE_DMA区分配

  • 一般内存,既可从ZONE_DMA,也可从ZONE_NORMAL分配,但不能同时从两个区分配;

用户空间

用户空间中进程的内存,往往称为进程地址空间

Linux采用虚拟内存技术。进程的内存空间只是虚拟内存(或者叫作逻辑内存),而程序的运行需要的是实实在在的内存,即物理内存(RAM)。在必要时,操作系统会将程序运行中申请的内存(虚拟内存)映射到RAM,让进程能够使用物理内存。

地址空间

每个进程都有一个32位或64位的地址空间,取决于体系结构。 一个进程的地址空间与另一个进程的地址空间即使有相同的内存地址,也彼此互不相干,对于这种共享地址空间的进程称之为线程。一个进程可寻址4GB的虚拟内存(32位地址空间中),但不是所有虚拟地址都有权访问。对于进程可访问的地址空间称为内存区域。每个内存区域都具有对相关进程的可读、可写、可执行属性等相关权限设置。

内存区域可包含的对象:

  • 代码段(text section): 可执行文件代码

  • 数据段(data section): 可执行文件的已初始化全局变量(静态分配的变量和全局变量)。

  • bss段:程序中未初始化的全局变量,零页映射(页面的信息全部为0值)。

  • 进程用户空间栈的零页映射(进程的内核栈独立存在并由内核维护)

  • 每一个诸如C库或动态连接程序等共享库的代码段、数据段和bss也会被载入进程的地址空间

  • 任何内存映射文件

  • 任何共享内存段

  • 任何匿名的内存映射(比如由malloc()分配的内存)

这些内存区域不能相互覆盖,每一个进程都有不同的内存片段。

内存描述符

内存描述符由mm_struct结构体表示

struct mm_struct {
 struct vm_area_struct *mmap;
 rb_root_t mm_rb;
 ...
 atomic_t mm_users;
 atomic_t mm_count;
​
 struct list_head mmlist;
 ...
};
  • mm_users:代表正在使用该地址的进程数目,当该值为0时mm_count也变为0;

  • mm_count: 代表mm_struct的主引用计数,当该值为0说明没有任何指向该mm_struct结构体的引用,结构体会被撤销。

  • mmap和mm_rb:描述的对象都是相同的

    • mmap以链表形式存放, 利于高效地遍历所有元素

    • mm_rb以红黑树形式存放,适合搜索指定元素

  • mmlist:所有的mm_struct结构体都通过mmlist连接在一个双向链表中,该链表的首元素是init_mm内存描述符,它代表init进程的地址空间。

在进程的进程描述符(<linux/sched.h>中定义的task_struct结构体)中,mm域记录该进程使用的内存描述符。故current->mm代表当前进程的内存描述符。

fork()函数 利用copy_mm函数复制父进程的内存描述符,子进程中的mm_struct结构体通过allcote_mm()从高速缓存中分配得到。通常,每个进程都有唯一的mm_struct结构体,即唯一的进程地址空间。

当子进程与父进程是共享地址空间,可调用clone(),那么不再调用allcote_mm(),而是仅仅是将mm域指向父进程的mm,即 tsk->mm = current->mm。

相反地,撤销内存是exit_mm()函数,该函数会进行常规的撤销工作,更新一些统计量。

内核线程

  • 没有进程地址空间,即内核线程对应的进程描述符中mm=NULL

  • 内核线程直接使用前一个进程的内存描述符,仅仅使用地址空间中和内核内存相关的信息

页表

应用程序操作的对象时映射到物理内存之上的虚拟内存,而处理器直接操作的是物理内存。故应用程序访问一个虚拟地址时,需要将虚拟地址转换为物理地址,然后处理器才能解析地址访问请求,这个转换工作通过查询页表完成。

Linux使用三级页表完成地址转换。

  1. 顶级页表:页全局目录(PGD),指向二级页目录;

  2. 二级页表:中间页目录(PMD),指向PTE中的表项;

  3. 最后一级:页表(PTE),指向物理页面。

多数体系结构,搜索页表工作由硬件完成。每个进程都有自己的页表(线程会共享页表)。为了加快搜索,实现了翻译后缓冲器(TLB),作为将虚拟地址映射到物理地址的硬件缓存。还有写时拷贝方式共享页表,当fork()时,父子进程共享页表,只有当子进程或父进程试图修改特定页表项时,内核才创建该页表项的新拷贝,之后父子进程不再共享该页表项。可见,利用共享页表可以消除fork()操作中页表拷贝所带来的消耗。

进程与内存

所有进程都必须占用一定数量的内存,这些内存用来存放从磁盘载入的程序代码,或存放来自用户输入的数据等。内存可以提前静态分配和统一回收,也可以按需动态分配和回收。

对于普通进程对应的内存空间包含5种不同的数据区:

  • 代码段

  • 数据段

  • BSS段

  • 堆:动态分配的内存段,大小不固定,可动态扩张(malloc等函数分配内存),或动态缩减(free等函数释放);

  • 栈:存放临时创建的局部变量;

进程内存空间

Linux采用虚拟内存管理技术,每个进程都有各自独立的进程地址空间(即4G的线性虚拟空间),无法直接访问物理内存。这样起到保护操作系统,并且让用户程序可使用比实际物理内存更大的地址空间。

  • 4G进程地址空间被划分两部分,内核空间和用户空间。用户空间从0到3G,内核空间从3G到4G;

  • 用户进程通常情况只能访问用户空间的虚拟地址,不能访问内核空间虚拟地址。只有用户进程进行系统调用(代表用户进程在内核态执行)等情况可访问到内核空间;

  • 用户空间对应进程,所以当进程切换,用户空间也会跟着变化;

  • 内核空间是由内核负责映射,不会跟着进程变化;内核空间地址有自己对应的页表,用户进程各自有不同额页表。

内存分配

进程分配内存,陷入内核态分别由brk和mmap完成,但这两种分配还没有分配真正的物理内存,真正分配在后面会讲。

  • brk: 数据段的最高地址指针_edata往高地址推

    • 当malloc需要分配的内存<M_MMAP_THRESHOLD(默认128k)时,采用brk;

    • brk分配的内存需高地址内存全部释放之后才会释放。(由于是通过推动指针方式)

    • 当最高地址空间的空闲内存大于M_TRIM_THRESHOLD时(默认128k),执行内存紧缩操作;

  • do_mmap:在堆栈中间的文件映射区域找空闲的虚拟内存

    • 当malloc需要分配的内存>M_MMAP_THRESHOLD(默认128k)时,采用do_map();

    • mmap分配的内存可以单独释放

物理内存

  • 物理内存只有进程真正去访问虚拟地址,发生缺页中断时,才分配实际的物理页面,建立物理内存和虚拟内存的映射关系。

  • 应用程序操作的是虚拟内存;而处理器直接操作的却是物理内存。当应用程序访问虚拟地址,必须将虚拟地址转化为物理地址,处理器才能解析地址访问请求。

  • 物理内存是通过分页机制实现的

  • 物理页在系统中由也结构struct page描述,所有的page都存储在数组mem_map[]中,可通过该数组找到系统中的每一页。

虚拟内存 转化为 真实物理内存:

  • 虚拟进程空间:通过查询进程页表,获取实际物理内存地址;

  • 虚拟内核空间:通过查询内核页表,获取实际物理内存地址;

  • 物理内存映射区:物理内存映射区与实际物理去偏移量仅PAGE_OFFSET,通过通过virt_to_phys()转化;

虚拟内存与真实物理内存映射关系:

其中物理地址空间中除了896M(ZONE_DMA + ZONE_NORMAL)的区域是绝对的物理连续,其他内存都不是物理内存连续。在虚拟内核地址空间中的安全保护区域的指针都是非法的,用于保证指针非法越界类的操作,vm_struct是连续的虚拟内核空间,对应的物理页面可以不连续,地址范围(3G + 896M + 8M) ~ 4G;另外在虚拟用户空间中 vm_area_struct同样也是一块连续的虚拟进程空间,地址空间范围0~3G。

碎片问题

  • 外部碎片:未被分配的内存,由于太多零碎的不连续小内存,无法满足当前较大内存的申请要求;

    • 原因:频繁的分配与回收物理页导致大量的小块内存夹杂在已分配页面中间;

    • 解决方案:伙伴算法有所改善

  • 内部碎片:已经分配的内存,却不能被利用的内存空间;

    • 缘由:所有内存分配必须起始可被4、8或16(体系结构决定)整除的地址或者MMU分页机制限制;

    • 解决方案:slab分配器有所改善

    • 实例:请求一个11Byte的内存块,系统可能会分配12Byte、16Byte等稍大一些的字节,这些多余空间就产生碎片

Android进程内存管理

进程的地址空间

在32位操作系统中,进程的地址空间为0到4GB,

示意图如下:

这里主要说明一下Stack和Heap:

Stack空间(进栈和出栈)由操作系统控制,其中主要存储函数地址、函数参数、局部变量等等,所以Stack空间不需要很大,一般为几MB大小。

Heap空间的使用由程序员控制,程序员可以使用malloc、new、free、delete等函数调用来操作这片地址空间。Heap为程序完成各种复杂任务提供内存空间,所以空间比较大,一般为几百MB到几GB。正是因为Heap空间由程序员管理,所以容易出现使用不当导致严重问题。

进程内存空间和RAM之间的关系

进程的内存空间只是虚拟内存(或者叫作逻辑内存),而程序的运行需要的是实实在在的内存,即物理内存(RAM)。在必要时,操作系统会将程序运行中申请的内存(虚拟内存)映射到RAM,让进程能够使用物理内存。

RAM作为进程运行不可或缺的资源,对系统性能和稳定性有着决定性影响。另外,RAM的一部分被操作系统留作他用,比如显存等等,内存映射和显存等都是由操作系统控制,我们也不必过多地关注它,进程所操作的空间都是虚拟地址空间,无法直接操作RAM。

示意图如下:

Android中的进程

Native进程:采用C/C++实现,不包含Dalvik实例的Linux进程,/system/bin/目录下面的程序文件运行后都是以native进程形式存在的。上图 /system/bin/surfaceflinger、/system/bin/rild、procrank等就是Native进程。

Java进程:实例化了Dalvik虚拟机实例的Linux进程,进程的入口main函数为Java函数。Dalvik虚拟机实例的宿主进程是fork()系统调用创建的Linux进程,所以每一个Android上的Java进程实际上就是一个Linux进程,只是进程中多了一个Dalvik虚拟机实例。因此,Java进程的内存分配比Native进程复杂。下图,Android系统中的应用程序基本都是Java进程,如桌面、电话、联系人、状态栏等等。

Android中进程的堆内存

进程空间中的Heap空间是我们需要重点关注的。Heap空间完全由程序员控制,我们使用的C Malloc、C++ new和Java new所申请的空间都是Heap空间, C/C++申请的内存空间在Native Heap中,而Java申请的内存空间则在Dalvik Heap中。

Android的 Java程序为什么容易出现OOM

这个是因为Android系统对Dalvik的VM Heapsize作了硬性限制,当java进程申请的Java空间超过阈值时,就会抛出OOM异常(这个阈值可以是48M、24M、16M等,视机型而定),可以通过adb shell getprop | grep dalvik.vm.heapgrowthlimit查看此值。

也就是说,程序发生OMM并不表示RAM不足,而是因为程序申请的Java Heap对象超过了Dalvik VM Heap Growth Limit。也就是说,在RAM充足的情况下,也可能发生OOM。

这样的设计似乎有些不合理,但是Google为什么这样做呢?这样设计的目的是为了让Android系统能同时让比较多的进程常驻内存,这样程序启动时就不用每次都重新加载到内存,能够给用户更快的响应。迫使每个应用程序使用较小的内存,移动设备非常有限的RAM就能使比较多的App常驻其中。但是有一些大型应用程序是无法忍受VM Heap Growth Limit的限制的。

Android如何应对RAM不足

Java程序发生OMM并不是表示RAM不足,如果RAM真的不足,会发生什么呢?这时Android的Memory Killer会起作用,当RAM所剩不多时,Memory Killer会杀死一些优先级比较低的进程来释放物理内存,让高优先级程序得到更多的内存。

应用程序如何绕过DalvikVM Heap Size的限制

对于一些大型的应用程序(比如游戏),内存使用会比较多,很容易超超出VM Heapsize的限制,这时怎么保证程序不会因为OOM而崩溃呢?

创建子进程

创建一个新的进程,那么我们就可以把一些对象分配到新进程的Heap上了,从而达到一个应用程序使用更多的内存的目的,当然,创建子进程会增加系统开销,而且并不是所有应用程序都适合这样做,视需求而定。

创建子进程的方法:使用android:process标签

使用JNI在Native Heap上申请空间(推荐使用)

Native Heap的增长并不受Dalvik VM heapsize的限制,它的Native Heap Size已经远远超过了Dalvik Heap Size的限制。

只要RAM有剩余空间,程序员可以一直在Native Heap上申请空间,当然如果RAM快耗尽,Memory Killer会杀进程释放RAM。大家使用一些软件时,有时候会闪退,就可能是软件在Native层申请了比较多的内存导致的。比如,我就碰到过UC Web在浏览内容比较多的网页时闪退,原因就是其Native Heap增长到比较大的值,占用了大量的RAM,被Memory Killer杀掉了。

使用显存(操作系统预留RAM的一部分作为显存)

使用OpenGL Textures等API,Texture Memory不受Dalvik VM Heapsize限制。再比如Android中的GraphicBufferAllocator申请的内存就是显存。

Bitmap分配在Native Heap还是Dalvik Heap上?

一种流行的观点是这样的:

Bitmap是JNI层创建的,所以它应该是分配到Native Heap上,并且为了解释Bitmap容易导致OOM,提出了这样的观点:native heap size + dalvik heapsize <= dalvik vm heapsize。但是Native Heap Size远远超过Dalvik VM Heap Size,所以,事实证明以上观点是不正确的。

正确的观点:

大家都知道,过多地创建Bitmap会导致OOM异常,且Native Heap Size不受Dalvik限制,所以可以得出结论:

Bitmap只能是分配在Dalvik Heap上的,因为只有这样才能解释Bitmap容易导致OOM。

Android内存管理机制

从操作系统的角度来说,内存就是一块数据存储区域,是可被操作系统调度的资源。在多任务(进程)的OS中,内存管理尤为重要,OS需要为每一个进程合理的分配内存资源。所以可以从OS对内存和回收两方面来理解内存管理机制。

  • 分配机制:为每一个任务(进程)分配一个合理大小的内存块,保证每一个进程能够正常的运行,同时确保进程不会占用太多的内存。
  • 回收机制:当系统内存不足的时候,需要有一个合理的回收再分配机制,以保证新的进程可以正常运行。回收时杀死那些正在占用内存的进程,OS需要提供一个合理的杀死进程机制。

同样作为一个多任务的操作系统,Android系统对内存管理有有一套自己的方法,手机上的内存资源比PC更少,需要更加谨慎的管理内存。理解Android的内存分配机制有助于我们写出更高效的代码,提高应用的性能。

下面分别从**分配 回收 **两方面来描述Android的内存管理机制:

分配机制

Android为每个进程分配内存时,采用弹性的分配方式,即刚开始并不会给应用分配很多的内存,而是给每一个进程分配一个“够用”的内存大小。这个大小值是根据每一个设备的实际的物理内存大小来决定的。随着应用的运行和使用,Android会为进程分配一些额外的内存大小。但是分配的大小是有限度的,系统不可能为每一个应用分配无限大小的内存。

总之,Android系统需要最大限度的让更多的进程存活在内存中,以保证用户再次打开应用时减少应用的启动时间,提高用户体验。

回收机制

Android对内存的使用方式是“尽最大限度的使用”,只有当内存水足的时候,才会杀死其它进程来回收足够的内存。但Android系统否可能随便的杀死一个进程,它也有一个机制杀死进程来回收内存。

Android杀死进程有两个参考条件:

1. 回收收益

当Android系统开始杀死LRU缓存中的进程时,系统会判断每个进程杀死后带来的回收收益。因为Android总是倾向于杀死一个能回收更多内存的进程,从而可以杀死更少的进程,来获取更多的内存。杀死的进程越少,对用户体验的影响就越小。

2. 进程优先级

下面将从 Application Framework 和 Linux kernel 两个层次分析 Android 操作系统的资源管理机制。

Android 之所以采用特殊的资源管理机制,原因在于其设计之初就是面向移动终端,所有可用的内存仅限于系统 RAM,必须针对这种限制设计相应的优化方案。当 Android 应用程序退出时,并不清理其所占用的内存,Linux 内核进程也相应的继续存在,所谓“退出但不关闭”。从而使得用户调用程序时能够在第一时间得到响应。当系统内存不足时,系统将激活内存回收过程。为了不因内存回收影响用户体验(如杀死当前的活动进程),Android 基于进程中运行的组件及其状态规定了默认的五个回收优先级:

Android为每一个进程分配了优先组的概念,优先组越低的进程,被杀死的概率就越大。根据进程的重要性,划分为5级:

1)前台进程(Foreground process)

用户当前操作所必需的进程。通常在任意给定时间前台进程都为数不多。只有在内存不足以支持它们同时继续运行这一万不得已的情况下,系统才会终止它们。

2)可见进程(Visible process)

没有任何前台组件、但仍会影响用户在屏幕上所见内容的进程。可见进程被视为是极其重要的进程,除非为了维持所有前台进程同时运行而必须终止,否则系统不会终止这些进程。

3)服务进程(Service process)

尽管服务进程与用户所见内容没有直接关联,但是它们通常在执行一些用户关心的操作(例如,在后台播放音乐或从网络下载数据)。因此,除非内存不足以维持所有前台进程和可见进程同时运行,否则系统会让服务进程保持运行状态。

4)后台进程(Background process)

后台进程对用户体验没有直接影响,系统可能随时终止它们,以回收内存供前台进程、可见进程或服务进程使用。 通常会有很多后台进程在运行,因此它们会保存在 LRU 列表中,以确保包含用户最近查看的 Activity 的进程最后一个被终止。如果某个 Activity 正确实现了生命周期方法,并保存了其当前状态,则终止其进程不会对用户体验产生明显影响,因为当用户导航回该 Activity 时,Activity 会恢复其所有可见状态。

5)空进程(Empty process)

不含任何活动应用组件的进程。保留这种进程的的唯一目的是用作缓存,以缩短下次在其中运行组件所需的启动时间。 为使总体系统资源在进程缓存和底层内核缓存之间保持平衡,系统往往会终止这些进程。

通常,前面三种进程不会被杀死。

ActivityManagerService 集中管理所有进程的内存资源分配。所有进程需要申请或释放内存之前必须调用 ActivityManagerService 对象,获得其“许可”之后才能进行下一步操作,或者 ActivityManagerService 将直接“代劳”。ActivityManagerService类中涉及到内存回收的几个重要的成员方法如下:trimApplications()、updateOomAdjLocked()、activityIdleInternal() 。这几个成员方法主要负责 Android 默认的内存回收机制,若 Linux 内核中的内存回收机制没有被禁用,则跳过默认回收。

默认回收过程

Android 操作系统中的内存回收可分为两个层次,即默认内存回收与Linux内核级内存回收,所有代码可参见 ActivityManagerService.java。

回收动作入口:activityIdleInternal()

Android 系统中内存回收的触发点大致可分为三种情况。第一,用户程序调用 StartActivity(), 使当前活动的 Activity 被覆盖;第二,用户按 back 键,退出当前应用程序;第三,启动一个新的应用程序。这些能够触发内存回收的事件最终调用的函数接口就是 activityIdleInternal()。当 ActivityManagerService 接收到异步消息 IDLE_TIMEOUT_MSG 或者 IDLE_NOW_MSG 时,activityIdleInternal() 将会被调用。

推荐阅读更多精彩内容