《设计模式》课程第4讲 - 内存管理
主讲:王亚沙 · 北京大学软件研究所 · 2004-10 课程:0C107 设计模式 来源:田浩然上传的资料/0C107设计模式/ss-04(内存管理).pdf
一、课程定位
本讲是设计模式课程的第4讲(ss-04),主题为内存管理。虽然名为”内存管理”,但内容聚焦于 Win32 平台下的 Windows 内存管理机制,从底层虚拟内存到上层 C++ new/delete 全链路梳理,是理解 Windows 平台程序性能、内存泄漏、DLL 加载等问题的基础。
前置内容:
- 设计模式课程第1讲-概述(ss-01)
- 设计模式课程第2讲-C++基础(ss-02)
- 设计模式课程第3讲-设计原则(ss-03)
- 设计模式课程第4讲-Pattern 1(ss-04 Pattern 1,内存管理的设计模式应用)
二、内存管理历史与发展
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
- 例:MFC DLL 地址范围为
五、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 小内存块堆维护算法
- 按 4MB 区域保留内存
- 按 64KB 块(16页) 占用内存
- 每次收回 64KB。当 128KB 空闲时,最后的 64KB 被收回
- 当 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)模式 的底层实现参考
与其他知识的关联
- 《深入理解计算机系统》-CSAPP —— 虚拟内存、页表、地址翻译的系统级讲解(更底层、更通用)
- 《结构化计算机组成》-Structured-Computer-Organization-第6版-Tanenbaum —— 内存层次结构
- 《程序员的自我修养》-链接、装载与库 —— 链接、装载、PE 文件格式、DLL 加载
- 《Linux内核设计与实现》-第三版-Robert-Love —— Linux 侧虚拟内存对比参考
与项目的关联
- 共聚焦/流式细胞仪软件的内存分配优化(图像缓冲、大数组分配)
- 堆碎片对长时间运行的医疗仪器软件的性能影响
- DLL 基地址设置对启动速度的影响
十二、核心要点速记
- Win32 每个进程 4GB 虚拟地址空间,低2GB用户态私有,高2GB内核态(NT完全隔离,Win9x只隔离1GB)
- 页面 4KB,两级页表,虚拟地址→物理地址翻译
- 写时复制(COW):多进程共享页面直到写入才复制
- VirtualAlloc 两步:先保留(MEM_RESERVE)后占用(MEM_COMMIT)
- GlobalAlloc 是兼容层,底层还是 HeapAlloc
- CRT 小内存块堆:4MB区域→64KB块→按需占用,_heapmin 手动回收
- 内存映射文件是 Win32 优先采用的共享内存方式,MFC 不原生支持
- const 数据进 .rdata → 映射到 EXE 文件,节省交换文件空间
- 堆碎片是性能杀手,频繁分配释放的小对象是元凶