学习笔记13 9 月, 2026· 约 8 分钟
操作系统
本次从操作系统内存、线程栈/堆、C++内存限制、GC堆压缩原理,一路到C 闭包、DisplayClass捕获规则,完整梳理如下。 一、内存基础(栈、堆) 1. 线程栈(栈内存) 每个线程创建时操作系统分配一块固定大小虚拟地址空间,是线程私有临时内存。 存放:函数局部变量、函数参数、返回地址、寄存器现场;函数调用生成栈帧
本次从操作系统内存、线程栈/堆、C++内存限制、GC堆压缩原理,一路到C#闭包、DisplayClass捕获规则,完整梳理如下。
一、内存基础(栈、堆)
- 线程栈(栈内存)
- 每个线程创建时操作系统分配一块固定大小虚拟地址空间,是线程私有临时内存。
- 存放:函数局部变量、函数参数、返回地址、寄存器现场;函数调用生成栈帧,函数返回直接弹出栈帧,自动回收,LIFO后进先出。
- 优点:分配释放极快,只移动栈指针,无系统调用;不会产生外部碎片,仅对齐padding带来少量内部碎片。
- 缺点:大小固定,不能动态扩容;局部大数组直接StackOverflow;不能返回栈上变量的指针/引用(栈帧销毁后野内存)。
- 注意:
Obj* ptr中,ptr本身存栈,ptr指向的对象实例存堆。
- 进程堆(堆内存)
- 整个进程共享,由分配器管理(系统malloc或引擎自定义分配器),手动/GC管理生命周期。
- new/malloc从堆分配,free/delete释放;会产生外部碎片(空闲被存活对象分割成不连续空洞)、内部碎片(对齐、桶大小向上取整)。
- C++硬限制:原生裸指针,运行时不能移动堆上对象,一旦移动所有裸指针直接野指针崩溃。
- 内存碎片概念
- 内部碎片:分配的内存比实际需要大,多出部分浪费。
- 外部碎片:总空闲内存充足,但都是零散不连续块,无法满足大块内存申请。
- C++没有运行时堆压缩能力;.NET GC可以做标记‑压缩,运行时移动对象。
二、GC压缩堆原理(.NET)
标记‑压缩三大阶段:标记Mark → 拷贝压缩Compact → 重定位Relocate 1)标记:从根集合(栈引用、静态变量、寄存器)遍历,标记所有可达存活对象,不可达为垃圾。 2)压缩:STW暂停所有用户线程,把存活对象拷贝到堆前端,挤掉垃圾空洞,尾部合并成一块连续空闲内存,消除外部碎片。 3)重定位:依靠GC能识别全部托管引用,通过转发表,把所有引用更新为对象新虚拟地址。
关键特性
- STW:压缩必须暂停业务线程;堆越大、存活对象越多停顿越长。
- 不是每次GC都压缩:0/1代通常只标记清除;2代碎片严重才压缩;大对象堆LOH默认不压缩(拷贝成本极高)。
- 标记清除:只释放垃圾,不移动对象,不解决外部碎片。
- 标记复制(Copying GC):把对象复制到To空间,多用于Java新生代。
- C++无法运行时压缩的根本原因:C++分不清内存中哪些数字是指针、哪些是普通数值,无法自动重定位更新指针。
三、UE引擎如何应对C++内存碎片(不能运行时压缩)
UE的UObject GC是标记清扫,不压缩、不移动对象。缓解碎片手段:
- 自研
FMallocBinned分桶分配器:按固定尺寸桶管理内存,相同大小块复用,抑制外部碎片,代价内部碎片。 - 多独立内存池隔离:游戏对象、渲染、物理、音频各自独立内存池,碎片不会互相污染。
- 对象池、预分配、批量释放:避免频繁new/delete;关卡卸载批量释放一大片内存。
- 仅在加载黑屏(天然STW窗口),部分独立内存池做对象拷贝整理;游戏运行时绝不移动对象。
TObjectPtr:只是安全弱指针,不能支持对象搬迁重定向。- 兜底:
TrimMemory归还空闲内存给OS;碎片过高主动重新加载关卡。
四、闭包 / Lambda原理
1. C++ Lambda
[x]值捕获:把变量拷贝一份到lambda对象;lambda可以存在栈;如果放入std::function会把整个lambda环境搬到堆。多个lambda值捕获互相独立。[&x]引用捕获:保存原变量地址;C++不会自动把栈变量提升到堆;若lambda逃出函数,原栈帧销毁,直接野引用UB。- lambda本身是编译器生成的匿名类对象;
T&C++引用只是const指针语法糖,没有运行时间接层,不能做内存搬迁重定向。 std::function会把lambda环境放到堆;std::function析构就释放堆环境;不析构则内存泄漏。
2. C# Lambda / 闭包
- C#没有语法层面“值捕获/引用捕获”,捕获的是变量(内存位置),不是捕获那一刻的值快照。
- 编译器生成隐藏类
DisplayClass(闭包环境),被逃逸的局部变量提升为该类字段,分配在托管堆。 - 实例化DisplayClass时机:定义lambda那一行,不是调用Invoke的时候;调用闭包只是执行方法,不会新建环境对象。
- 委托(Func/Action):委托对象的Target指向DisplayClass实例;多个委托可以指向同一个DisplayClass。
- 核心规则:
- 多个lambda捕获同一个变量实例(同一个栈帧内同一个局部变量):共用同一个DisplayClass,修改互相可见。
- 多个lambda捕获不同的变量实例(多次函数调用产生的新局部变量):每个变量对应独立DisplayClass,互相隔离互不影响。
- 经典for循环坑:for的循环变量
i在整个循环只创建一个变量实例;所有lambda共享同一个DisplayClass,全部读到循环结束后的值。修复:循环体内新建局部变量。 - 闭包堆内存回收:没有任何委托引用该DisplayClass,GC自动回收;只要一个闭包存活,捕获的所有对象都不会释放,容易内存泄漏。
- 值类型、引用类型捕获区别:看捕获的是不是同一个变量实例;捕获引用类型变量时,修改实例内部数据所有闭包可见。
五、关键易错点汇总
- C#中,变量名字相同 ≠ 同一个变量实例;两次调用同一个方法,形参是两份独立变量,生成两套闭包环境。
- C++引用捕获不会自动上堆;C#会自动将逃逸闭包捕获的变量提升到堆。
- GC托管引用 ≠ C++引用;C#托管引用运行时可被GC批量更新地址;C++引用底层是裸指针,无法重定向。
- 闭包没有“拆包”语法;销毁闭包对象,堆环境随之释放。
- 闭包捕获环境会持有所有提升进去的变量,即使逻辑不用,只要闭包存活就不会释放。
A =+1是赋值;A +=1才是自增,运算符笔误大坑。
六、关键对比简表
| 特性 | C++ lambda | C# lambda闭包 |
|---|---|---|
| 捕获方式 | 显式[x]值 /[&x]引用 |
捕获变量实例,无语法选项 |
| 栈变量逃逸 | 不会自动提升堆,容易野指针 | 自动提升到DisplayClass托管堆 |
| 能否移动被捕获对象 | 不能,裸指针失效 | GC可以移动,自动重定向引用 |
| 环境实例生成 | lambda对象可栈;std::function触发堆分配 | 定义lambda时new DisplayClass堆实例 |
| 回收 | RAII,std::function析构释放 | GC,无引用后回收 |
如果你需要,我可以把这份总结精简成一页速记版,方便复习。


0 条回复
还没有留言,来说点什么吧。