從C++看C#託管記憶體與非託管記憶體

ggtc發表於2024-07-31

程序的記憶體

一個exe檔案,在沒有執行時,其磁碟儲存空間格式為函式程式碼段+全域性變數段。載入為記憶體後,其程序記憶體模式增加為函式程式碼段+全域性變數段+函式呼叫棧+堆區。我們重點討論堆區。

程序記憶體
函式程式碼段
全域性變數段
函式呼叫棧
堆區

託管堆與非託管堆

  • C#
    int a=10這種程式碼申請的記憶體空間位於函式呼叫棧區

    var stu=new Student();
    GC.Collect();
    

    new運算子申請的記憶體空間位於堆區。關鍵在於new關鍵字。在C#中,這個關鍵字是向CLR虛擬機器申請空間,因此這個記憶體空間位於託管堆上面,如果沒有對這個物件的引用,在我們呼叫GC.Collect()後,或者CLR主動收集垃圾,申請的這段記憶體空間就會被CLR釋放。這種機制簡化了記憶體管理,我們不能直接控制記憶體的釋放時機。不能精確指定釋放哪個物件佔用的空間。

    我不太清楚CLR具體原理,但CLR也只是執行在作業系統上的一個程式。假設它是C++寫的,那麼我們可以想象,CLR呼叫C++new關鍵字後向作業系統申請了一個堆區空間,然後把這個變數放在一個全域性列表裡面。然後記錄我們執行在CLR上面的C#託管程式堆這個物件的引用。當沒有引用存在之後,CLR從列表中刪除這個物件,並呼叫delete xxx把記憶體釋放給作業系統。

    但是非託管堆呢?

  • C++
    在C++中也有new關鍵字,比如

    Student* stu=new Student();
    delete stu;
    //引發異常
    cout >> stu->Name >> stu->Age;
    

    申請的記憶體空間也位於堆區。但又C++沒有虛擬機器,所以C++中的new關鍵字實際上是向作業系統申請記憶體空間,在程序關閉後,又作業系統釋放。但是C++給了另一個關鍵字deletedelete stu可以手動釋放向作業系統申請的記憶體空間。之後訪問這個結構體的欄位會丟擲異常

  • C
    C語言中沒有new關鍵字,但卻有兩個函式,mallocfree

    int* ptr = (int *)malloc(5 * sizeof(int));
    free(ptr);
    

    他們起到了和C++中new關鍵字相同的作用。也是向作業系統申請一塊在堆區的記憶體空間。

C#透過new關鍵字向CLR申請的記憶體空間位於託管堆。C++透過new關鍵字向作業系統申請的記憶體空間位於非託管堆。C語言透過mallocfree向作業系統申請的記憶體空間也位於非託管堆。C#的new關鍵字更像是對C++的new關鍵字的封裝。

C#如何申請位於非託管堆的記憶體空間

C#本身的new運算子申請的是託管堆的記憶體空間,要申請非託管堆記憶體空間,目前我知道的只有透過呼叫C++的動態連結庫實現。在.net8以前,使用DllImport特性在函式宣告上面。在.net8,使用LibraryImport特性在函式宣告上面

C++部分

新建一個C++動態連結庫專案
image

然後新增.h標頭檔案和.cpp原始檔

//Student.h

#pragma once
#include <string>
using namespace std;

extern struct Student
{
	wchar_t* Name;// 使用 char* 替代 std::string 以保證與C#相容
	int Age;
};

//__declspec(xxx)是MSC編譯器支援的關鍵字,dllexport表示匯出後面的函式
/// <summary>
/// 建立學生
/// </summary>
/// <param name="name">姓名</param>
/// <returns>學生記憶體地址</returns>
extern "C" __declspec(dllexport) Student* CreateStudent(const wchar_t* name);

/// <summary>
/// 釋放堆上的記憶體
/// </summary>
/// <param name="student">學生地址</param>
extern "C" __declspec(dllexport) void FreeStudent(Student* student);
//Student.cpp

//pch.h在專案屬性中指定,pch.cpp必需
#include "pch.h"

#include "Student.h"
#include <cstring>

Student* CreateStudent(const wchar_t* name)
{
	//new申請堆空間
	Student* student = new Student;
	student->Age = 10;
	//new申請名字所需要的堆空間
	//wcslen應對unicode,ansi的話,使用strlen和char就夠了
	student->Name = new wchar_t[wcslen(name) + 1];
	//記憶體賦值
	wcscpy_s(student->Name, wcslen(name) + 1, name);
	return student;
}

void FreeStudent(Student* student)
{
	// 假設使用 new 分配
	delete[] student->Name;//釋放陣列形式的堆記憶體
    delete student; 
}

生成專案後,在解決方案下的x64\Debug中可以找到DLL

C#部分

由於C++動態連結庫不符合C#動態連結庫的規範。所以沒法在C#專案的依賴中直接新增對類庫的引用。只需要把DLL放在專案根目錄下,把檔案複製方式改為總是複製,然後程式碼中匯入。

[DllImport("Student.dll", //指定DLL
CharSet=CharSet.Unicode//指定字串編碼
)]
public static extern IntPtr CreateStudent(string name);

[DllImport("Student.dll")]
private static extern IntPtr FreeStudent(IntPtr stu);
		
public static void Main()
{
    string studentName = "John";
    //用IntPtr接收C++申請空間的起始地址
    IntPtr studentPtr = CreateStudent(studentName);

    // 在C#中操作Student結構體需要進行手動的記憶體管理,如下
    // 從地址所在記憶體構建C#物件或結構體,類似於指標的解引用
    Student student = Marshal.PtrToStructure<Student>(studentPtr);

    // 訪問學生資訊
    //Marshal.PtrToStringUni(student.Name)將一段記憶體解釋為unicode字串,直到遇見結束符'\0'
    Console.WriteLine($"Student Name: {Marshal.PtrToStringUni(student.Name)}, Age: {student.Age}");

    // 記得釋放分配的記憶體
    FreeStudent(studentPtr);
}

// 定義C++的Student結構體
[StructLayout(LayoutKind.Sequential)]
public struct Student
{
    // IntPtr對應C++中的 char*
    public IntPtr Name;
    public int Age;
}

呼叫結果如下

image

非託管類釋放非託管記憶體空間

如果我們把C++程式碼的呼叫封裝成類,那麼可以實現IDisposable介面。在Dispose方法中釋放資源,然後使用using語句塊來確保Dispose方法被呼叫。這樣使得記憶體洩漏可能性降低。

繼承IDisposable介面後按下alt+enter,選擇透過釋放模式實現介面可以快速生成程式碼

/// <summary>
/// 非託管類
/// </summary>
public class Student:IDisposable
{
    // 定義C++的Student結構體
    [StructLayout(LayoutKind.Sequential)]
    private struct _Student
    {
        public IntPtr Name;
        public int Age;
    }

    // IntPtr對應C++中的 char*
    //需要在Dispose中手動釋放
    private IntPtr _this;
    private IntPtr name;

    public string Name => Marshal.PtrToStringUni(name);
    public int Age;

    private bool disposedValue;

    public Student(string name)
    {
        _this=CreateStudent(name);
        _Student layout = Marshal.PtrToStructure<_Student>(_this);
		//記住要釋放的記憶體起始地址
        this.Age = layout.Age;
        this.name = layout.Name;
    }

    [DllImport("Student.dll", CharSet = CharSet.Unicode)]
    private static extern IntPtr CreateStudent(string name);

    [DllImport("Student.dll")]
    private static extern IntPtr FreeStudent(IntPtr stu);

    protected virtual void Dispose(bool disposing)
    {
        if (!disposedValue)
        {
            if (disposing)
            {
                // TODO: 釋放託管狀態(託管物件)
            }

            // TODO: 釋放未託管的資源(未託管的物件)並重寫終結器
            if (_this != IntPtr.Zero)
            {
                FreeStudent(_this);
                //設定為不可訪問
                _this = IntPtr.Zero;
                name = IntPtr.Zero;
            }
            // TODO: 將大型欄位設定為 null
            disposedValue = true;
        }
    }

    // // TODO: 僅當“Dispose(bool disposing)”擁有用於釋放未託管資源的程式碼時才替代終結器
    // ~Student()
    // {
    //     // 不要更改此程式碼。請將清理程式碼放入“Dispose(bool disposing)”方法中
    //     Dispose(disposing: false);
    // }

    public void Dispose()
    {
        // 不要更改此程式碼。請將清理程式碼放入“Dispose(bool disposing)”方法中
        Dispose(disposing: true);
        GC.SuppressFinalize(this);
    }
}

然後在Main中建立物件

string studentName = "John";
using (Student stu=new Student(studentName))
{
    Console.WriteLine($"Student Name: {stu.Name}, Age: {stu.Age}");
}
return;

結果

image

程式碼確實執行到了這裡。

  • 單步除錯執行流程,using->Console->Dispose()->Dispose(bool disposing)->FreeStudent(_this);

image

事實上可以在FreeStudent(_this);之後加一句程式碼Console.WriteLine(Name);,你將會看到原本的正常屬性變成了亂碼

image

其實程式碼有點重複。如果我把_Student layout = Marshal.PtrToStructure<_Student>(_this);中的layout定義為Student的私有成員,那麼Student中的那兩個私有指標就不需要了,完全可以從layout中取得。

相關文章