C++與C#記憶體管理對比分析

來源:互聯網
上載者:User

C++與C#管理記憶體方式概述

C#最大的一個改進其實就是對記憶體訪問與管理方法的改進。在.NET中記憶體的管理是全權委託給記憶體回收行程,由記憶體回收行程來決定何時該釋放記憶體空間。現在普遍採用兩種技術來釋放程式動態申請的系統記憶體:首先是以C++為代表的必須以手工方式使應用程式程式碼完成這些工作,讓對象維護引用計數。然後是以.NET以及Java使用的記憶體回收行程來完成記憶體釋放工作。

在C++中讓應用程式代碼負責釋放記憶體是低級、高效能的語言使用技術。這種技術非常有效,且可以讓資源在不需要時就釋放,因為這種技術可以直接存取記憶體,所以其最大的缺點是可能導致錯誤。而且如果程式員的記性不太好的話,也會常常忘記釋放記憶體而導致記憶體流失。

在C#中記憶體的管理是依靠記憶體回收行程,記憶體回收行程是一個清理記憶體的程式。所有採用new關鍵字申請的動態記憶體空間都會分配到堆上,當.NET檢測到給定過程的堆已經滿時,需要清理時,就會調用記憶體回收行程。記憶體回收行程將採用記憶體回收演算法將那些不再被引用的對象所佔用的記憶體空間釋放掉。顯然由於程式員無法直接控制記憶體的釋放,所開發出的軟體效能和效率上一定會受到很大的影響。不過這種影響是隨著電腦硬體技術的發展日益縮小的。

究竟是C++中直接由程式員管理記憶體好,還是像.NET中那樣由單獨一個程式來統一管理好呢?這個問題是公說公有理,婆說婆有理。但是我相信隨著電腦硬體技術不斷的發展、儲存空間空間越來越大、軟體的複雜性和軟體健壯性要求的不斷提高,程式員直接管理記憶體的方式必將會退出曆史舞台。當今的程式員不必再為該如何把程式分塊放到容量有限的記憶體中運行而擔心,因為這項任務已經交給了作業系統的虛擬記憶體來管理。相信不久將來人們也會習慣完全交由諸如記憶體回收行程一類的專門程式來管理程式申請的記憶體空間。

C++中記憶體的分配方式

在C++中記憶體的分配方式大致有三種:

(1)    從靜態儲存地區分配。記憶體在程式編譯的時候已經分配完畢了。並且這塊記憶體中的所有資料在程式的整個運行期間都始終存在的。例如:全域變數,static變數等等。

(2)    在棧上建立。在函數執行期間,無論什麼時候到達一個特殊的執行點(左花括弧)時,儲存單元都可以在棧上被建立。出了執行點(右花括弧),這個儲存單元自動被釋放。這些棧分配運算內建在處理器的指令集中,非常有效,並且不存在失敗的危險,但是可供分配的記憶體容量很有限。

(3)    儲存單元也可以從一塊稱為堆(也被稱為自由儲存單元)的地方分配,從堆(heap)上分配,亦成為動態分配。在C++中,程式在運行期間可以用malloc或者new申請任意數量的記憶體,程式員自己掌握釋放記憶體的適當時機(使用free或者delete)。動態記憶體的生存期間是由程式員決定,使用非常靈活,但也最容易產生問題。

C++使用者管理記憶體常出現的問題

在C++中,我們必須非常小心第三種記憶體配置方式,因為記憶體的分配和釋放都得由程式員來控制,一不小心就會出錯。下面我就分析下在C++中,由於第三種記憶體配置方式而導致的一些常見的記憶體流失以及一系列的指標問題。

#include<iostream.h>

#include<string.h>

void GetMemory(char *p, int num)

{

p = new char[12];

}

void main()

{

char *str = NULL;

GetMemory(str,100);

strcpy(str,"hello");

}

注意到函數GetMemory(char *p,int num),中的第一個字元型指標參數。寫程式的人的本意可能是希望通過此函數為str指標申請記憶體。但事實上卻是str並不會得到所期望得到的記憶體,str依舊是NULL。因為函數GetMemory(char *p,int num)中所得到的只是指標str的一個副本使得p = str ,他們所儲存的內容均是指向同一個記憶體的地址,但是由於p申請了新的記憶體,但str指標的值並沒被改變,所以函數GetMemory並不能得到任何有用的東西。並且由於每執行一次GetMemory就會泄漏一塊記憶體,因為沒有使用free釋放記憶體。

#include<iostream>

using namespace std;

class X

{

public:

      int *ptrArray;

      int size;

      X(int *ptr , int size)

      {

             ptrArray = new int[size];      //(A)

             for(int i=0;i<size;i++)ptrArray[i]=ptr[i];

      }

};

int main()

{

      int arrayData[100]={0};

      X *bill = new X(arrayData,100);  //(B)

      delete bill;                    //(C)

      return 0;

}

例如上面程式中的X類,程式員忘記了編寫解構函式來釋放在類中所動態申請記憶體空間。注意到程式第B行代碼處,聲明了一個X類的對象指標bill。然後在代碼第A行處代碼動態申請了一段記憶體空間,並且把數組arrayData中的數值都複製到類中。

接著在代碼第C行處,我們刪除了指標bill所指向的記憶體空間。但是類中第A行所申請的記憶體空間並不會被刪除,所以將造成記憶體流失。

#include<iostream.h>

#include<string.h>

void main()

{

char *pointer = new char[100];

strcpy(pointer,"Hello , I am Howells !");

cout<<pointer<<endl;

cout<<"Before call delete function, the address of pointer is : "<<&pointer<<endl;

delete pointer;  //(A)

cout<<"After call delete function, the address of pointer is : "<<&pointer<<endl;

//其間程式非常長,程式員也許忘記了p所指向的記憶體空間已經釋放。

     if(pointer != NULL)

     {

strcpy(pointer, "world");  // (B)

}

}

 

程式運行結果:

Hello , I am Howells !

Before call delete function, the address of pointer is : 0x0013FF7C

After call delete function, the address of pointer is : 0x0013FF7C

Press any key to continue

 

注意到程式中第A行代碼,雖然在第A行代碼對pointer指標進行了delete操作,但是delete方法只是會釋放掉該地址所指向的記憶體,而pointer指標仍然指向原來所指向的記憶體位址。通常由於程式比較長,程式員有時可能記不住pointer所指向的記憶體是否被釋放掉了,所以我們會使用一個語句if(pointer != NULL)來進行測試,但是很遺憾的是,pointer並不是NULL,它指向一塊不合法的記憶體單元。第B行的代碼在VC++6.0是可行的,也就是說strcpy操作會將一塊不屬於自己的一塊記憶體單元賦值,這非常可怕會對其他程式造成潛在的危害。

#include<iostream.h>

class A

{

public:

        void Func(void){cout<<"A::func() " <<endl;}

    int I;

};

A *Test(void)

{

      A a;

      return &a;   / /(A)

}

void main()

{

      A *p = NULL;

      p = Test();

      p->Func();

}

在Test函數中我們聲明了一個對象a,然後返回對象a的地址。我們知道由於a是臨時變數,所以當函數出了執行點(右花括弧)時,a被退棧了,但是a所在的儲存單元並沒有被清除掉。所以仍然可以通過指標p來訪問函數Func()。這顯然將造成潛在的危險。

C#中記憶體管理機制

C#中動態記憶體分配方式

在C#中對記憶體的管理是依靠.NET 記憶體回收行程來完成的,記憶體回收行程為高速的分配服務提供了很好的記憶體使用量機制。它可以恢複正在運行中的應用程式需要的記憶體。記憶體回收行程負責清理記憶體,當.NET檢測到給定過程的堆已滿時,需要清理時,就需要調用記憶體回收行程,下面我將詳細介紹.NET的記憶體配置機制。和C++一樣,在.NET中使用者所申請的動態記憶體空間將被分配到堆上,不同的是在.NET上的堆是託管堆。自動記憶體管理是公用語言運行庫在託管執行過程過程中提供的服務之一。公用語言運行庫的記憶體回收行程為應用程式管理記憶體的分配和釋放。對開發人員而言,這就意味著在開發託管應用程式時不必編寫執行記憶體管理工作的代碼。

在C#中大致有三種不同的儲存單元:

(1)    Managed Heap:這是動態配置(Dynamic Allocation)的儲存單元,由Gargage Collector在執行時自動管理,整個進程將公用一個Managed Heap。

(2)    Call Stack:這是由.NET CLR在執行時自動管理的儲存單元,每個Thread都有自己專門的Call Stack。每呼叫一次method,就會使得Call Stack上多一個Record Frame;方法執行完畢之後,此Record Frame會被丟棄。這一點與C++類似。

(3)    Evaluation Stack:這是由.NET CLR在執行時自動管理的儲存單元,每個Thread都有自己專門的Evaluation Stack。這個堆棧也叫做堆疊式虛擬機器,既程式執行時的資料都是先放在堆疊中,再進行運算。

 

其三種儲存單元的物理結構模型如下:

 

圖1-1  

 

是託管堆的簡化模型。

                          

 

 

圖1-2  

 

在C#中動態分配記憶體時,.NET是採用如下規則進行記憶體管理的。

(1)    堆被劃分為代,以便只需尋找堆的一小部分就能清除大多數垃圾。

(2)    同代中的對象大體上均為同齡。

(3)    代的編號越高,表示堆的這一片地區所包含的對象越老,這些對象就越有可能是穩定的。最老的對象位於最低的地址內,而新的對象則建立在增加的地址內。

(4)    新對象的分配指標標記了記憶體的已使用(已指派)記憶體地區和未使用(可用)記憶體地區之間的邊界。

(5)    通過刪除死對象並將活對象轉移到堆的低地址末尾,堆周期性地進行壓縮。這就擴充了在建立新對象的圖表底部的未使用地區。

(6)    對象在記憶體中的順序仍然是建立它們的順序,以便於定位。

(7)    在堆中,對象之間永遠不會有任何空隙。

(8)    只有某些可用空間是已提交的。需要時,作業系統會從“保留的”位址範圍中分配更多的記憶體。

(9)    所有可進行記憶體回收的對象都分配在一個連續的地址空間範圍內。

C#中動態記憶體回收機制

在C#中大致有三種記憶體回收機制:完全回收、部分回收、使代與寫入屏障配合工作。

 

 

圖1-3  

1)      完全回收

在完全回收時,程式將停止執行,並且到託管堆中找到所有的根。這些根以各種形式出現,它們可以是堆棧上的指標或者指向堆中的全域變數。從根開始,我們訪問每個對象,並沿途追溯包含在每個被訪問對象內的每個對象指標,指標用於標記這些對象。一旦找出了不可達到的對象,我們就需要回收空間以便隨後使用;在這裡,回收器的目標是要將活的對象向上移動,並清除浪費的空間。在執行過程停止的情況下,回收器可以安全地移動所有這些對象,並修複所有指標,以便所有對象在新的位置上被正確連結。倖存的對象將被提升到下一代的編號(就是說,代的邊界得到更新),並且執行過程可以恢複。

2)      部分回收

假設最近執行了一次完全回收,程式繼續執行,在發生足夠多的分配之後,記憶體管理系統決定是進行回收的時候了。假設我們非常的幸運,自從上一次回收以後,在我們啟動並執行所有時間裡,我們根本沒有對任何較老的對象執行寫操作,而只是對新分配的(第零代 (gen0))對象執行了寫操作。因此,當執行記憶體回收的時候,只需要檢查所有的根,如果有任何根指向舊對象,就忽略這些對象。而對於其他根(指向 gen0 的根)我們進行追溯所有指標。一旦我們發現有內部指標指回較老的對象,我們就忽略它。完成以後,我們就訪問完gen0中的所有活的對象,但沒有訪問過任何老的對象(gen1,gen2對象)。接著就對gen0地區進行回收空間處理。

3)      使代與寫入屏障配合工作

但事實上,部分回收演算法的充分條件是不太可能的,因為總會有一些較老的對象肯定會發生更改。發生這種情況時,.NET使用另外一種輔助的資料結構來配合部分回收演算法。card table的資料結構來記住髒對象的位置;牌桌中的每個位代表堆中的一個記憶體範圍,比如說是 128 個位元組。程式每次將對象寫入某個地址時,寫入屏障代碼必須計算哪個 128 位元組塊被寫入,然後在牌桌中設定相應的位。

如果我們正在執行一次 gen0 記憶體回收,我們可以使用上面討論的演算法(忽略指向較老代的任何指標),但一旦我們完成該操作,那麼我們還必須尋找位於牌桌中被標記為已修改的塊中的每個對象中的每個對象指標。我們必須像對待根一樣對待這些指標。如果我們同樣地考慮這些指標,那麼我們將準確無誤地只回收 gen0 對象。

C#中動態分配記憶體注意事項

 我們瞭解了.NET記憶體回收行程的工作原理後,就可以針對它來制定出編寫高效程式的準則:

(1)     最大程度地減少對象指標的寫入次數,尤其是對較老對象的寫入。

(2)     減少資料結構中的指標密度。第一,將有很多個物件寫入。第二,當回收該資料結構的時間到來時,您將使記憶體回收行程追溯所有這些指標,如果需要,還要隨著對象的到處移動全部更改這些指標。如果您的資料結構的生命週期很長,並且不會有很多更改,那麼,當完全回收發生時(在 gen2 層級),回收器只需要訪問所有這些指標。但如果您建立的此類結構的生命週期短暫(就是說,作為處理事務的一部分),那麼您將支付比正常情況下大出很多的開銷。

(3)     如果可以通過只增加少量的程式複雜性,則應該避免過多的動態記憶體臨時分配。如在比較兩個字串的時候,應該避免使用String.Split。因為 String.Split 將建立一個字串數組,這意味著原來在關鍵字字串中的每個關鍵字都有一個新的字串對象,再加上該數組也有一個對象。現在,您的兩行比較函數就建立了數量非常多的臨時對象。記憶體回收行程突然因為您而負載大增,甚至使用最智能的回收方案也會有很多垃圾需要清理。最好編寫一個根本不需要分配記憶體的比較函數。

(4)     盡量避免使用解構函式。一個帶有解構函式的對象意味著它是需要終結的對象。記憶體回收行程第一次遇到應死而未死但仍需要終結的對象時,它必須在這個時候放棄回收該對象的空間的嘗試。而是將對象添加到需要終結的對象列表中,而且,回收器隨後必須確保對象內的所有指標在終結完成之前仍然繼續有效。這基本上等同於說,從回收器的觀察角度來看,需要終結的每個對象都像是臨時的根對象。回收完成後,終結線程將遍曆需要終結的對象列表,並調用終結器。該操作完成時,對象再一次成為死對象,並且將以正常方式被自然回收。

C#與C++記憶體優缺點對比總結

在對C#以及C++的記憶體管理機制分析完畢以後,我們可以對比出它們間的優缺點如下:

(1)    C#記憶體配置比C++更加有效率:因為不需要像傳統分配器那樣搜尋可用的記憶體塊;所有需要發生的操作只是需要移動在可用的和已指派的地區之間的邊界。

(2)    C#清理記憶體機制可以使得程式員無需為管理記憶體而單獨編寫在大多數時候都是重複的代碼(記憶體緊縮)。

(3)    在相當出色的程式員編寫的程式中沒有任何操縱與記憶體相關的錯誤碼(通常非常難), 利用C++中程式員直接控制記憶體方式肯定比C#利用記憶體回收行程更加有效。因為程式員通常更加清楚何時回收記憶體是最佳時刻。

(4)    由於C#中由記憶體回收行程回收無用已指派的記憶體快,所以不會發生由於程式員疏忽而產生的記憶體流失。當然也可能會丟失一些資源,如忘記關閉與資料庫的串連等。

聯繫我們

該頁面正文內容均來源於網絡整理,並不代表阿里雲官方的觀點,該頁面所提到的產品和服務也與阿里云無關,如果該頁面內容對您造成了困擾,歡迎寫郵件給我們,收到郵件我們將在5個工作日內處理。

如果您發現本社區中有涉嫌抄襲的內容,歡迎發送郵件至: info-contact@alibabacloud.com 進行舉報並提供相關證據,工作人員會在 5 個工作天內聯絡您,一經查實,本站將立刻刪除涉嫌侵權內容。

A Free Trial That Lets You Build Big!

Start building with 50+ products and up to 12 months usage for Elastic Compute Service

  • Sales Support

    1 on 1 presale consultation

  • After-Sales Support

    24/7 Technical Support 6 Free Tickets per Quarter Faster Response

  • Alibaba Cloud offers highly flexible support services tailored to meet your exact needs.