CodeGym /课程 /C# SELF /C# 中的低级内存管理

C# 中的低级内存管理

C# SELF
第 65 级, 课程 1
可用

1. 介绍

在上一讲我们说到 C# 和 .NET Runtime 会替你处理大部分内存管理的事。但有时候你需要或者想要更低层次的内存控制,尤其是为了性能优化或者和非托管代码交互(比如用 C++ 写的系统 API)。C# 提供了一些用于这些场景的特性,虽然它们很强,但用起来要小心。

当我们说在 C# 中的“低级内存管理”时,通常指:

  • 直接使用指针:按地址访问内存,就像在 C/C++ 里一样。
  • 在 GC 堆之外分配内存:使用垃圾回收器不追踪的内存。
  • 管理托管/非托管边界的资源:高效地和本地库交互。

普通的 C# 代码为了安全和稳定,不允许直接操作内存地址(指针)。但为了低级操作,可以在 unsafe 上下文中使用指针。

什么是 unsafe 上下文?

被 unsafe 标记的块或方法允许你使用指针语法并执行 CLR 不会验证安全性的操作。unsafe 里的代码跟本地代码一样不受 CLR 的完全保护。要使用 unsafe 代码,项目需要用 /unsafe 选项编译。

示例:声明 unsafe 方法和块


public unsafe class UnsafeExamples
{
    // Unsafe 方法
    public static unsafe void ManipulatePointer(int* ptr)
    {
        Console.WriteLine($"指针指向的值: {*ptr}");
        *ptr = 200; // 修改地址上的值
    }

    public static void DemoUnsafeBlock()
    {
        int value = 100;
        unsafe // 在普通方法里的 unsafe 块
        {
            int* ptr = &value; // 获取变量地址
            ManipulatePointer(ptr); // 把指针传给 unsafe 方法
            Console.WriteLine($"新值: {value}"); // 输出: 新值: 200
        }
    }
}

指针类型

在 C# 里可以声明针对值类型的指针(int*, bool*, MyStruct*)以及 void 类型的指针(void*,通用指针)。不能直接声明对引用类型的指针,但如果引用类型的字段是值类型,可以获得该字段的指针。

示例:不同类型的指针


unsafe
{
    double d = 123.45;
    double* dPtr = &d; // 指向 double 的指针

    int[] numbers = { 1, 2, 3 };
    fixed (int* arrPtr = numbers) // 'fixed' 把对象固定在内存中,防止 GC 移动它
    {
        Console.WriteLine($"数组第一个元素: {arrPtr[0]}");
        Console.WriteLine($"数组第二个元素: {*(arrPtr + 1)}");
    }
}

指针运算符

  • &(地址):取得变量地址。
  • *(解引用):取得地址处的值。
  • ->(访问成员):通过指针访问结构/类的成员(仅限值类型)。
  • [](索引):通过指针访问数组元素(像 C/C++)。

2. 固定和分配的内存块

当你用指针时,很重要的一点是被指向的对象在你操作期间不能被 GC 移动。为此使用关键字 fixed 和 stackalloc。

操作符 fixed

fixed 操作符会把引用类型变量“固定”在内存中,防止它在 fixed 块执行期间被垃圾回收器移动。这在把指针传给非托管代码或直接操作托管对象时至关重要。

示例:对数组使用 fixed


public unsafe class FixedExample
{
    public static void ProcessFixedArray()
    {
        byte[] buffer = new byte[10];
        // 填充缓冲区
        for (int i = 0; i < buffer.Length; i++)
        {
            buffer[i] = (byte)(i + 1);
        }

        unsafe
        {
            // 把数组固定在内存中,获取第一个元素的指针
            fixed (byte* p = buffer)
            {
                // 现在可以安全地用 p,知道数组不会被移动
                Console.WriteLine($"第一个字节的值: {*p}");
                Console.WriteLine($"第二个字节的值: {*(p + 1)}");
                // 可以把 'p' 传给期待指针的本地函数
            } // 退出 fixed 块后,数组可能会被 GC 移动
        }
    }
}

fixed 也可以用在结构字段或字符串上。

在栈上分配内存:stackalloc

stackalloc 允许在栈上分配一块内存。速度很快,但内存只在当前方法内可用。分配的内存不由垃圾回收器管理。

  • 优点:分配非常快,无 GC 开销,释放是确定性的。
  • 缺点:大小受限(栈比较小),分配太多可能导致栈溢出(StackOverflowException)。
  • 使用场景:适合小的、短期存在的缓冲区。

示例:使用 stackalloc


public unsafe class StackAllocExample
{
    public static void ProcessStackAlloc()
    {
        unsafe
        {
            // 在栈上分配 10 个 int
            int* numbers = stackalloc int[10];

            for (int i = 0; i < 10; i++)
            {
                numbers[i] = i * 10;
            }

            Console.WriteLine($"第一个元素的值: {numbers[0]}");
            Console.WriteLine($"第五个元素的值: {numbers[4]}");
        } // 这方法结束时内存自动释放
    }
}

stackalloc 可以和 Span<T> 一起用,这能让对这块内存的操作更安全,因为 Span<T> 是一个托管类型(结构体),提供对连续内存块的安全访问。关于 Span 的更多内容在下一讲会讲。

示例:用 stackalloc 和 Span<T>


using System;

public class StackAllocWithSpan
{
    public static void DemoSpanStackAlloc()
    {
        // 在栈上分配,但通过安全的 Span
  
    来工作 Span
   
     buffer = stackalloc int[10]; for (int i = 0; i < buffer.Length; i++) { buffer[i] = i * 2; } Console.WriteLine($"Span 的第一个元素: {buffer[0]}"); Console.WriteLine($"Span 的最后一个元素: {buffer[9]}"); // 可以把 Span
    
      传给接受它的方法 PrintSpan(buffer); } public static void PrintSpan(Span
     
       s) { foreach (var item in s) { Console.Write($"{item} "); } Console.WriteLine(); } } 
     
    
   
  

这是混合方法:在栈上分配(低级),但通过 Span<T>(高级)安全访问。

3. 与非托管代码交互(P/Invoke)

Platform Invoke (P/Invoke) 是一种机制,允许 C# 调用非托管库里的函数(比如 Windows API 的 DLL,或 Linux 的 .so 文件)。这是低级内存管理的重要组成部分,因为你经常需要在托管和非托管之间传递指针。

声明外部函数

你用 DllImport 特性声明静态的 extern 方法,对应本地函数。

示例 3.1:调用 Windows API 函数


using System.Runtime.InteropServices;

public class PInvokeExample
{
    // 从 user32.dll 导入本地函数 MessageBox
    [DllImport("user32.dll", CharSet = CharSet.Unicode)]
    public static extern int MessageBox(IntPtr hWnd, string lpText, string lpCaption, uint uType);

    public static void ShowMessageBox()
    {
        // 调用本地函数
        MessageBox(IntPtr.Zero, "来自 C# 的问候!", "窗口标题", 0);
    }
}

数据的 marshal(编组)

调用本地函数时 .NET Runtime 会做 marshaling —— 把数据类型从托管格式转换成非托管格式。例如,C# 的 string 可能会被编组成 C++ 的 char* 或 wchar_t*。

对于更复杂的编组,可以用 MarshalAs 特性和 Marshal 类。

示例:编组结构体


using System.Runtime.InteropServices;

// 这个结构体会被编组为本地结构体
[StructLayout(LayoutKind.Sequential)] // 指示字段按顺序排列
public struct NativePoint
{
    public int X;
    public int Y;
}

public class StructMarshalExample
{
    [DllImport("your_native_lib.dll")] // 示例:本地库中的函数
    public static extern void ProcessPoint(NativePoint point);

    [DllImport("your_native_lib.dll")]
    public static extern void FillPoint(out NativePoint point); // 接受指向结构的指针

    public static void DemoStructMarshal()
    {
        NativePoint myPoint = new NativePoint { X = 10, Y = 20 };
        ProcessPoint(myPoint); // 结构会按值编组(拷贝)

        NativePoint resultPoint;
        FillPoint(out resultPoint); // 本地函数会填充结构
        Console.WriteLine($"来自本地的点: ({resultPoint.X}, {resultPoint.Y})");
    }
}

4. GCHandle 和对象固定

GCHandle 是个结构体,允许你获得一个托管堆上对象的“句柄”,并在需要时临时把它固定,防止被移动或回收。当你需要给非托管代码传一个稳定的指针到托管对象时,这很有用。

示例:用 GCHandle 固定对象


using System.Runtime.InteropServices;

public class GCHandleExample
{
    public static void PinObject()
    {
        byte[] data = new byte[100];
        GCHandle handle = GCHandle.Alloc(data, GCHandleType.Pinned); // 把数组固定在内存中
        try
        {
            IntPtr pointer = handle.AddrOfPinnedObject(); // 获取固定对象的指针
            Console.WriteLine($"固定数组的地址: {pointer:X}");

            // 现在可以把 'pointer' 安全地传给本地函数。
            // 本地函数可以直接操作这块内存。

            Marshal.WriteByte(pointer, 0, 255); // 通过指针修改第一个字节
            Console.WriteLine($"数组的第一个字节: {data[0]}"); // 输出: 255
        }
        finally
        {
            if (handle.IsAllocated)
            {
                handle.Free(); // 释放句柄,让 GC 可以再次管理对象
            }
        }
    }
}

对 P/Invoke 和 GCHandle 的深入讲解超出本课程范围,但它们在你需要直接调用 Windows 库时会很有用。至少现在你知道该往哪个方向继续学习。

2
任务
C# SELF, 第 65 级, 课程 1
已锁定
使用 stackalloc 在栈上分配内存
使用 stackalloc 在栈上分配内存
评论
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION