学习笔记13 9 月, 2026· 约 8 分钟

操作系统

本次从操作系统内存、线程栈/堆、C++内存限制、GC堆压缩原理,一路到C 闭包、DisplayClass捕获规则,完整梳理如下。 一、内存基础(栈、堆) 1. 线程栈(栈内存) 每个线程创建时操作系统分配一块固定大小虚拟地址空间,是线程私有临时内存。 存放:函数局部变量、函数参数、返回地址、寄存器现场;函数调用生成栈帧


本次从操作系统内存、线程栈/堆、C++内存限制、GC堆压缩原理,一路到C#闭包、DisplayClass捕获规则,完整梳理如下。

一、内存基础(栈、堆)

  1. 线程栈(栈内存)
  • 每个线程创建时操作系统分配一块固定大小虚拟地址空间,是线程私有临时内存。
  • 存放:函数局部变量、函数参数、返回地址、寄存器现场;函数调用生成栈帧,函数返回直接弹出栈帧,自动回收,LIFO后进先出。
  • 优点:分配释放极快,只移动栈指针,无系统调用;不会产生外部碎片,仅对齐padding带来少量内部碎片。
  • 缺点:大小固定,不能动态扩容;局部大数组直接StackOverflow;不能返回栈上变量的指针/引用(栈帧销毁后野内存)。
  • 注意:Obj* ptr中,ptr本身存栈,ptr指向的对象实例存堆。
  1. 进程堆(堆内存)
  • 整个进程共享,由分配器管理(系统malloc或引擎自定义分配器),手动/GC管理生命周期。
  • new/malloc从堆分配,free/delete释放;会产生外部碎片(空闲被存活对象分割成不连续空洞)、内部碎片(对齐、桶大小向上取整)。
  • C++硬限制:原生裸指针,运行时不能移动堆上对象,一旦移动所有裸指针直接野指针崩溃。
  1. 内存碎片概念
  • 内部碎片:分配的内存比实际需要大,多出部分浪费。
  • 外部碎片:总空闲内存充足,但都是零散不连续块,无法满足大块内存申请。
  • C++没有运行时堆压缩能力;.NET GC可以做标记‑压缩,运行时移动对象。

二、GC压缩堆原理(.NET)

  1. 标记‑压缩三大阶段:标记Mark → 拷贝压缩Compact → 重定位Relocate 1)标记:从根集合(栈引用、静态变量、寄存器)遍历,标记所有可达存活对象,不可达为垃圾。 2)压缩:STW暂停所有用户线程,把存活对象拷贝到堆前端,挤掉垃圾空洞,尾部合并成一块连续空闲内存,消除外部碎片。 3)重定位:依靠GC能识别全部托管引用,通过转发表,把所有引用更新为对象新虚拟地址。

  2. 关键特性

  • STW:压缩必须暂停业务线程;堆越大、存活对象越多停顿越长。
  • 不是每次GC都压缩:0/1代通常只标记清除;2代碎片严重才压缩;大对象堆LOH默认不压缩(拷贝成本极高)。
  • 标记清除:只释放垃圾,不移动对象,不解决外部碎片。
  • 标记复制(Copying GC):把对象复制到To空间,多用于Java新生代。
  1. C++无法运行时压缩的根本原因:C++分不清内存中哪些数字是指针、哪些是普通数值,无法自动重定位更新指针。

三、UE引擎如何应对C++内存碎片(不能运行时压缩)

UE的UObject GC是标记清扫,不压缩、不移动对象。缓解碎片手段:

  1. 自研FMallocBinned分桶分配器:按固定尺寸桶管理内存,相同大小块复用,抑制外部碎片,代价内部碎片。
  2. 多独立内存池隔离:游戏对象、渲染、物理、音频各自独立内存池,碎片不会互相污染。
  3. 对象池、预分配、批量释放:避免频繁new/delete;关卡卸载批量释放一大片内存。
  4. 仅在加载黑屏(天然STW窗口),部分独立内存池做对象拷贝整理;游戏运行时绝不移动对象。
  5. TObjectPtr:只是安全弱指针,不能支持对象搬迁重定向。
  6. 兜底:TrimMemory归还空闲内存给OS;碎片过高主动重新加载关卡。

四、闭包 / Lambda原理

1. C++ Lambda

  1. [x] 值捕获:把变量拷贝一份到lambda对象;lambda可以存在栈;如果放入std::function会把整个lambda环境搬到堆。多个lambda值捕获互相独立。
  2. [&x]引用捕获:保存原变量地址;C++不会自动把栈变量提升到堆;若lambda逃出函数,原栈帧销毁,直接野引用UB。
  3. lambda本身是编译器生成的匿名类对象;T&C++引用只是const指针语法糖,没有运行时间接层,不能做内存搬迁重定向。
  4. std::function会把lambda环境放到堆;std::function析构就释放堆环境;不析构则内存泄漏。

2. C# Lambda / 闭包

  1. C#没有语法层面“值捕获/引用捕获”,捕获的是变量(内存位置),不是捕获那一刻的值快照。
  2. 编译器生成隐藏类DisplayClass(闭包环境),被逃逸的局部变量提升为该类字段,分配在托管堆。
  3. 实例化DisplayClass时机:定义lambda那一行,不是调用Invoke的时候;调用闭包只是执行方法,不会新建环境对象。
  4. 委托(Func/Action):委托对象的Target指向DisplayClass实例;多个委托可以指向同一个DisplayClass。
  5. 核心规则:
    • 多个lambda捕获同一个变量实例(同一个栈帧内同一个局部变量):共用同一个DisplayClass,修改互相可见。
    • 多个lambda捕获不同的变量实例(多次函数调用产生的新局部变量):每个变量对应独立DisplayClass,互相隔离互不影响。
  6. 经典for循环坑:for的循环变量i在整个循环只创建一个变量实例;所有lambda共享同一个DisplayClass,全部读到循环结束后的值。修复:循环体内新建局部变量。
  7. 闭包堆内存回收:没有任何委托引用该DisplayClass,GC自动回收;只要一个闭包存活,捕获的所有对象都不会释放,容易内存泄漏。
  8. 值类型、引用类型捕获区别:看捕获的是不是同一个变量实例;捕获引用类型变量时,修改实例内部数据所有闭包可见。

五、关键易错点汇总

  1. C#中,变量名字相同 ≠ 同一个变量实例;两次调用同一个方法,形参是两份独立变量,生成两套闭包环境。
  2. C++引用捕获不会自动上堆;C#会自动将逃逸闭包捕获的变量提升到堆。
  3. GC托管引用 ≠ C++引用;C#托管引用运行时可被GC批量更新地址;C++引用底层是裸指针,无法重定向。
  4. 闭包没有“拆包”语法;销毁闭包对象,堆环境随之释放。
  5. 闭包捕获环境会持有所有提升进去的变量,即使逻辑不用,只要闭包存活就不会释放。
  6. A =+1是赋值;A +=1才是自增,运算符笔误大坑。

六、关键对比简表

特性 C++ lambda C# lambda闭包
捕获方式 显式[x]值 /[&x]引用 捕获变量实例,无语法选项
栈变量逃逸 不会自动提升堆,容易野指针 自动提升到DisplayClass托管堆
能否移动被捕获对象 不能,裸指针失效 GC可以移动,自动重定向引用
环境实例生成 lambda对象可栈;std::function触发堆分配 定义lambda时new DisplayClass堆实例
回收 RAII,std::function析构释放 GC,无引用后回收

如果你需要,我可以把这份总结精简成一页速记版,方便复习。


0 条回复

还没有留言,来说点什么吧。

留言