作者:yurunsun@gmail.com 新浪微博@孙雨润 新浪博客 优快云博客日期:2012年11月28日
1. Windows内存体系结构
1.1 进程的虚拟地址空间
每个进程都有自己的虚拟地址空间,对32位进程来说是4G,对64位来说是4G的平方16E。在Windows下虚拟地址空间分成4个区:空指针赋值区、用户模式分区、64K禁入分区、内核模式分区。
-
空指针赋值区
这段分区是从0x00000000~0x0000ffff,目的是帮助程序员捕获对空指针的赋值。如果不加以保护,就会造成访问到其他有用的数据区。
-
用户模式分区
一般情况是2G,有些程序可能需要更多的用户地址空间如sqlserver,windows提供了3G用户分区模式,执行BCDEdit.exe
64位机器上默认OS会保留多余的分区空间,让程序与32位程序一样只使用2G,如果需要整个分区需要打开/LARGEADDRESSAWARE来告诉编译器。
-
内核模式分区
内核与线程调度、内存管理、文件系统支持、网络支持、驱动设备的代码都在这个分区,这个分区的任何东西都为所有进程共有。如果应用程序试图去读写这段地址则会引发违规。
1.2 地址空间中的区域
-
OS创建一个进程并赋予地址空间时,可用地址空间中大部分都是free或unallocated,必须调用
VirtualAlloc
来分配其中的区域,这个操作叫做reserving. -
应用程序reserving地址空间时,OS保证区域的起始地址是分配粒度的整数倍,大小是OS页面大小的整数倍。一般前者为64K后者为4K。
VirtualFree
用来释放reserving的空间。
1.3 给区域调拨物理存储器
-
为了使用reserving的空间,还需要分配物理存储器,并将存储器映射到所预定的区域,这个操作叫做committing物理存储器。仍然是通过
VirtualAlloc
完成。 -
不需要访问时需要释放物理存储器,这个操作叫做decommitting,还是通过调用
VirtualFree
完成 。
1.4 物理存储器和页交换文件(paging file)
-
线程访问的数据在内存中,CPU会把数据的虚拟内存地址映射到内存的物理地址,然后访问
-
线程访问的数据不在内存中,而是在paging file的某处,这次不成功的访问称为页面错误,CPU会通知OS,OS在内存中立即找到一个free的页面,如果找不到,OS必须先释放一个已分配的页面
运行一个程序时,OS实际上并不会为该进程的代码和数据执行上述一系列操作:reserving、committing、将代码数据拷贝到paging file中committing的物理存储器。而是先根据.exe文件计算出所需代码和数据的大小,然后reserving一块地址空间,将其committing到.exe文件本身(而不是paging file)。这种把一个程序位于硬盘的一个文件映像(.exe/.dll)用作地址空间区域对应的物理存储器的做法,叫做内存映射文件。
当载入一个.exe/.dll时,OS会自动reversing/committing到这个区域,但是OS也提供了一组API,能够在程序里指定reversing/committing.
2. 虚拟内存的一组API
2.1 系统信息 GetSystemInfo
void WINAPI GetSystemInfo(
_Out_ LPSYSTEM_INFO lpSystemInfo
);
typedef struct _SYSTEM_INFO {
union {
DWORD dwOemId;
struct {
WORD wProcessorArchitecture;
WORD wReserved;
};
};
DWORD dwPageSize;
LPVOID lpMinimumApplicationAddress;
LPVOID lpMaximumApplicationAddress;
DWORD_PTR dwActiveProcessorMask;
DWORD dwNumberOfProcessors;
DWORD dwProcessorType;
DWORD dwAllocationGranularity;
WORD wProcessorLevel;
WORD wProcessorRevision;
} SYSTEM_INFO;
2.2 虚拟内存状态 GlobalMemoryStatus
void WINAPI GlobalMemoryStatus(
_Out_ LPMEMORYSTATUS lpBuffer
);
typedef struct _MEMORYSTATUS {
DWORD dwLength;
DWORD dwMemoryLoad;
SIZE_T dwTotalPhys;
SIZE_T dwAvailPhys;
SIZE_T dwTotalPageFile;
SIZE_T dwAvailPageFile;
SIZE_T dwTotalVirtual;
SIZE_T dwAvailVirtual;
} MEMORYSTATUS, *LPMEMORYSTATUS;
2.3 地址空间的状态 VirtualQuery
SIZE_T WINAPI VirtualQuery(
_In_opt_ LPCVOID lpAddress,
_Out_ PMEMORY_BASIC_INFORMATION lpBuffer,
_In_ SIZE_T dwLength
);
typedef struct _MEMORY_BASIC_INFORMATION {
PVOID BaseAddress;
PVOID AllocationBase;
DWORD AllocationProtect;
SIZE_T RegionSize;
DWORD State;
DWORD Protect;
DWORD Type;
} MEMORY_BASIC_INFORMATION, *PMEMORY_BASIC_INFORMATION;
3. 应用程序中使用虚拟内存
Windows提供已下三种机制操作内存:
- 虚拟内存: 管理大型对象数组和数据结构
- 内存映射文件:管理大型数据流/文件,以及同一机器上多个进程的共享数据
- 堆:大量小型对象
这里讨论第一种:虚拟内存。
3.1 Reserving 地址空间
LPVOID WINAPI VirtualAlloc(
_In_opt_ LPVOID lpAddress,
_In_ SIZE_T dwSize,
_In_ DWORD flAllocationType,
_In_ DWORD flProtect
);
-
lpAddress:告诉OS我们想要哪块地址空间,由于OS会记录free的地址空间,所以大部分时间传NULL,表示自动寻找。如果传参则需要保证位于用户模式分区中,否则分配失败返回NULL
-
dwSize:指定区域大小
-
fdwAllocationType:是reserving还是committing. MEM_RESERVE表示预定
-
fdwProtect:指定保护属性
3.2 Committing 地址空间
Reserving之后还要Committing,这样OS才会在页交换文件中调拨物理存储器给区域。
-
lpAddress:要调拨给哪里使用
-
dwSize:要调拨多少物理存储器
-
fdwAllocationType:传入MEM_COMMIT
3.3 何时Committing物理存储器
对于一个200*256的电子表格:
CELLDATA CellData[200][256];
考虑到用户只会在少数几个单元格存放信息,如果全部载入内存,利用率太低。如果使用链表,又增大了读取单元格内容的难度。此时虚拟内存就是一个合适的选择。步骤如下:
- Reserving一块足够大的区域容纳CELLDATA,只reserving是不会消耗物理存储器的
- 用户输入数据时首先确定CELLDATA在区域中的内存地址
- 给上一步的内存地址committing足够的物理存储器
有几种方法可以确定是否需要给区域中某一部分committing物理存储器:
- 总是尝试调用VirtualAlloc去committing
- 使用VirtualQuery来判断是否已经committing过了
- 手动维护数据结构记录
- 使用结构化异常处理 —— 最佳方案。
3.4 撤销committing物理存储器并de-reserving
BOOL WINAPI VirtualFree(
_In_ LPVOID lpAddress,
_In_ SIZE_T dwSize,
_In_ DWORD dwFreeType
);
与VirtualAlloc基本相同,不再解释。
4. 内存映射文件
与虚拟内存相似,内存映射文件允许开发者reserving/committing一块地址空间区域。不同点在于内存映射文件的物理存储器来自磁盘上已有文件,而不是OS的paging file. 一旦把文件映射到地址空间,就可以像已经被载入内存一样对其访问。
内存映射文件主要用于以下三种情况:
- OS用来载入并运行.exe/.dll,节省了paging file的空间以及应用程序启动的时间
- 开发者用内存映射文件来访问磁盘上的文件,避免直接操作IO或将文件读入缓冲区
- 不同进程间共享数据。是windows下最高效的方法
4.1 映射到内存的.exe/.dll
4.1.1 执行过程
当一个线程调用CreateProcess
时OS执行以下操作:
- OS确定.exe路径,如果无法找到则返回FALSE
- OS创建一个新的进程内核对象
- OS为新进程创建一个私有地址空间
- OS reserving足够大的地址空间来容纳.exe
- OS对此地址空间进行标注,表明该区域的后背物理存储器来自磁盘上.exe文件而非OS的paging file
然后OS会访问.exe文件中一个特定的段,这个段列出了一些dll文件,OS再调用LoadLibrary来载入dll,与上述倒数两步相似。
所有.exe/.dll文件都映射到进程的地址空间后,OS开始执行.exe文件的启动代码,并负责paging、buffering、caching. 例如当.exe一行代码跳转到一个未载入内存的指令地址,则CPU报page fault,并将该页代码从文件映像中载入到内存。
4.1.2 同一个.exe/.dll的多个实例不会共享静态数据
假设应用程序的第二个实例现在运行,OS只是把包含代码和数据的虚拟内存页面映射到第二个实例的地址空间中,如果其中一个实例修改了数据页面中的一些全局变量,那么应用程序所有实例的内存都会被修改,这中灾难是通过写时复制的方式避免的。
当应用程序试图写入内存映射文件时,OS会先截获此类尝试,接着为app试图写入的内存页面分配一块新的虚拟内存,再复制页面内容,最后让app写入到刚刚分配的内存块。
4.2 映射到内存的数据文件
考虑一个问题:如何颠倒一个2G的文件。
- 一个文件一块大缓存:无法commit 2G的存储器
- 两个文件一块小缓存:完成前占用了4G磁盘
- 一个文件两块小缓存:OK但是比较难实现
- 一个文件0块缓存:打开文件、向OS预订虚拟地址空间、让OS把文件第一个字节映射到该地址空间第一个字节
使用内存映射数据文件的好处是,将何时载入内存、换入换出都交给了OS处理。下面看使用内存映射数据文件的具体步骤
- 创建或打开一个文件内核对象,标识了我们想要用做内存映射文件的那个磁盘文件
- 创建一个文件映射内核对象,告诉OS文件的大小以及我们打算如何访问文件
- 告诉OS把文件映射对象的部分或者全部映射到进程的地址空间中
- 告诉OS从进程地址空间中取消对文件映射内核对象的映射
- 关闭文件映射内核对象
- 关闭文件内核对象
4.2.1 创建或打开一个文件内核对象
HANDLE WINAPI CreateFile(
_In_ LPCTSTR lpFileName,
_In_ DWORD dwDesiredAccess,
_In_ DWORD dwShareMode,
_In_opt_ LPSECURITY_ATTRIBUTES lpSecurityAttributes,
_In_ DWORD dwCreationDisposition,
_In_ DWORD dwFlagsAndAttributes,
_In_opt_ HANDLE hTemplateFile
);
之前讨论过这个API,这里注意一下
dwDesiredAccess
必须是:GENERIC_READ
或GENERIC_READ | GENERIC_WRITE
4.2.2 创建文件映射内核对象
HANDLE WINAPI CreateFileMapping(
_In_ HANDLE hFile,
_In_opt_ LPSECURITY_ATTRIBUTES lpAttributes,
_In_ DWORD flProtect,
_In_ DWORD dwMaximumSizeHigh,
_In_ DWORD dwMaximumSizeLow,
_In_opt_ LPCTSTR lpName
);
-
dwMaximumSizeHigh dwMaximumSizeLow
两个参数加在一起告诉OS内存映射文件的最大大小,最多为64E。但是有一个问题,32位进程地址空间最大只有4G,其中还包括内核等不能用的地址。如何把16E的文件映射到4G地址空间去呢,后面会介绍。
-
如果文件比指定的大小要小,那么
CreateFileMapping
会增大文件大小,目的是保证后来把文件用作内存映射文件时,物理存储器已经准备就绪。
4.2.3 将文件数据映射到进程的地址空间
LPVOID WINAPI MapViewOfFile(
_In_ HANDLE hFileMappingObject,
_In_ DWORD dwDesiredAccess,
_In_ DWORD dwFileOffsetHigh,
_In_ DWORD dwFileOffsetLow,
_In_ SIZE_T dwNumberOfBytesToMap
);
dwMaximumSizeHigh dwMaximumSizeLow
两个参数加在一起告诉OS从哪里开始映射dwNumberOfBytesToMap
指定映射多大
4.2.4 从进程地址空间撤销对文件数据的映射
BOOL WINAPI UnmapViewOfFile(
_In_ LPCVOID lpBaseAddress
);
4.3 用内存映射文件在进程间共享数据
有多种机制能在进程间共享数据:RPC/COM/OLE/DDE/WM_COPYDATA/剪切板/mailslot/pipe/socket,但是最底层的机制就是内存映射文件,也就是说在同一台机器上进程间共享数据,上边提到的所有机制都会用到内存映射文件。
这种数据共享机制通过让多个进程映射同一个文件映射对象的视图来实现,即进程间共享相同的物理存储页面。因此当一个进程在文件映射对象的视图中写入数据时,其他进程会在视图中立即看到变化。
【Note】OS允许同一个数据文件为后背存储器来创建多个文件映射对象,但不保证这些文件映射对象的各个视图是一致的。OS只保证同一文件映射对象的多个视图间保持一致
5. 堆
5.1 进程的默认堆
进程初始化时OS会在进程的地址空间中创建一个大小为1MB(可调)的默认堆。我们可以通过调用GetProcessHeap
拿到进程默认堆的句柄。
5.2 进程的额外堆
以下几种情况需要创建额外的堆:
-
对组件进行保护
假如程序需要处理一个链表和一个二叉树,如果在同一个堆中,链表的错误操作可能会覆盖二叉树的节点,使开发者误认为是二叉树的代码有bug,如果创建两个独立的堆,会使这种可能性小很多。
-
更有效的内存管理
如果链表节点的大小是8,堆节点的大小是12,那么链表释放的节点无法被堆节点使用,造成内存碎片。如果两个独立的堆,可以重复分配空间。
-
使内存访问局部化
链表和二叉树在同一个堆,可能导致遍历链表时频繁换页。如果分别占用同一块页,会降低换页开销。
-
避免线程同步的开销
后文会介绍。
-
快速释放
可以直接释放整个堆而不必显示释放每个内存块。
5.3 如何创建额外的堆
5.3.1 创建
HANDLE WINAPI HeapCreate(
_In_ DWORD flOptions,
_In_ SIZE_T dwInitialSize,
_In_ SIZE_T dwMaximumSize
);
-
默认堆堆的访问会依次进行,使多个线程可以从同一个堆中分配和释放内存块,不会存在堆数据被破坏的危险。如果开发者能够自己保证对堆访问时的线程安全,可以在flOptions中指定HEAPNOSERIALIZE
-
dwInitialSize表示一开始要committing给堆的字节数
-
dwMaximumSize表示堆的最大大小,如果为0表示无上限可增长
5.3.2 分配内存块
LPVOID WINAPI HeapAlloc(
_In_ HANDLE hHeap,
_In_ DWORD dwFlags,
_In_ SIZE_T dwBytes
);
其中dwFlags与HeapCreate
相似
5.3.3 调整内存块大小
LPVOID WINAPI HeapReAlloc(
_In_ HANDLE hHeap,
_In_ DWORD dwFlags,
_In_ LPVOID lpMem,
_In_ SIZE_T dwBytes
);
5.3.4 获得内存块大小
SIZE_T WINAPI HeapSize(
_In_ HANDLE hHeap,
_In_ DWORD dwFlags,
_In_ LPCVOID lpMem
);
5.3.5 释放内存块
BOOL WINAPI HeapFree(
_In_ HANDLE hHeap,
_In_ DWORD dwFlags,
_In_ LPVOID lpMem
);
5.3.6 销毁堆
BOOL WINAPI HeapDestroy(
_In_ HANDLE hHeap
);
5.3.7 在C++中使用堆
class CSomeClass
{
public:
void* operator new (size_t size);
void operator delete (void* p);
private:
static HANDLE s_hHeap;
static UINT s_uNumAllocsInHeap;
};
HANDLE CSomeClass::s_hHeap = NULL;
UINT CSomeClass::s_uNumAllocsInHeap = 0;
void* CSomeClass::operator new (size_t size) {
if (s_hHeap == NULL) {
s_hHeap = HeapCreate(HEAP_NO_SERIALIZE, 0, 0);
}
if (s_hHeap == NULL) {
return NULL;
}
void* p = HeapAlloc(s_hHeap, 0, size);
if (p != NULL) {
s_uNumAllocsInHeap ++;
}
return p;
}
void CSomeClass::operator delete (void* p) {
if (HeapFree(s_hHeap, 0, p)) {
s_uNumAllocsInHeap--;
}
if (s_uNumAllocsInHeap == 0) {
if (HeapDestroy(s_hHeap)) {
s_hHeap = NULL;
}
}
}