C#与C++交互开发系列(三十):C#非托管内存分配大比拼,哪种方式才是真正的性能王者?

1. 引言

在 C# 开发中,你是否也遇到过这样的困境:

  • 处理海量数据时,GC 不断触发回收,性能骤降
  • 与本机 C/C++ 代码交互时,托管内存和非托管内存的边界让人头疼
  • 对内存对齐或布局有严格要求时,托管对象始终"不听话"

C# 的垃圾回收器(GC)的确为我们省去了大部分手动管理内存的烦恼。但在 高性能计算、实时系统、图像/信号处理、跨语言交互 等特殊场景下,GC 的"自动化"反而成了瓶颈。

这时候,掌握非托管内存的申请与释放方式,就成为突破性能和灵活性限制的关键。

本文将全面系统梳理 C# 中操作非托管内存的多种方式,结合 实用代码示例、适用场景解析与性能对比,帮助我们在项目中找到最优解法。

2. 非托管内存基础知识

2.1 什么是非托管内存?

非托管内存是指不受.NET垃圾回收器管理的内存区域。与托管内存不同,非托管内存需要开发者手动分配和释放,这带来了更大的灵活性,同时也增加了内存泄漏的风险。

2.2 为什么需要使用非托管内存?

使用非托管内存主要有以下几个原因:

  1. 性能考量:避免GC带来的性能开销,特别是在处理大量临时对象时
  2. 内存控制:精确控制内存分配和释放的时机
  3. 与本机代码交互:与C/C++等语言编写的库进行数据交换
  4. 特殊内存布局:需要特定内存对齐或连续内存块的场景
  5. 大型数据处理:处理超出托管堆限制的大型数据集

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 方法是一种特殊的非托管内存交互方式,它不直接分配非托管内存,而是通过"固定"托管对象使其在内存中保持不变,从而允许获取其地址并与非托管代码交互。

特点:

  1. 固定托管对象:防止垃圾回收器移动对象,使其地址保持稳定
  2. 多种句柄类型
    • GCHandleType.Pinned:固定对象在内存中的位置
    • GCHandleType.Normal:创建强引用
    • GCHandleType.Weak:创建弱引用,允许对象被回收
    • GCHandleType.WeakTrackResurrection:创建可跟踪复活的弱引用
  3. 获取对象地址 :通过 AddrOfPinnedObject() 方法获取固定对象的内存地址
  4. 内存安全:需要手动释放句柄,否则可能导致内存泄漏

适用场景:

  1. 与非托管代码交互时需要传递托管对象的地址
  2. 需要在垃圾回收期间保持对象不移动
  3. 创建对象的弱引用,允许在内存压力下回收对象
  4. 在不复制数据的情况下与本机代码共享大型数组或缓冲区
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 最佳实践建议

在使用非托管内存时,请遵循以下最佳实践:

  1. 始终释放非托管内存

    • 使用 try-finally 块确保即使发生异常也能释放内存
    • 考虑使用 using 语句或实现 IDisposable 接口
  2. 选择合适的分配方法

    • 短生命周期、小型缓冲区:优先使用 stackallocSpan<T>
    • 与COM交互:使用 Marshal.AllocCoTaskMem
    • 通用场景:在.NET 5+中使用 NativeMemory,旧版本使用 Marshal.AllocHGlobal
  3. 注意内存安全

    • 避免越界访问
    • 不要使用已释放的内存
    • 在多线程环境中使用适当的同步机制
  4. 考虑使用安全包装器

    • 创建封装非托管内存操作的安全类
    • 实现 IDisposable 接口自动管理资源释放
  5. 性能优化

    • 批量处理数据以减少分配次数
    • 重用非托管内存而不是频繁分配/释放
    • 考虑内存池化技术

5. 总结

C#提供了多种申请非托管内存的方式,每种方式都有其特定的用途和优缺点。在选择合适的方法时,需要考虑性能需求、安全性、兼容性和代码可维护性等因素。

  • 对于现代.NET应用,Span<T>/Memory<T>stackalloc 通常是首选
  • 对于与本机代码交互,Marshal.AllocHGlobalMarshal.AllocCoTaskMem 仍然是重要选择
  • 对于.NET 5及以上版本,NativeMemory 提供了更现代的API

无论选择哪种方式,正确管理非托管内存的生命周期都是至关重要的。通过遵循本文介绍的最佳实践,你可以安全高效地利用非托管内存,在特定场景下获得显著的性能提升。

你是否已经在项目中使用过非托管内存?你遇到了哪些挑战?欢迎在评论区分享你的经验和见解!

相关推荐
睡不醒的kun4 分钟前
leetcode算法刷题的第二十一天
数据结构·c++·算法·leetcode·职场和发展·回溯算法·回归算法
小欣加油5 分钟前
leetcode 461 汉明距离
c++·算法·leetcode
淮北4947 分钟前
linux系统学习(15.启动管理)
运维·服务器·网络·c++·vscode·学习
mit6.82413 分钟前
[游戏中的空间划分] KD树|四叉树|价格系统
c++·游戏·游戏程序
三小尛2 小时前
C++继承
开发语言·c++
小码编匠3 小时前
手把手教会设计 WinForm 高DPI兼容程序,告别字体模糊与控件乱飞(.NET 4.6.1/.NET 6.0)
后端·c#·.net
小苏兮4 小时前
【C++】类与对象(上)
开发语言·c++·学习
钩鸿踏月4 小时前
复盘一个诡异的Bug之FileNotFoundException
c#·bug·.net
大龄门外汉4 小时前
CPP学习之map和set
c++·笔记·学习·stl·set·map·改行学it
上官鹿离5 小时前
C++学习笔记之输入输出流
c++·笔记·学习