
1. 引言
在 C# 开发中,你是否也遇到过这样的困境:
- 处理海量数据时,GC 不断触发回收,性能骤降
- 与本机 C/C++ 代码交互时,托管内存和非托管内存的边界让人头疼
- 对内存对齐或布局有严格要求时,托管对象始终"不听话"
C# 的垃圾回收器(GC)的确为我们省去了大部分手动管理内存的烦恼。但在 高性能计算、实时系统、图像/信号处理、跨语言交互 等特殊场景下,GC 的"自动化"反而成了瓶颈。
这时候,掌握非托管内存的申请与释放方式,就成为突破性能和灵活性限制的关键。
本文将全面系统梳理 C# 中操作非托管内存的多种方式,结合 实用代码示例、适用场景解析与性能对比,帮助我们在项目中找到最优解法。
2. 非托管内存基础知识
2.1 什么是非托管内存?
非托管内存是指不受.NET垃圾回收器管理的内存区域。与托管内存不同,非托管内存需要开发者手动分配和释放,这带来了更大的灵活性,同时也增加了内存泄漏的风险。
2.2 为什么需要使用非托管内存?
使用非托管内存主要有以下几个原因:
- 性能考量:避免GC带来的性能开销,特别是在处理大量临时对象时
- 内存控制:精确控制内存分配和释放的时机
- 与本机代码交互:与C/C++等语言编写的库进行数据交换
- 特殊内存布局:需要特定内存对齐或连续内存块的场景
- 大型数据处理:处理超出托管堆限制的大型数据集
2.3 使用非托管内存的风险
在享受非托管内存带来的灵活性和性能优势的同时,我们也需要注意以下风险:
- 内存泄漏:忘记释放内存会导致应用程序逐渐消耗所有可用内存
- 悬挂指针:访问已释放的内存可能导致程序崩溃
- 边界溢出:越界访问内存可能破坏其他数据或导致安全漏洞
- 线程安全问题:多线程环境下的非托管内存访问需要额外同步机制
3. C#中申请非托管内存的方法
C#提供了多种申请非托管内存的方式,每种方式都有其特定的用途和优缺点。下面我们将详细介绍这些方法。
3.1 Marshal.AllocHGlobal 方法
Marshal.AllocHGlobal
是最基础的非托管内存分配方法,它直接调用Windows API中的 HeapAlloc
函数。
特点:
- 直接从进程堆中分配内存
- 内存不会被GC跟踪或移动
- 需要手动调用
Marshal.FreeHGlobal
释放内存 - 分配的内存不会被初始化
适用场景:
- 与本机代码交互
- 需要长期存在的非托管内存
- 需要特定内存地址的场景
Marshal.AllocHGlobal 示例
csharp
using System;
using System.Runtime.InteropServices;
class MarshalAllocHGlobalExample
{
public static void Run()
{
Console.WriteLine("=== Marshal.AllocHGlobal 示例 ===");
// 分配100个整数大小的非托管内存
int size = 100 * sizeof(int);
IntPtr ptr = Marshal.AllocHGlobal(size);
try
{
Console.WriteLine($"已分配 {size} 字节的非托管内存,地址: 0x{ptr.ToInt64():X}");
// 将数据写入非托管内存
for (int i = 0; i < 100; i++)
{
Marshal.WriteInt32(ptr + i * sizeof(int), i);
}
// 从非托管内存读取数据
Console.WriteLine("读取前10个值:");
for (int i = 0; i < 10; i++)
{
int value = Marshal.ReadInt32(ptr + i * sizeof(int));
Console.WriteLine($" 索引 {i}: {value}");
}
}
finally
{
// 释放非托管内存(即使发生异常也会执行)
Marshal.FreeHGlobal(ptr);
Console.WriteLine("非托管内存已释放");
}
}
}
执行结果
3.2 Marshal.AllocCoTaskMem 方法
Marshal.AllocCoTaskMem
使用COM任务分配器分配内存,主要用于COM互操作场景。
特点:
- 使用COM任务分配器分配内存
- 与COM组件交互时更为兼容
- 需要手动调用
Marshal.FreeCoTaskMem
释放内存 - 在某些COM互操作场景下是必需的
适用场景:
- COM互操作
- 与使用COM任务分配器的本机代码交互
Marshal.AllocCoTaskMem 示例
csharp
using System;
using System.Runtime.InteropServices;
class MarshalAllocCoTaskMemExample
{
public static void Run()
{
Console.WriteLine("=== Marshal.AllocCoTaskMem 示例 ===");
// 分配字符串的非托管内存
string message = "Hello from unmanaged memory!";
IntPtr ptr = Marshal.StringToCoTaskMemAnsi(message);
try
{
Console.WriteLine($"已分配字符串的非托管内存,地址: 0x{ptr.ToInt64():X}");
// 从非托管内存读取字符串
string retrievedMessage = Marshal.PtrToStringAnsi(ptr);
Console.WriteLine($"读取的字符串: {retrievedMessage}");
// 修改非托管内存中的字符串
string newMessage = "Modified message";
Marshal.FreeCoTaskMem(ptr); // 释放旧内存
ptr = Marshal.StringToCoTaskMemAnsi(newMessage); // 分配新内存
// 再次读取
retrievedMessage = Marshal.PtrToStringAnsi(ptr);
Console.WriteLine($"修改后的字符串: {retrievedMessage}");
}
finally
{
// 释放非托管内存
Marshal.FreeCoTaskMem(ptr);
Console.WriteLine("非托管内存已释放");
}
}
}
执行结果
3.3 UnmanagedMemoryStream 类
UnmanagedMemoryStream
提供了对非托管内存块的流式访问。
特点:
- 提供流式API访问非托管内存
- 支持读写操作
- 可以与其他Stream类型互操作
- 不直接负责内存分配和释放
适用场景:
- 需要流式访问已分配的非托管内存
- 与其他Stream-based API交互
UnmanagedMemoryStream 示例
csharp
using System;
using System.IO;
using System.Runtime.InteropServices;
class UnmanagedMemoryStreamExample
{
public static unsafe void Run()
{
Console.WriteLine("=== UnmanagedMemoryStream 示例 ===");
// 分配非托管内存
int size = 1024;
IntPtr ptr = Marshal.AllocHGlobal(size);
try
{
// 初始化内存内容
for (int i = 0; i < size; i++)
{
Marshal.WriteByte(ptr + i, (byte)(i % 256));
}
// 创建非托管内存流
using (UnmanagedMemoryStream stream = new UnmanagedMemoryStream((byte*)ptr.ToPointer(), size, size, FileAccess.ReadWrite))
{
Console.WriteLine($"创建了大小为 {stream.Length} 字节的非托管内存流");
// 写入数据
stream.Position = 0;
byte[] dataToWrite = new byte[] { 10, 20, 30, 40, 50 };
stream.Write(dataToWrite, 0, dataToWrite.Length);
Console.WriteLine($"写入了 {dataToWrite.Length} 字节的数据");
// 读取数据
stream.Position = 0;
byte[] buffer = new byte[10];
int bytesRead = stream.Read(buffer, 0, buffer.Length);
Console.WriteLine($"读取了 {bytesRead} 字节的数据:");
for (int i = 0; i < bytesRead; i++)
{
Console.WriteLine($" 索引 {i}: {buffer[i]}");
}
}
}
finally
{
// 释放非托管内存
Marshal.FreeHGlobal(ptr);
Console.WriteLine("非托管内存已释放");
}
}
}
执行结果
3.4 unsafe 代码和指针操作
使用C#的 unsafe
关键字,可以直接通过指针操作非托管内存。
特点:
- 提供最直接的内存访问方式
- 需要启用不安全代码(编译选项)
- 语法类似C/C++的指针操作
- 完全绕过.NET的类型安全检查
适用场景:
- 需要精确控制内存布局
- 性能关键型代码
- 与指针密集型本机代码交互
unsafe 代码和指针操作示例
csharp
using System;
class UnsafeCodeExample
{
public static unsafe void Run()
{
Console.WriteLine("=== unsafe 代码和指针操作示例 ===");
// 在栈上分配内存
int arraySize = 10;
int* intArray = stackalloc int[arraySize];
Console.WriteLine($"在栈上分配了 {arraySize} 个整数的数组");
// 使用指针写入数据
for (int i = 0; i < arraySize; i++)
{
intArray[i] = i * 10;
}
// 使用指针读取数据
Console.WriteLine("数组内容:");
for (int i = 0; i < arraySize; i++)
{
Console.WriteLine($" 索引 {i}: {intArray[i]}");
}
// 指针算术运算
int* p = intArray;
Console.WriteLine("\n使用指针算术运算:");
Console.WriteLine($" *p = {*p}");
p++;
Console.WriteLine($" *(p+1) = {*p}");
p += 2;
Console.WriteLine($" *(p+3) = {*p}");
// 不需要手动释放栈上分配的内存,它会在方法结束时自动释放
}
}
执行结果
3.5 Memory/Span 和 stackalloc
.NET Core引入的 Memory<T>
和 Span<T>
类型提供了安全高效地操作内存的方式,而 stackalloc
则允许在栈上分配非托管内存。
特点:
Span<T>
提供类型安全的内存访问stackalloc
在栈上分配内存,速度快但大小有限- 无需手动释放(栈上内存会自动释放)
- 现代.NET应用中推荐的方式
适用场景:
- 短生命周期的内存操作
- 需要避免堆分配的性能关键型代码
- 现代.NET应用程序
Memory/Span 和 stackalloc 示例
csharp
using System;
class SpanAndStackallocExample
{
public static void Run()
{
Console.WriteLine("=== Memory<T>/Span<T> 和 stackalloc 示例 ===");
// 使用stackalloc在栈上分配内存
Span<int> stackInts = stackalloc int[100];
Console.WriteLine($"在栈上分配了 {stackInts.Length} 个整数的Span");
// 初始化数据
for (int i = 0; i < stackInts.Length; i++)
{
stackInts[i] = i;
}
// 使用Span操作数据
Console.WriteLine("前5个元素:");
for (int i = 0; i < 5; i++)
{
Console.WriteLine($" 索引 {i}: {stackInts[i]}");
}
// 创建子Span
Span<int> slice = stackInts.Slice(10, 10);
Console.WriteLine($"\n创建了从索引10开始的10个元素的子Span");
// 修改子Span
for (int i = 0; i < slice.Length; i++)
{
slice[i] = 99;
}
// 查看原Span中被修改的部分
Console.WriteLine("索引10-19的元素(已被子Span修改):");
for (int i = 10; i < 20; i++)
{
Console.WriteLine($" 索引 {i}: {stackInts[i]}");
}
// 使用Memory<T>
Memory<int> memory = new int[20];
Console.WriteLine($"\n创建了大小为 {memory.Length} 的Memory<int>");
// 获取Span并操作数据
Span<int> memorySpan = memory.Span;
for (int i = 0; i < memorySpan.Length; i++)
{
memorySpan[i] = i * 100;
}
Console.WriteLine("Memory<int>中的前5个元素:");
for (int i = 0; i < 5; i++)
{
Console.WriteLine($" 索引 {i}: {memorySpan[i]}");
}
}
}
执行结果
3.6 NativeMemory 类 (.NET 5+)
.NET 5引入的 NativeMemory
类提供了更现代的非托管内存分配API。
特点:
- 提供清晰的Alloc/Free API
- 支持内存对齐
- 可以选择是否初始化内存
- 更现代的API设计
适用场景:
- .NET 5及以上版本
- 需要内存对齐的场景
- 通用非托管内存分配
NativeMemory 类示例 (.NET 5+)
csharp
using System;
using System.Runtime.InteropServices;
class NativeMemoryExample
{
public static unsafe void Run()
{
Console.WriteLine("=== NativeMemory 类示例 (.NET 5+) ===");
// 分配非托管内存
int size = 1024;
IntPtr ptr = (IntPtr)NativeMemory.Alloc((nuint)size);
try
{
Console.WriteLine($"已分配 {size} 字节的非托管内存,地址: 0x{ptr.ToInt64():X}");
// 将内存清零
NativeMemory.Clear((void*)ptr, (nuint)size);
Console.WriteLine("内存已清零");
// 写入一些数据
Span<byte> span = new Span<byte>((void*)ptr, size);
for (int i = 0; i < 100; i++)
{
span[i] = (byte)i;
}
// 读取数据
Console.WriteLine("前10个字节:");
for (int i = 0; i < 10; i++)
{
Console.WriteLine($" 索引 {i}: {span[i]}");
}
// 分配对齐的内存
int alignment = 16;
IntPtr alignedPtr = (IntPtr)NativeMemory.AlignedAlloc((nuint)size, (nuint)alignment);
try
{
Console.WriteLine($"\n已分配 {size} 字节的16字节对齐非托管内存,地址: 0x{alignedPtr.ToInt64():X}");
Console.WriteLine($"地址是否16字节对齐: {alignedPtr.ToInt64() % 16 == 0}");
}
finally
{
// 释放对齐的内存
NativeMemory.AlignedFree((void*)alignedPtr);
Console.WriteLine("对齐的非托管内存已释放");
}
}
finally
{
// 释放非托管内存
NativeMemory.Free((void*)ptr);
Console.WriteLine("非托管内存已释放");
}
}
}
执行结果
3.7 GCHandle.Alloc 方法
GCHandle.Alloc
方法是一种特殊的非托管内存交互方式,它不直接分配非托管内存,而是通过"固定"托管对象使其在内存中保持不变,从而允许获取其地址并与非托管代码交互。
特点:
- 固定托管对象:防止垃圾回收器移动对象,使其地址保持稳定
- 多种句柄类型 :
GCHandleType.Pinned
:固定对象在内存中的位置GCHandleType.Normal
:创建强引用GCHandleType.Weak
:创建弱引用,允许对象被回收GCHandleType.WeakTrackResurrection
:创建可跟踪复活的弱引用
- 获取对象地址 :通过
AddrOfPinnedObject()
方法获取固定对象的内存地址 - 内存安全:需要手动释放句柄,否则可能导致内存泄漏
适用场景:
- 与非托管代码交互时需要传递托管对象的地址
- 需要在垃圾回收期间保持对象不移动
- 创建对象的弱引用,允许在内存压力下回收对象
- 在不复制数据的情况下与本机代码共享大型数组或缓冲区
GCHandle.Alloc 示例
csharp
using System;
using System.Runtime.InteropServices;
class GCHandleExample
{
// 用于演示的本机方法声明
[DllImport("kernel32.dll")]
private static extern void RtlMoveMemory(IntPtr destination, IntPtr source, int length);
public static unsafe void Run()
{
Console.WriteLine("=== GCHandle.Alloc 示例 ===");
// 创建一个托管数组
int[] managedArray = new int[10];
for (int i = 0; i < managedArray.Length; i++)
{
managedArray[i] = i * 100;
}
Console.WriteLine("原始托管数组内容:");
for (int i = 0; i < 5; i++)
{
Console.WriteLine($" 索引 {i}: {managedArray[i]}");
}
// 使用GCHandle固定数组
GCHandle handle = GCHandle.Alloc(managedArray, GCHandleType.Pinned);
try
{
// 获取固定对象的地址
IntPtr pinnedAddress = handle.AddrOfPinnedObject();
Console.WriteLine($"固定数组的内存地址: 0x{pinnedAddress.ToInt64():X}");
// 创建一个新数组并修改原始数据
int[] newValues = new int[] { 999, 888, 777 };
GCHandle newValuesHandle = GCHandle.Alloc(newValues, GCHandleType.Pinned);
try
{
// 使用本机方法复制内存
RtlMoveMemory(pinnedAddress, newValuesHandle.AddrOfPinnedObject(), 3 * sizeof(int));
Console.WriteLine("使用本机方法修改后的数组内容:");
for (int i = 0; i < 5; i++)
{
Console.WriteLine($" 索引 {i}: {managedArray[i]}");
}
}
finally
{
// 释放新值的句柄
newValuesHandle.Free();
}
// 演示弱引用
object largeObject = new byte[10000000]; // 创建一个大对象
GCHandle weakHandle = GCHandle.Alloc(largeObject, GCHandleType.Weak);
Console.WriteLine($"\n创建了对大对象的弱引用");
Console.WriteLine($"弱引用是否仍然有效: {weakHandle.Target != null}");
// 强制进行垃圾回收
largeObject = null;
GC.Collect();
GC.WaitForPendingFinalizers();
Console.WriteLine("垃圾回收后:");
Console.WriteLine($"弱引用是否仍然有效: {weakHandle.Target != null}");
// 释放弱引用句柄
weakHandle.Free();
}
finally
{
// 释放固定对象的句柄
handle.Free();
Console.WriteLine("GCHandle已释放");
}
}
}
执行结果
4. 性能对比与最佳实践
4.1 性能对比
下面是各种非托管内存分配方法的性能对比:
方法 | 分配速度 | 访问速度 | 内存开销 | 安全性 | 适用场景 |
---|---|---|---|---|---|
Marshal.AllocHGlobal | 中等 | 快 | 低 | 低 | 与本机代码交互 |
Marshal.AllocCoTaskMem | 中等 | 快 | 低 | 低 | COM互操作 |
UnmanagedMemoryStream | 不适用 | 中等 | 中等 | 中等 | 流式访问 |
unsafe代码和指针 | 快 | 最快 | 最低 | 最低 | 性能关键型代码 |
stackalloc | 最快 | 最快 | 无堆开销 | 低 | 小型临时缓冲区 |
Span/Memory | 快 | 快 | 低 | 高 | 现代.NET应用 |
NativeMemory | 中等 | 快 | 低 | 中等 | 通用非托管内存 |
GCHandle.Alloc | 快 | 快 | 中等 | 中等 | 固定托管对象 |
4.2 最佳实践建议
在使用非托管内存时,请遵循以下最佳实践:
-
始终释放非托管内存
- 使用
try-finally
块确保即使发生异常也能释放内存 - 考虑使用
using
语句或实现IDisposable
接口
- 使用
-
选择合适的分配方法
- 短生命周期、小型缓冲区:优先使用
stackalloc
和Span<T>
- 与COM交互:使用
Marshal.AllocCoTaskMem
- 通用场景:在.NET 5+中使用
NativeMemory
,旧版本使用Marshal.AllocHGlobal
- 短生命周期、小型缓冲区:优先使用
-
注意内存安全
- 避免越界访问
- 不要使用已释放的内存
- 在多线程环境中使用适当的同步机制
-
考虑使用安全包装器
- 创建封装非托管内存操作的安全类
- 实现
IDisposable
接口自动管理资源释放
-
性能优化
- 批量处理数据以减少分配次数
- 重用非托管内存而不是频繁分配/释放
- 考虑内存池化技术
5. 总结
C#提供了多种申请非托管内存的方式,每种方式都有其特定的用途和优缺点。在选择合适的方法时,需要考虑性能需求、安全性、兼容性和代码可维护性等因素。
- 对于现代.NET应用,
Span<T>
/Memory<T>
和stackalloc
通常是首选 - 对于与本机代码交互,
Marshal.AllocHGlobal
和Marshal.AllocCoTaskMem
仍然是重要选择 - 对于.NET 5及以上版本,
NativeMemory
提供了更现代的API
无论选择哪种方式,正确管理非托管内存的生命周期都是至关重要的。通过遵循本文介绍的最佳实践,你可以安全高效地利用非托管内存,在特定场景下获得显著的性能提升。
你是否已经在项目中使用过非托管内存?你遇到了哪些挑战?欢迎在评论区分享你的经验和见解!