经验首页 前端设计 程序设计 Java相关 移动开发 数据库/运维 软件/图像 大数据/云计算 其他经验
当前位置:技术经验 » 数据库/运维 » Windows » 查看文章
.NET Core 内存结构体系(Windows环境)底层原理浅谈
来源:cnblogs  作者:叫我安不理  时间:2025/2/20 10:46:18  对本文有异议

物理内存与虚拟内存

  1. 物理内存(Physical Memory)
    定义:物理内存是计算机硬件中的实际RAM(如DDR5内存条),直接通过总线与CPU连接,用于临时存储运行中的程序和数据。
  2. 虚拟内存(Virtual Memory)
    定义:由操作系统管理的抽象内存层,通过结合物理内存和磁盘空间(如页面文件或交换分区),为程序提供连续且独立的内存空间。

用户只需要与虚拟内存地址打交道,而无需关心数据到底分配在哪里

image

眼见为实

image

物理页4K对齐

在Windows系统下,以4K为最小粒度,这个单位叫做物理页,并以4K的整数倍分配内存。比如申请1k分配4k,申请5k分配8k

眼见为实

  1. void page4k() {
  2. for (int i = 0; i < 200; i++) {
  3. //1k 的占用
  4. LPVOID ptr = VirtualAlloc(NULL, 1024 * 1, MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE);
  5. printf("i=%d, 1k, address:%#0.8x \n", i + 1, ptr);
  6. }
  7. for (int i = 200; i < 400; i++) {
  8. //5k 的占用
  9. LPVOID ptr = VirtualAlloc(NULL, 1024 * 5, MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE);
  10. printf("i=%d, 5k, address:%#0.8x \n", i + 1, ptr);
  11. }
  12. getchar();
  13. }

申请1k分配4k
image

申请5k分配8k
image

物理内存与虚拟内存如何映射?

Windows系统采用二叉树结构(5层)来实现高效映射。

举个例子,某个32bit的内存地址为:0x77b01a42,其二进制为:01110,11110,11000,00001,101001000010
image
image

  1. 前20位用来构建页表树,实现物理页的的高效映射
  2. 后12位映射物理页的偏移量

操作系统以4K为一个单位对内存进行分组,4G内存=102410241024*4/(4/1024)=1048576物理页,如此庞大的物理页,,采用5层二叉树来提高索引效率

眼见为实:以notepad为例

任务管理:
image
Windbg:
image

可以看到非常明显的不同,任务管理器显示占用44.6mb内存,而windbg显示占用489.531mb内存,这是为什么呢?
答:显示逻辑不同,任务管理器显示的是Private WorkingSet,指的是物理内存的地址,即内存条上的内存,而Windbg是显示映射到的物理页,Commit指的是虚拟内存地址,这包括内存条上的内存,pagefile,image三种

眼见为实:可视化观察 虚拟地址=>物理地址

使用windbg进入内核态,这很重要,大家可以猜猜原因。

随便找一个字符串的内存地址
image

  1. 使用dp观察虚拟地址
  2. 使用!vtop 观察映射信息
  3. 使用!db观察物理地址

虚拟地址布局

image

眼见为实:空指针区与用户态区

image

windows/linux在默认情况下,会开启ASLR,需要关闭此技术才能复现。
ASLR 是一种针对缓冲区溢出攻击等内存攻击技术而设计的安全特性。在没有 ASLR 的情况下,程序加载到内存中的位置通常是固定的,攻击者可以预测程序中各种模块(如可执行文件、动态链接库等)的加载地址,进而利用这些固定地址来构造恶意代码进行攻击,比如在缓冲区溢出攻击中精准定位跳转地址来执行恶意指令。
而启用 ASLR 后,操作系统在每次启动程序时会随机化程序的内存布局,包括可执行文件、动态链接库、堆、栈等的加载地址,使得攻击者难以准确预测内存地址,大大增加了攻击的难度。

Reserved与Commit

  1. Reserved
    在虚拟地址上申请一段内存空间,此时操作系统也会同步创建页表树,但此时并未映射到物理内存,此时对该虚拟内存的读写会抛异常
  2. Commit
    页表树调配真实的物理内存,此时才能正常写入

眼见为实:Reserved

  1. void mem_reserved() {
  2. LPVOID ptr = VirtualAlloc(NULL, 4 * 1024, MEM_RESERVE, PAGE_READWRITE);
  3. *(int*)(ptr) = 10; //在首地址上写入内容。
  4. printf("num=%d", *(int*)ptr);
  5. }

image

眼见为实:Commit

  1. void mem_commit() {
  2. LPVOID ptr = VirtualAlloc(NULL, 4 * 1024, MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE);
  3. *(int*)(ptr) = 10; //在首地址上写入内容。
  4. printf("num=%d", *(int*)ptr);
  5. }

image

NT堆

NT堆是 Windows NT 内核引入的内存管理组件,主要负责进程内的堆内存分配与释放。在 Windows 系统里,进程可以使用 NT 堆来动态分配和管理内存,比如程序中使用 malloc()(C 语言)、new(C++) 等函数进行内存分配时,底层通常就依赖 NT 堆机制。

上面说到,VirtualAlloc方法它会一次性分配 64k 整数倍的内存段,内部对象按4k的内存页对齐.
如果让application直接操作VirtualAlloc,难免会造成大量的内存浪费。为了提高内存性能与使用效率,Windows又提供了一层抽象,以提供更细颗粒度的内存管理。它的名字叫做NT堆

  1. 在32bit平台上:8byte为一个分配粒度
  2. 在64bit平台上:16btye为一个分配粒度

image

  1. CRT堆:C运行时使用的堆,默认是对NT堆的简单封装
  2. 托管堆:用作特殊用途的,自行实现的一套内存池管理机制。比如GC堆

从图中可以看出,使用NT与否取决于程序员本身。完全可以绕过NT堆,直接使用VirtualAlloc来分配内存,只要你接收内存浪费。
NT堆非常重要,作为非托管堆,与.NET 托管堆交互得异常频繁。因此,当出现非托管堆内存泄漏 时。90%的情况下,都是NT堆的问题。

眼见为实

GC堆,底层使用VirtualAlloc分配内存

点击查看代码
  1. static void Main(string[] args)
  2. {
  3. var rand = new Random();
  4. List<string> list = new List<string>();
  5. for (int i = 0; i < 100000; i++)
  6. {
  7. var str = string.Join(",", Enumerable.Range(0, rand.Next(1, 1000)));
  8. list.Add(str);
  9. Console.WriteLine($"i={i},length={str.Length}");
  10. }
  11. Console.ReadLine();
  12. }

在bp KERNELBASE!VirtualAlloc 下断点

image

CRT堆/NT堆,底层使用VirtualAlloc分配内存

点击查看代码
  1. #include <iostream>
  2. #include <Windows.h>
  3. void crt_c() {
  4. for (int i = 0; i < 10000000; i++) {
  5. int* ptr = (int*)malloc(sizeof(int) * 1000);
  6. *(ptr) = 10;
  7. printf("第 %d 次分配 \n", i);
  8. }
  9. }

在 bp ntdll!NtAllocateVirtualMemory 下断点

image

NT多堆结构

Windows NT堆可以拥有多个堆,默认情况下,每个进程会有一个默认堆。此外,进程还可以根据需要创建额外的堆。
按照类型可以分为以下两种:

  1. 进程堆
    每一个进程创建时,Windows加载器都会给进程附加一个默认的NT堆(ProcessHeap),主要是承载依赖的DLL与系统库,例如,一些图形处理库在初始化时可能需要分配内存来存储图像数据或缓存计算结果,这些内存分配操作通常会通过默认 NT 堆来完成。
    进程自身的一些内部数据结构也依赖于默认 NT 堆进行内存分配。这些数据结构用于管理进程的状态、线程信息、文件句柄
  2. 私有堆
    如果你的项目自身也有特殊需求,也可以创建私有的NT堆,做到专用。

眼见为实

点击查看代码
  1. #include <iostream>
  2. #include <Windows.h>
  3. int main()
  4. {
  5. //process heap
  6. HANDLE handle1 = GetProcessHeap();
  7. printf("process heap => handle= %#0.8x \n", handle1);
  8. //private heap
  9. HANDLE handle2 = HeapCreate(0, 1024 * 10, 0);
  10. printf("private heap => handle= %#0.8x \n", handle2);
  11. getchar();
  12. }

image

image

NT堆内存结构

NT堆与GC堆类似,有N个Segment组成,Segment内部由Entry组成
image

HEAP 开头有一块大小为 0x4a8 的 HEAP_ENTRY 堆块,用来存放 _HEAP 结构的元数据信息

眼见为实

image

Heap_Entry内存结构

用户分配的对象,都以8byte/16byte的最小粒度分配在Heap_Entry中,它们的内存结构是什么样的呢?
image

  1. 元数据区
    类似C#的 MethodTable,存储着元数据
  2. 用户分配区
    2.1 User
    这是用户实际可用的内存起始地址。通常,堆块的头部会包含一些管理信息(如块大小、状态标志等),而用户真正可以使用的内存是从 User 地址开始的
    2.2 Handle
    分配对象真正存储的区域
  3. 尾填充区
    尾填充区是指在已分配或空闲的内存块末尾添加的一段额外内存区域。它通常被填充上特定的字节模式,主要用于内存调试、检测内存越界访问以及维护内存块的完整性。

眼见为实

image

NT堆内存泄漏排查思路

在C#中,所有托管对象都会有一个root根,让你知晓为什么没有被回收,被什么所持有。
但在非托管环境中,C/C++这种无GC的语言需要自己手动管理内存,因此当出现内存泄露时,往往无从下手。

因此找到内存的分配源头是其中一个重要思路。

GFlags

使用GFlags开始UST(user-mode stack trace)功能,可以清楚得知每一个Heap_Entry的调用堆栈,从而帮助开发人员找到未释放内存的代码所在。

https://learn.microsoft.com/zh-cn/windows-hardware/drivers/debugger/gflags

眼见为实

image
当开发人员找出可疑的Heap_Entry时,可以通过查看调用栈,进一步确认代码中是否存在只分配未释放的情况。

原文链接:https://www.cnblogs.com/lmy5215006/p/18707150

 友情链接:直通硅谷  点职佳  北美留学生论坛

本站QQ群:前端 618073944 | Java 606181507 | Python 626812652 | C/C++ 612253063 | 微信 634508462 | 苹果 692586424 | C#/.net 182808419 | PHP 305140648 | 运维 608723728

W3xue 的所有内容仅供测试,对任何法律问题及风险不承担任何责任。通过使用本站内容随之而来的风险与本站无关。
关于我们  |  意见建议  |  捐助我们  |  报错有奖  |  广告合作、友情链接(目前9元/月)请联系QQ:27243702 沸活量
皖ICP备17017327号-2 皖公网安备34020702000426号