操作系统讲演
Table of Contents
1. Comparing Linux and Android
1.1. 进程管理
1.1.1. Android
1.1.1.1. 进程概述
通俗地讲一个进程代表一个应用程序,该应用程序运行在自己的进程当中,使用系统为其分配的堆内存,不受其他应用程序或者是其他进程的影响,是独立运行的
当然一个进程中可以同时运行多个应用程序,这时堆内存是共享的。
在Android 系统中一个进程会对应一个虚拟机实例。
虚拟机实例的运存在 /system/build.prop
文件里面中配置,该文件在一般情况下是不可修改的,
所以 Android 在应用程序配置文件 AndroidManifest.xml
中的 Application 节点下可设置属性 largeheap="true" 来使用最大的内存。
1.1.1.2. 进程模型
每个应用一个 id
在 Linux 中,一个用户ID 识别一个给定用户;在 Android 上,一个用户ID 识别一个应用程序。
应用程序在安装时被分配用户 ID,应用程序在设备上的存续期间内,用户ID 保持不变。并设置相应的权限,这样其它应用程序就不能访问此应用程序所拥有的数据和资源了
默认情况下,每个apk运行在它自己的Linux进程中。当需要执行应用程序中的代码时,Android会启动一个虚拟机,即一个新的进程来执行,因此不同的apk运行在相互隔离的环境中。
下图显示了两个 Android 应用程序,各自在其自己的基本沙箱或进程上。他们是不同的Linux user ID。
共享 id
开发者也可以给两个应用程序分配相同的linux用户id,这样他们就能访问对方所拥有的资源。
假如两个应用程序的用户ID是一样的,那么这两个应用程序将运行在同一个进程中,这就是一个进程中存在多个应用程序的情况,
此时这两个应用程序使用同一份堆内存,使用同一个虚拟机。要实现这个功能,首先必须使用相同的私钥签署这些应用程序,
然后必须使用 AndroidManifest.xml
文件给它们分配相同的Linux用户ID,这通过在manifest节点下得属性 android:shared UserId
来实现。
如下图,显示了两个 Android 应用程序,运行在同一进程上。
1.1.1.3. 进程管理
生命周期
Android系统为每个应用程序分配了一个进程,应用程序中组件( Activity Service BroadCast )的状态决定的一个进程的“重要性层次”,
层次最低的属于旧进程。这个“重要性层次”有五个等级,也就是进程的生命周期,按最高层次到最低层次排列如下:
- 前台进程
正在与用户进行交互的进程 - 可见进程
可在屏幕上显示但不在前台运行,比如一个前台进程以对话框的形式显示在该进程前面。典型的如输入法。 - 服务进程
正在运行已使用 startService() 方法启动的服务且不属于上述两个更高类别进程的进程。
尽管服务进程与用户所见内容没有直接关联,但是它们通常在执行一些用户关心的操作(例如,在后台播放音乐或从网络下载数据) - 后台进程
包含目前对用户不可见的 Activity 的进程(已调用 Activity 的 onStop() 方法)。
这些进程对用户体验没有直接影响,系统可能随时终止它们,以回收内存供前台进程、可见进程或服务进程使用。
一旦按home键(注意不是back键)返回到桌面,程序就停留在后台,成为后台进程。 - 空进程
不含任何活动应用组件的进程。
保留这种进程的的唯一目的是用作缓存,以缩短下次在其中运行组件所需的启动时间。
一般来说,当应用按back按键退出后应用后,就变成了一个空进程。
进程回收
内存的回收包括进程内的回收和进程级的回收,前者是通过进程虚拟机的GC机制实现的,后者则是Linux内核或者安卓系统实现的,如图:
在安卓中,我们主要关注LowMemoryKiller,也就是所谓的LMK。
1.1.2. Linux
1.1.2.1. 进程概述
- Linux 是一种动态系统,能够适应不断变化的计算需求
- Linux 计算需求的表现是以进程 的通用抽象为中心的
- 进程可以是短期的(从命令行执行的一个命令),也可以是长期的(一种网络服务)
- 因此,对进程及其调度进行一般管理就显得极为重要
- 在用户空间,进程是由进程标识符(PID)表示的
- 从用户的角度来看,一个 PID 是一个数字值,可惟一标识一个进程
- 一个 PID 在进程的整个生命期间不会更改,但 PID 可以在进程销毁后被重新使用,所以对它们进行缓存并不见得总是理想的
- 在用户空间,创建进程可以采用几种方式
- 可以执行一个程序(这会导致新进程的创建),也可以在程序内,调用一个 fork 或 exec 系统调用
- fork 调用会导致创建一个子进程,而 exec 调用则会用新程序代替当前进程上下文
1.1.2.2. 进程模型
内核把进程的列表存放在任务队列(task list)中(双向循环链表), 链表的每一项类型为taskstruct,我们称之为进程描述符(process descriptor)
进程的信息主要保存在taskstruct中(位于 include/linux/sched.h)
通过 task_struct
和 thread_info
存放和表示进程
1.1.2.3. 进程管理
生命周期
进程有5种状态
TASK_RUNNING
TASK_INTERRUPTIBLE
/TASK_UNINTERRUPTIBLE
TASK_RUNNING
TASK_TRACED
TASK_STOPPED
1.2. 存储管理
1.2.1. Android
Android 运行时 (ART) 和 Dalvik 虚拟机使用 分页 和 内存映射 来管理内存
这意味着应用修改的任何内存,无论修改的方式是分配新对象还是轻触内存映射的页面,都会一直驻留在 RAM 中,并且无法换出
要从应用中释放内存,只能释放应用保留的对象引用,使内存可供垃圾回收器回收
这种情况有一个例外:对于任何未经修改的内存映射文件(如代码),如果系统想要在其他位置使用其内存,可将其从 RAM 中换出。
1.2.1.1. 垃圾回收
ART 或 Dalvik 虚拟机之类的受管内存环境会跟踪每次内存分配。
一旦确定程序不再使用某块内存,它就会将该内存重新释放到堆中,无需程序员进行任何干预。
这种回收受管内存环境中的未使用内存的机制称为“垃圾回收”。
垃圾回收有两个目标:在程序中查找将来无法访问的数据对象,并回收这些对象使用的资源。
Android 的内存堆是分代的,这意味着它会根据分配对象的预期寿命和大小跟踪不同的分配存储分区。
例如,最近分配的对象属于“新生代”。
当某个对象保持活动状态达足够长的时间时,可将其提升为较老代,然后是永久代。
堆的每一代对相应对象可占用的内存量都有其自身的专用上限。每当一代开始填满时,系统便会执行垃圾回收事件以释放内存。
垃圾回收的持续时间取决于它回收的是哪一代对象以及每一代有多少个活动对象。
尽管垃圾回收速度非常快,但仍会影响应用的性能。通常情况下,您无法从代码中控制何时发生垃圾回收事件。
系统有一套专门确定何时执行垃圾回收的标准。当条件满足时,系统会停止执行进程并开始垃圾回收。
如果在动画或音乐播放等密集型处理循环过程中发生垃圾回收,则可能会增加处理时间,进而可能会导致应用中的代码执行超出建议的 16ms 阈值,无法实现高效、流畅的帧渲染。
此外,您的代码流执行的各种工作可能迫使垃圾回收事件发生得更频繁或导致其持续时间超过正常范围。
例如,如果您在 Alpha 混合动画的每一帧期间,在 for 循环的最内层分配多个对象,则可能会使内存堆受到大量对象的影响。
在这种情况下,垃圾回收器会执行多个垃圾回收事件,并可能降低应用的性能。
1.2.1.2. 共享内存
为了在 RAM 中容纳所需的一切,Android 会尝试跨进程共享 RAM 页面。它可以通过以下方式实现这一点:
- 每个应用进程都从一个名为 Zygote 的现有进程分叉。
系统启动并加载通用框架代码和资源(如 Activity 主题背景)时,Zygote 进程随之启动。
为启动新的应用进程,系统会分叉 Zygote 进程,然后在新进程中加载并运行应用代码。
这种方法使为框架代码和资源分配的大多数 RAM 页面可在所有应用进程之间共享。 - 大多数静态数据会内存映射到一个进程中。
这种方法使得数据不仅可以在进程之间共享,还可以在需要时换出。
静态数据示例包括:Dalvik 代码(通过将其放入预先链接的 .odex 文件中进行直接内存映射)、
应用资源(通过将资源表格设计为可内存映射的结构以及通过对齐 APK 的 zip 条目)和传统项目元素(如 .so 文件中的原生代码)。 - 在很多地方,Android 使用明确分配的共享内存区域(通过 ashmem 或 gralloc)在进程间共享同一动态 RAM。
例如,窗口 surface 使用在应用和屏幕合成器之间共享的内存,而光标缓冲区则使用在内容提供器和客户端之间共享的内存。
由于共享内存的广泛使用,在确定应用使用的内存量时需要小心谨慎
1.2.1.3. 分配与回收应用内存
Dalvik 堆局限于每个应用进程的单个虚拟内存范围。
这定义了逻辑堆大小,该大小可以根据需要增长,但不能超过系统为每个应用定义的上限。
堆的逻辑大小与堆使用的物理内存量不同。
在检查应用堆时,Android 会计算按比例分摊的内存大小 (PSS) 值,该值同时考虑与其他进程共享的脏页和干净页,但其数量与共享该 RAM 的应用数量成正比。
此 (PSS) 总量是系统认为的物理内存占用量。
Dalvik 堆不压缩堆的逻辑大小,这意味着 Android 不会对堆进行碎片整理来缩减空间。
只有当堆末尾存在未使用的空间时,Android 才能缩减逻辑堆大小。
但是,系统仍然可以减少堆使用的物理内存。
垃圾回收之后,Dalvik 遍历堆并查找未使用的页面,然后使用 madvise 将这些页面返回给内核。
因此,大数据块的配对分配和解除分配应该使所有(或几乎所有)使用的物理内存被回收。
但是,从较小分配量中回收内存的效率要低得多,因为用于较小分配量的页面可能仍在与其他尚未释放的数据块共享。
1.2.1.4. 限制应用内存
为了维持多任务环境的正常运行,Android 会为每个应用的堆大小设置硬性上限。
不同设备的确切堆大小上限取决于设备的总体可用 RAM 大小。
如果您的应用在达到堆容量上限后尝试分配更多内存,则可能会收到 OutOfMemoryError。
在某些情况下,例如,为了确定在缓存中保存多少数据比较安全,您可能需要查询系统以确定当前设备上确切可用的堆空间大小。
您可以通过调用 getMemoryClass() 向系统查询此数值。此方法返回一个整数,表示应用堆的可用兆字节数。
1.2.1.5. 切换应用
当用户在应用之间切换时,Android 会将非前台应用保留在缓存中。
非前台应用就是指用户看不到或未运行前台服务(如音乐播放)的应用。
例如,当用户首次启动某个应用时,系统会为其创建一个进程;但是当用户离开此应用时,该进程不会退出。
系统会将该进程保留在缓存中。如果用户稍后返回该应用,系统就会重复使用该进程,从而加快应用切换速度。
如果您的应用具有缓存的进程且保留了目前不需要的资源,那么即使用户未使用您的应用,它也会影响系统的整体性能。
当系统资源(如内存)不足时,它将会终止缓存中的进程。系统还会考虑终止占用最多内存的进程以释放 RAM。
1.2.2. Linux
1.2.2.1. Intel 处理器的内存管理
早期 Intel 的处理器从 80286 开始使用的是段式内存管理。
但是很快发现,光有段式内存管理而没有页式内存管理是不够的,这会使它的 X86 系列会失去市场的竞争力。
因此,在不久以后的 80386 中就实现了对页式内存管理。也就是说,80386 除了完成并完善从 80286 开始的段式内存管理的同时还实现了页式内存管理。
但是这个 80386 的页式内存管理设计时,没有绕开段式内存管理,而是建立在段式内存管理的基础上,
这就意味着,页式内存管理的作用是在由段式内存管理所映射而成的地址上再加上一层地址映射。
由于此时由段式内存管理映射而成的地址不再是“物理地址”了,Intel 就称之为“线性地址”(也称虚拟地址)
于是,段式内存管理先将逻辑地址映射成线性地址,然后再由页式内存管理将线性地址映射成物理地址。
1.2.2.2. Linux 内存管理
Linux 内存主要采用的是页式内存管理,但同时也不可避免地涉及了段机制
这主要是上面 Intel 处理器发展历史导致的,因为 Intel X86 CPU 一律对程序中使用的地址先进行段式映射,然后才能进行页式映射
既然 CPU 的硬件结构是这样,Linux 内核也只好服从 Intel 的选择
但是事实上,Linux 内核所采取的办法是使段式映射的过程实际上不起什么作用
也就是说,“上有政策,下有对策”,若惹不起就躲着走。
Linux 系统中的每个段都是从 0 地址开始的整个 4GB 虚拟空间(32 位环境下),也就是所有的段的起始地址都是一样的
这意味着,Linux 系统中的代码,包括操作系统本身的代码和应用程序代码,所面对的地址空间都是线性地址空间(虚拟地址),
这种做法相当于屏蔽了处理器中的逻辑地址概念,段只被用于访问控制和内存保护。