1.堆栈
| 术语 | 说明 |
|---|---|
| 托管堆(Managed Heap) | GC 管理的内存区域,所有 new 分配的引用类型对象、数组、字符串都在这里。包含小对象堆(SOH,<85KB)和大对象堆(LOH,≥85KB)。 |
| GC堆 | 托管堆的另一种说法,强调由 GC 负责回收。 |
| 非托管堆(Unmanaged Heap) | 通过 Marshal.AllocHGlobal、malloc、VirtualAlloc 等分配的内存,不受 GC 管理,需要手动释放。这部分通常不称为"C# 的堆",而是"非托管内存"。 |
| 加载器堆(Loader Heap) | CLR 内部用于存储类型系统、方法表等的堆,对普通开发者透明。 |
| 线程栈(Stack) | 虽然叫"栈",不是堆,但值类型局部变量分配在这里。 |
2.GC
在创建场景以及场景中的人物时,使用了工厂模式,并使用依赖注入的方式创建人物,这样我们就可以直接持有人物的组件(Controller等),避免了在Awake甚至Update中使用gameObject.Transform或GetComponent产生GC压力。
我们应该遵循零分配的原则,包括使用LINQ,Foreach产生枚举器分配,字符串拼接,GetCompent等Unity自带方法,匿名委托(Action,Func),容器扩容(这部分类似于C++),装箱等都会产生GC压力。还需要主要大对象堆(LOH),当对象的大小大于85KB时就会被分配到大对象堆中,所以对于大容量的数组更应该进行预先的容器内存分配,因为在LOH上的GC不会进行内存压缩,会产生大量的内存碎片。
GC依据根可达性来判断是否要回收一个对象,在进行GC的时候会从GC根开始沿着对象的引用字段递归遍历,如果对象没有被访问到,那么就被标记为需要回收。
GC判断根可达性会依据GCHandle,静态字段,托管线程栈上的变量(值/引用),Task/Thread。
托管线程
托管线程是由.NET运行时管理的线程(System.Threading.Thread),每一个托管线程都映射着一个底层的由OS调度的操作系统原生线程。每一个托管线程都有一个自己的托管栈(包含局部变量,参数,返回值等),GC在回收时会遍历所有的托管栈查找根引用。每一个线程都会独立储存一个ThreadStatic线程静态变量,在当前线程存在期间作为根,线程结束时自身也会成为垃圾(没有被其他地方引用),在进行GC回收的时候托管线程会暂停,但是现代GC使用了后台GC和增量GC来进行优化。
后台GC通过将GC线程放在后台和托管线程并发执行来避免托管线程的完全暂停(仍会有暂停,分别在开始挂起扫描标记根引用和结束的更新引用与压缩等收尾工作)
Unity独有的增量GC,会将一整个GC回收动作切分成若干时间片并分散到多帧中执行,这一过程类似于Unity协程对并发多线程的伪实现。
此外,所有的静态字段都会一直存在一直到AppDomain卸载,在线程池复用的时候旧的ThredStatic可能会残留,使用TreadLoacl<T>结合Dispose会是更好的选择,我们也要尽量避免静态字段的使用。
托管线程栈的引用是强根,只要存在就绝对不会被当成垃圾回收。但是非托管代码持有的托管对象地址(unsafe指针)不会被GC视为根,所以我们需要使用fixed(仅固定,异步时不保证不被回收)或GCHandle.Alloc来固定对象。
GCHandle
在.NET中作为一个句柄,该句柄被GC认为是根,所以可以实现从非托管代码中安全的引用托管对象的作用,此外还可以固定托管对象在GC堆中的位置(Pinned)并传递指针给非托管代码。
cs
// 创建一个强引用句柄
object obj = new byte[1024];
GCHandle handle = GCHandle.Alloc(obj, GCHandleType.Normal);
IntPtr ptr = GCHandle.ToIntPtr(handle); // 获取句柄的指针,可传给非托管代码
// 从非托管代码传递回来的句柄,恢复为 GCHandle 并获取对象
handle = GCHandle.FromIntPtr(ptr);
object target = handle.Target;
// 必须手动释放句柄,否则会造成内存泄漏
handle.Free();
GCHandle类型
| 类型 | 说明 |
|---|---|
Normal |
强引用句柄,阻止对象被回收。与直接持有托管引用类似,但可被非托管代码存储和传递。 |
Pinned |
强引用 + 固定对象在内存中的位置(GC 压缩时不会移动)。用于将对象内部指针(如数组元素地址)传给非托管 API。 |
Weak |
弱引用,不阻止回收。可通过 Target 属性获取对象,若对象已被回收则返回 null。 |
WeakTrackResurrection |
跟踪复活阶段的弱引用,对象即使有终结器也会被跟踪到回收完成。 !!这两个Week用于检测托管对象是否还存在/被回收 |
GC只识别托管代码中的引用
如果通过P/Invoke将一个托管对象的指针传递给一个非托管对象,但是此时该托管对象没有被任何另外的托管对象引用,那么此时会被GC认为该托管对象是垃圾,可能会被提前回收。
cs
// 危险:没有使用 GCHandle 或 KeepAlive
[DllImport("native.dll")]
static extern void ProcessData(byte* data, int length);
void DangerousCall()
{
byte[] buffer = new byte[1024];
unsafe
{
fixed (byte* p = buffer) // fixed 仅保证在此块内不被移动,但不防止回收!
{
ProcessData(p, buffer.Length);
// 在 ProcessData 执行期间,buffer 可能被 GC 回收吗?
// 答案:在当前 .NET 实现中,fixed 块会阻止回收(因为栈上持有引用),
// 但更微妙的情况是:如果 ProcessData 异步返回(如启动后台线程),
// fixed 块结束后 buffer 就会失去保护。
}
}
}
这时候就需要使用到GCHandle.Alloc创建Pinned类型的强引用或GC.KeepAlive延长生命周期。
或者在托管代码中使用静态引用来保存委托,还是推荐使用GCHandle.Alloc。
cs
byte[] buffer = new byte[1024];
GCHandle handle = GCHandle.Alloc(buffer, GCHandleType.Pinned);
IntPtr ptr = handle.AddrOfPinnedObject(); // 获取内部地址
// 将 ptr 传给非托管函数(包括异步调用)
NativeApi.StartAsyncOperation(ptr, buffer.Length);
// 不要调用 handle.Free(),直到异步操作完成并通知你。
// 当操作完成时,在回调中调用 handle.Free()。
固定对象会增加 GC 堆的碎片,应尽量缩短固定时间,且不要固定小对象(小于 85KB),因为固定大量小对象托管堆会变得严重碎片化。在分代的GC中,小对象一般被分为0或1代,因为这些小对象的分配和回收非常频繁,固定小对象相当于变相延长了内存周期。而大对象默认不进行内存压缩只进行清理,所以固定大对象是非常合理的,不会导致内存碎片问题。
cs
void SafeCall()
{
byte[] buffer = new byte[1024];
unsafe
{
fixed (byte* p = buffer)
{
// 假设这个同步调用会使用 p
NativeApi.SyncProcess(p, buffer.Length);
}
}
// 确保 buffer 在此方法内不会被回收(即使 fixed 块结束后)
GC.KeepAlive(buffer);
}
在同步场景下且无法使用GCHandle的时候,就可以使用GC.KeepAlive,此时会生成一个obj的假引用,阻止 GC 在此方法执行期间回收该对象(仅适用于同步场景)
托管代码与非托管代码
托管代码:在 .NET 公共语言运行时(CLR) 控制下执行的代码。CLR 提供自动内存管理(GC)、类型安全、异常处理、代码访问安全等基础设施。C#、VB.NET、F# 等语言编译生成的中间语言(IL)代码属于托管代码。(unsafe也属于托管代码,因为仍收CLR管理且需编译为IL)
非托管代码:不依赖于 CLR 的代码,由操作系统直接加载和执行。典型例子:C/C++ 编译生成的二进制可执行文件或动态链接库(DLL)。非托管代码自行管理内存、无类型安全保证、使用原始指针等。
.NET提供了在托管代码中调用非托管库的方法:
(1)P/Invoke(平台调用)
-
用途:调用动态链接库(DLL)中的普通函数。
-
原理:CLR 加载目标 DLL,封送参数(Marshalling),转换托管类型到非托管类型,执行函数,再将返回值封送回托管代码。
cs
[DllImport("user32.dll", CharSet = CharSet.Auto)]
public static extern int MessageBox(IntPtr hWnd, string text, string caption, uint type);
-
注意事项:
-
需要正确指定调用约定、字符集、入口点。
-
复杂类型(结构体、回调)需要显式封送(
MarshalAs)。 -
性能:每次调用有一定的封送开销。
-
(2) C++/CLI(C++ 托管扩展)
- C++/CLI 允许在同一个源文件中使用托管类型(
ref class)和非托管类型(原生class),混合编写托管代码和非托管代码,可以在C#中包装C++类,并对外提供接口。
(3)COM 互操作
(4) unsafe
(5) GCHandle 与 Marshal 类
-
GCHandle:允许非托管代码持有托管对象的引用,防止被 GC 过早回收。 -
Marshal:提供一系列方法在托管和非托管内存之间复制数据、转换类型(如Marshal.StructureToPtr)。
托管内存与非托管内存
托管内存:由 .NET 公共语言运行时(CLR)自动管理的内存。所有使用 new 关键字创建的引用类型对象、数组、字符串等,都分配在托管堆(GC堆)上。GC 负责分配、回收、压缩内存,开发者无需也无法显式释放。
非托管内存:CLR 不参与管理的内存。通常通过直接调用操作系统 API(如 Windows VirtualAlloc、C 语言的 malloc)或 .NET 中的 Marshal.AllocHGlobal / Marshal.AllocCoTaskMem 分配。开发者必须手动释放(FreeHGlobal、free 等),否则会造成内存泄漏。
3.Marshal->托管与非托管代码之间的桥梁
Marshal的意思是进行封送。System.Runtime.InteropServices.Marshal提供了一组静态方法用于在托管代码和非托管代码之间分配、拷贝、转换内存。
在需要固定小对象时,我们可以使用Marshal类:将数据复制到非托管内存,然后让非托管代码操作那个副本,而不是直接固定托管对象,这样托管堆上的小对象就可以正常回收压缩,然后副本全权交由非托管代码操作与释放。
(1)分配非托管内存(非托管堆)
cs
IntPtr ptr = Marshal.AllocHGlobal(1024); // 分配 1024 字节(类似 malloc)
Marshal.FreeHGlobal(ptr); // 注意手动释放内存
可以充当传递给非托管代码的缓冲区,从而避免固定托管的数组。因为分配出来的是非托管内存,所以不受GC管理,需要手动释放。
(2)托管与非托管内存之间的拷贝
cs
byte[] managedArray = new byte[256];
IntPtr unmanagedPtr = Marshal.AllocHGlobal(256);
// 托管 -> 非托管
Marshal.Copy(managedArray, 0, unmanagedPtr, 256);
// 非托管 -> 托管
Marshal.Copy(unmanagedPtr, managedArray, 0, 256);
Marshal.FreeHGlobal(unmanagedPtr);
这里的两个Copy是两个不同的重载,需要将managedArray和开始的索引0看作一个整体,在不同转换方向下需要将其作为一个整体和unmanagedPtr交换位置,最后一个参数为复制的元素数量。
在复制时,如果是托管对象,那么在复制期间内部会通过 GCHandle.Alloc(array, GCHandleType.Pinned) 临时固定数组,复制完成后立即释放。底层使用memcpy。
复制不会处理源与目标内存重叠的情况,复制后非托管代码可以自由使用指针,不会影响托管堆。
(3)结构体与指针的转换
cs
// 托管结构体转非托管内存(序列化)
MyStruct obj = new MyStruct();
// 分配结构体大小的非托管内存
IntPtr ptr = Marshal.AllocHGlobal(Marshal.SizeOf<MyStruct>());
Marshal.StructureToPtr(obj, ptr, false);
// 非托管内存转回托管结构体
MyStruct result = Marshal.PtrToStructure<MyStruct>(ptr);
Marshal.DestroyStructure<MyStruct>(ptr); // 如果结构体包含引用类型字段,需要清理
Marshal.FreeHGlobal(ptr);
Marshal.sizeof<T>()
返回非托管表示下结构体的大小(字节数),如果是指针则返回指针的大小(IntPtr.Size)。这个大小可能与 sizeof(MyStruct) 不同:
sizeof 只能用于unsafe上下文中的非托管类型(值类型或者是不存在引用类型的struct)
Marshal.SizeOf 可用于任何结构体,它基于封送(Marshaling) 后的布局计算大小,考虑了 [StructLayout]、[MarshalAs] 等特性,必须显式指定StructLayout。
StructLayout默认为Auto,CLR会为了性能而重新排列内存布局,所以在互操作时必须指定内存布局。Sequential为顺序布局,按照生命的顺序排列在内存中,不过会填充字节进行优化,C/C++的默认布局。Explicit显式指定每个字段在结构体中的偏移量,常用于模拟C++的union。
cs
[StructLayout(LayoutKind.Explicit)]
struct Union
{
[FieldOffset(0)] public int Integer;
[FieldOffset(0)] public float Float; // 与 Integer 重叠
[FieldOffset(4)] public byte Extra;
}
Marshal.StructureToPtr(obj, ptr, fDeleteOld)
将托管结构体复制到非托管内存块中(序列化)。相当于生成结构体的非托管副本。
指向的内存段必须是分配好的。
fDeleteOld为true会对ptr指向的内存执行DestroyStructure释放原有结构体的非托管资源,如果在这块非托管内存上的结构体含有引用类型的字段,那么必须将参数设置为ture,防止内存泄露。
如果是新分配的内存就可以使用false减少一次遍历清理的开销。
同样在转换时会固定托管堆上的结构体。memcpy或者逐字段封送。
Marshal.PtrToStructure<T>()
将非托管内存中的数据结构反序列化为托管结构体。
非托管内存中的布局必须和结构体完全匹配,而对于包含指针的结构体则会直接复制指针而不会解引用。
会分配一个新的结构体并填充其字段。对于结构体中的值类型会直接复制值,引用类型会从非托管指针还原为托管对象(例如 char* → string,连续内存 → 数组)
Marshal.DestroyStructure<MyStruct>(ptr)
当结构体包含引用类型时,StructureToPtr会为这些引用类型额外分配非托管内存,例如字符串内容被复制到新分配的 char* 缓冲区,指针存放在结构体对应的位置。如果不调用 DestroyStructure,这些内部内存就会泄漏。
对于只有值的结构体则什么都不做。
(4)处理字符串
cs
string str = "Hello";
IntPtr ptr = Marshal.StringToHGlobalAnsi(str); // 转换为 ANSI 字符串
// 使用 ptr...
Marshal.FreeHGlobal(ptr);
(5)获取函数指针(用于委托回调)
cs
Delegate del = new MyDelegate(MyMethod);
IntPtr funcPtr = Marshal.GetFunctionPointerForDelegate(del);
// 将 funcPtr 传给非托管代码
// 注意:必须保持 del 的引用,否则会被 GC 回收
(6)操作内存块
使用Marshal.ReadByte / WriteByte 等直接读写非托管内存。