《设计模式》课程第4讲 - 内存管理

主讲:王亚沙 · 北京大学软件研究所 · 2004-10 课程:0C107 设计模式 来源:田浩然上传的资料/0C107设计模式/ss-04(内存管理).pdf


一、课程定位

本讲是设计模式课程的第4讲(ss-04),主题为内存管理。虽然名为”内存管理”,但内容聚焦于 Win32 平台下的 Windows 内存管理机制,从底层虚拟内存到上层 C++ new/delete 全链路梳理,是理解 Windows 平台程序性能、内存泄漏、DLL 加载等问题的基础。

前置内容:


二、内存管理历史与发展

Win16 → Win32 的变迁

  • Win16 时代:使用 GlobalAlloc、GlobalLock 等函数完成大量基本维护工作
  • Win32 时代:这些函数仅出于向后兼容保留,底层工作方式已完全不同,同时新增了许多函数

核心变化:Win16 的全局堆和句柄管理机制在 Win32 中被虚拟内存管理器取代,Global* 函数退化为兼容层。


三、进程与进程内存空间

3.1 进程的基本概念

  • 进程 = 运行中的程序,一个程序启动两次就是两个独立进程
  • 每个进程拥有自己的内存、文件句柄和其他系统资源
  • 任务栏显示的是主窗口而非进程:一个进程可拥有多个主窗口,每个主窗口由一个线程支持,有些进程根本没有主窗口

3.2 Windows 95/98 进程地址空间(4GB 虚拟地址)

地址范围内容特性
0x00000000 ~ 0x7FFFFFFF(低2GB)栈、堆、可读写全局内存、EXE、DLL真正私有,最底下4MB禁止访问
0x80000000 ~ 0xBFFFFFFF(1GB)Windows DLL、内存映射文件所有进程共享
0xC0000000 ~ 0xFFFFFFFF(1GB)Win95/98内核、执行体、VxD、文件系统、页表所有进程共享

3.3 Win95/98 安全性问题

  • 低2GB私有 → 进程无法改写另一进程的栈/全局/堆
  • EXE/DLL 代码标记为只读 → 多进程映射同一份物理内存无问题
  • 但上1GB系统空间脆弱:一个错误程序可能毁坏系统表
  • 内存映射文件在共享区:一个进程可能弄乱另一个进程的内存映射文件

3.4 Windows NT/2000/XP 进程地址空间

地址范围内容特性
低2GB(用户态)用户代码、数据、堆、栈进程私有,最低和最高各64KB不可访问
高2GB(内核态)内核、执行体、设备驱动完全保护,不受错误程序影响
内存映射文件也是安全的

关键差异:NT 系列将内核完全隔离在高2GB,比 Win9x 安全得多。


四、虚拟内存工作机理

4.1 页式管理

  • 页面 = 4KB 单元,用于保存代码或数据
  • 页面被使用时才占用物理内存,用户态看不到物理地址
  • Intel 32位架构:两级4KB页表完成虚拟地址→物理地址映射
  • 页表保存页面状态:是否在内存、数据是否有效、只读/可读写等

4.2 页面调度

  • Windows 虚拟内存管理器负责页面调度,根据读写方式优化调度
  • 频繁页交换(抖动)→ 程序执行缓慢
  • 系统交换文件可多个(NT/2000/XP)
  • EXE/DLL 代码直接映射到磁盘文件,不使用交换文件(因为只读,无需写回)

4.3 写时复制(Copy-on-Write)

同一 EXE 的两个进程:

  • 代码和常量 → 映射到同一份物理内存(只读,安全)
  • Win95/98 → 为每个进程映射全局数据的一份拷贝
  • NT/2000/XP → 写时复制优化:两进程共享全局数据每页,直到某个进程企图写该页时才复制

4.4 DLL 基地址与重定位

  • DLL 只有装入到指定基地址时才能直接映射到文件
  • 如果基地址已被占用,Windows 必须”修订”DLL 代码中的地址(重定位)
    • NT/2000/XP:DLL 第一次装入时把修改后的代码页拷贝到交换文件
    • Win95/98:页装入 RAM 时才做修订
  • DLL 设计要点:避免地址范围重叠很重要
    • 例:MFC DLL 地址范围为 0x5F400000 ~ 0x5FFFFFFF

五、Win32 虚拟内存管理 API

5.1 VirtualAlloc —— 保留与占用

两种模式:

模式作用说明
MEM_RESERVE(保留)预留连续虚拟地址空间以64KB为边界,不分配RAM,不占交换文件
MEM_COMMIT(占用)实际分配物理存储4KB边界,分配交换文件页和页表
  • 可以指定起始地址,也可让系统分配
  • 先保留后占用的两步模式可按需分配,避免浪费

5.2 VirtualFree —— 收回与释放

  • decommit:将已占用页收回为保留状态
  • release:释放整个保留内存区域(必须指定基地址)

六、Windows 堆(Heap)

6.1 堆的概念

  • 堆 = 进程的内存池
  • 堆管理器使用已有页,或调用 VirtualAlloc 获取更多页
  • 每个进程有一个默认堆,也可创建自己的堆(HeapCreate / HeapDestroy)
  • HeapAlloc / HeapFree 操作堆内存
  • Win32 中不必再使用 GlobalAlloc(它只是兼容层)

6.2 GlobalAlloc 的真相

标志实现
MEM_FIXED直接调用 HeapAlloc,返回32位指针
MEM_MOVEABLE返回句柄表入口指针,该入口保存实际内存指针(二级索引,纯为Win16兼容)

使用 HGLOBAL 的注意事项:

  • 拿到 HGLOBAL 必须假设是 MEM_MOVEABLE 的,要用 GlobalLock 获取指针,用完 GlobalUnlock
  • 作为参数传 HGLOBAL 时,最好用 GMEM_MOVEABLE 分配,以防函数内部调用 GlobalReAlloc

七、C++ 的 new / delete 与 CRT 堆

7.1 映射关系

new / delete
    ↓
malloc / free
    ↓
大于阈值 → HeapAlloc(CRT堆)
小于阈值 → CRT小内存块堆

7.2 CRT 小内存块堆维护算法

  1. 按 4MB 区域保留内存
  2. 按 64KB 块(16页) 占用内存
  3. 每次收回 64KB。当 128KB 空闲时,最后的 64KB 被收回
  4. 当 4MB 区域中每页都被收回时,释放整个区域

可调参数:

  • _set_sbh_threshold / _get_sbh_threshold —— 改变小内存块阈值

7.3 页回收(heapmin)

  • CRT 的 Windows 堆不会自动收回页和解除保留
  • 清除大块内存需调用 _heapmin → 内部调用 HeapCompact
  • Win95/98 的 HeapCompact 什么也不做(又是用 NT 的理由)
  • 页收回后,交换文件空间可被其他程序重新使用

7.4 调试版本:_malloc_dbg

  • _malloc_dbg:malloc 的调试版,在分配块中加入调试信息
  • _DEBUG 宏编译 MFC 时,new 会调用 _malloc_dbg
  • 可检测:忘记释放的内存块、无意中被改写的内存块(越界)

八、内存映射文件(Memory-Mapped Files)

8.1 原理

  • 将地址范围直接映射到文件
  • 进程访问内存页时,Windows 分配 RAM 并从磁盘读入
  • 默认映射整个文件(也可只映射一部分)

8.2 共享内存

  • 两个进程共享同一个文件映射对象 → 文件本身就是共享内存
  • Win32 中优先采用的共享内存方式
  • 不需要永久磁盘文件的共享内存:CreateFileMapping 时 hFile = 0xFFFFFFFF → 映射到交换文件页
  • MFC 不支持内存映射文件/共享内存(CSharedFile 只支持 HGLOBAL 剪贴板传输)

8.3 资源访问

  • 资源包含在 EXE/DLL 中,占用虚拟地址空间,进程生命期不变
  • 可以直接访问资源(如直接获取 DIB 位图地址),无需拷贝

九、动态内存管理实践建议

9.1 堆碎片问题

  • 越频繁使用堆 → 碎片越多 → 效率越低
  • 极端策略:启动时分配所有内存,退出时释放
  • CString 频繁分配释放小内存,是碎片大户

9.2 显式回收

  • 分配大于小内存块阈值的块后,调用 _heapmin 回收
  • 记住内存来源:不要用 HeapFree 释放 new 申请的小内存块

9.3 栈上分配

  • 32位环境栈不再有 64KB 限制
  • 大的数据对象可放栈中,降低堆分配压力

十、常量数据优化存储

10.1 PE 文件节区对比

节区类型存取内容映射位置
.text代码只读程序代码EXE/DLL 文件(不占交换文件)
.rdata数据只读常量初始化数据EXE/DLL 文件(不占交换文件)
.data数据读/写非常量初始化数据交换文件(每进程一份)
.bss数据读/写非常量未初始化数据交换文件(每进程一份)

10.2 优化原则

  • .rdata 是 EXE 文件的一部分 → 用 const 修饰的数据可被放入 .rdata,节省交换文件空间
  • 内置类型/结构体可放 .rdata,但有构造函数的 C++ 对象不行
  • 未初始化的全局对象 → .bss → 每进程单独映射
  • CString 对象小但在 .bss,字符数组在 .data,都不能映射到 EXE
    • 更好的做法:用 const 字符数组代替 CString 对象

十一、知识脉络与关联

与设计模式的关联

本讲是设计模式课程的基础设施铺垫:

  • 内存管理理解 → 正确实现 Singleton、Flyweight、Object Pool 等创建型/结构型模式
  • DLL 基地址与重定位 → 理解 Bridge、Proxy 等模式在 Windows 平台的实际约束
  • 写时复制 → COW(Copy-On-Write)模式 的底层实现参考

与其他知识的关联

与项目的关联

  • 共聚焦/流式细胞仪软件的内存分配优化(图像缓冲、大数组分配)
  • 堆碎片对长时间运行的医疗仪器软件的性能影响
  • DLL 基地址设置对启动速度的影响

十二、核心要点速记

  1. Win32 每个进程 4GB 虚拟地址空间,低2GB用户态私有,高2GB内核态(NT完全隔离,Win9x只隔离1GB)
  2. 页面 4KB,两级页表,虚拟地址→物理地址翻译
  3. 写时复制(COW):多进程共享页面直到写入才复制
  4. VirtualAlloc 两步:先保留(MEM_RESERVE)后占用(MEM_COMMIT)
  5. GlobalAlloc 是兼容层,底层还是 HeapAlloc
  6. CRT 小内存块堆:4MB区域→64KB块→按需占用,_heapmin 手动回收
  7. 内存映射文件是 Win32 优先采用的共享内存方式,MFC 不原生支持
  8. const 数据进 .rdata → 映射到 EXE 文件,节省交换文件空间
  9. 堆碎片是性能杀手,频繁分配释放的小对象是元凶