文章目錄
- 前言
- 一、棧與堆
- 1.1 棧(Stack)
- 1.1.1 基本信息
- 1.1.2 特點
- 1.2 堆(Heap)
- 1.2.1 基本信息
- 1.2.2 特點
- 1.3 從代碼中窺見堆棧
- 二、裝箱與拆箱
- 2.1 裝箱
- 2.2 拆箱
- 2.3 如何避免不必要的裝箱與拆箱
- 2.3.1 泛型集合
- 2.3.2 泛型參數
- 總結
前言
編寫一個健壯的程序離不開對資源的高效利用,這里說的無非就是內存,算力。我們基于.NET平臺編寫程序的時候,了解內存機制,對程序的性能與運行穩定性都會有幫助。
本篇文章將介紹堆(Heap)和棧(Stack)這兩種基礎內存區域,了解程序運行的時候堆和棧是如何決定數據的存儲與訪問方式。并且探究裝箱與拆箱是如何偷走我們程序的內存和無端消耗資源的,以及如何去避免。下面就開始深入理解堆與棧。
一、棧與堆
程序運行時,CLR 在操作系統提供的虛擬地址空間基礎上的,將虛擬內存空間劃分和管理為多個區域,其中堆和棧是 C# 中最核心的兩個數據存儲區域(也可以稱之為托管堆,托管棧)。這兩者采用不同的數據結構,存儲的內容也不同,性能差異上也有巨大的差異。下面分別就二者的設計目的分別介紹。
CLR
CLR是.NET框架的核心組件,負責C#這類托管代碼的執行。運行在NET平臺上的程序,其內存管理正是由CLR調度,CLR在操作系統提供的虛擬地址空間基礎上劃分托管內存區域,這正包括了堆和棧。
值類型和引用類型
值類型和引用類型是 C# 類型的兩個主要類別。 值類型的變量包含類型的實例。 它不同于引用類型的變量,后者包含對類型實例的引用。 默認情況下,在分配中,通過將實參傳遞給方法并返回方法結果來復制變量值。
對于值類型,每個變量都有其自己的數據副本,并且一個變量上的作不會影響另一個變量。
對于引用類型,兩種變量可引用同一對象。因此,對一個變量執行的操作會影響另一個變量所引用的對象。
1.1 棧(Stack)
1.1.1 基本信息
棧是一種先進后出(LIFO)的連續內存區域,由CLR自動管理分配,它存儲的是值類型、引用類型的引用和方法上下文。
- 值類型無非就整型數值(sbyte,byte,short,ushort,int,uint,long,ulong,nint,nuint)浮點類型(float,double,decimal)、布爾型(bool),字符型(char),枚舉類型(enum)和結構類型(struct)。
- 引用類型的實際數據存儲在堆中,但其 "指針"存儲在棧上,也就是引用類型的引用。
- 方法的上下文內容包括方法參數、局部變量、和返回地址等。其中局部變量包括值類型和引用類型的引用
當程序調用一個方法的時候,CLR會在棧上創建一個棧幀(Stack Frame)。這個棧幀用于存儲方法的參數;方法內的局部變量,如果是值類型就存它本身,引用類型存儲其引用類型的引用;方法執行完后回到調用處的位置的返回地址。
1.1.2 特點
棧的內存分配是連續的,由CLR自動管理。由于它是一片連續的內存,無需復雜操作就能實現入棧 和出棧,分配和釋放速度極快。當一段方法執行完畢,也就是數據超出了作用域范圍,其棧上的內存會被自動釋放。但是棧的內存空間很小,幾MB的大小,不適合存儲大批量數據。
回想在基于C語言的開發中,經常是手動申請棧空間和手動釋放棧內存,稍有不慎就會造成棧溢出。
1.2 堆(Heap)
1.2.1 基本信息
比起小且連續的棧。堆是一種無序結構的大內存區域。.NET的GC(垃圾回收器)自動管理內存的分配和釋放。堆主要用來存儲引用類型本身。
引用類型大致可以分成兩類,一類是需要用顯式聲明引用類型(class,interface,delegate,record),還有一類是.NET內置的基礎引用類型(dynamic類型,object,string)
1.2.2 特點
前面提到堆是無序結構的大內存區域,在堆上面內存分配需要查找可用空間。對堆內存的釋放也依賴 GC的定期清理,這里面是有一部分的性能開銷存在的。雖然開發者無需手動釋放堆內存,GC 會自動回收不再被引用的對象。但是頻繁分配和釋放可能導致不連續的空閑空間,GC雖然也會自動進行壓縮操作會緩解但也有開銷的存在。這種不連續的空閑空間進一步減慢了分配速度。
1.3 從代碼中窺見堆棧
分別聲明一個結構體(值類型)和一個類(引用類型),結構體是存儲在棧上,類的實例存儲在堆上,變量僅保存引用地址存放在棧上。
值類型之間的復制傳遞的是棧上的值,也就是復制一個新的結構體的時候,是在棧上開辟一個新的空間保存原始結構體的值。
引用類型之間復制雖然本質上傳遞的也是棧上的引用,復制一個新的類的時候,也會在棧上開辟一個空間存儲類型的引用。這個引用地址指向堆,也就是類實例實際存放數據的位置。
這種特性就引申出了一個經典的話題,深拷貝和淺拷貝。
對于值類型來說,原始值類型和被復制的值類型之間數據是相互獨立的,它們保存在棧上的不同空間。對其中一個的修改,不會影響到對方。
對于引用類型,賦值時復制的是引用。原始對象和復制對象之間的在棧上雖然不是保存在一個位置,但是保存的都是同一個引用。也就是說如果通過其中一個棧上引用找到的堆上數據進行修改,也會影響到另一個對象。
Console.WriteLine("================== 結構體(棧存儲)===========================");
StackItem item1 = new StackItem(1, "原始結構體");
StackItem item2 = item1; //復制整個值到棧上的新位置
Console.WriteLine($"修改前 - item1: Id={item1.Id}, Data={item1.Data}");
Console.WriteLine($"修改前 - item2: Id={item2.Id}, Data={item2.Data}");
item2.Id = 2;
item2.Data = "修改后結構體"; //只修改棧上的副本
Console.WriteLine($"修改后 - item1: Id={item1.Id}, Data={item1.Data}"); //原始值不變
Console.WriteLine($"修改后 - item2: Id={item2.Id}, Data={item2.Data}"); //副本被修改Console.WriteLine("================== 類(堆存儲)===========================");
HeapItem obj1 = new HeapItem(1, "原始對象"); // 對象在堆上,obj1是棧上的引用
HeapItem obj2 = obj1; // 復制引用(棧上的地址),指向同一個堆對象Console.WriteLine($"修改前 - obj1: Id={obj1.Id}, Data={obj1.Data}");
Console.WriteLine($"修改前 - obj2: Id={obj2.Id}, Data={obj2.Data}");obj2.Id = 2;
obj2.Data = "修改后對象"; //通過引用修改堆上的同一個對象Console.WriteLine($"修改后 - obj1: Id={obj1.Id}, Data={obj1.Data}"); // 原始對象被修改
Console.WriteLine($"修改后 - obj2: Id={obj2.Id}, Data={obj2.Data}"); // 引用指向的對象被修改public struct StackItem {public int Id;public string Data; public StackItem(int id, string data){Id = id;Data = data;}
}public class HeapItem
{public int Id;public string Data;public HeapItem(int id, string data){Id = id;Data = data;}
}
二、裝箱與拆箱
值類型和引用類型是之間是能相互轉換的,比如object是所有類型的最終基類,自然也是值類型的基類。特定條件下值類型能轉換成object,object也能轉換為值類型。前者值類型轉換成引用類型稱之為裝箱,后者引用類型轉換為值類型稱之為拆箱。
值類型與引用類型之間轉換的兩種操作背后是內存里棧和堆的轉換。這里面涉及內存分配、數據復制和類型檢查等過程,理解裝箱與拆箱能幫我們注意到各種容易引起性能消耗的陷阱。
2.1 裝箱
將值類型轉換為引用類型的過程,稱為裝箱。
值類型是存儲在棧上,而引用類型的實際數據是存儲在堆上。當一個值類型要轉換成引用類型,首先創建一個新的引用類型對象,需要在堆上分配內存,這個內存大小為棧上值類型數據的大小和引用類型自身額外元數據的占用(一個存儲類型標識,和同步塊索引的對象頭);然后將棧上值類型的值復制到堆上的裝箱對象中;最后在棧上開辟一個空間存儲這個新的引用類型對象的引用地址。
值類型到引用類型的裝箱中,堆上的裝箱對象與原棧上的值類型是相互獨立的。它們復制的是值本身,修改原變量不會影響裝箱對象,反之亦然。
值得注意的是裝箱是隱式的,編譯器會自動幫我們轉換。也就是說我們在敲代碼的時候是不需要額外操作就能將一個值類型賦值給引用類型。而前面我們了解到值類型到引用類型,需要一次堆空間分配,然后是棧到堆的復制,最后是棧分配引用類型的引用。這些都是在不經意間增大程序的性能開銷。
int num = 25;
object obj = num; //發生裝箱
2.2 拆箱
將裝箱后的引用類型轉換回原來的值類型的過程,稱為拆箱。
比起裝箱的隱式方便,拆箱的步驟要求較為嚴格。在每一次拆箱前都需要驗證堆上的裝箱對象是否確實是目標值類型的裝箱結果。類型驗證通關后將堆上裝箱對象中的值復制回棧上。
引用類型到值類型的拆箱需要手動觸發,通過顯式類型轉換完成。并且拆箱也是值復制,棧上的新的值類型變量與堆上的裝箱對象之間是相互獨立,修改新的值類型變量不會影響堆上舊的裝箱對象,反之亦然。
int num = 25;
object obj = num; //發生裝箱
int unboxedNum = (int)obj; //執行拆箱
2.3 如何避免不必要的裝箱與拆箱
堆的分配速度遠慢于棧上內存的分配,頻繁的裝箱會消耗額外時間等待堆內存分配。而且頻繁裝箱可能導致GC頻繁觸發,占用系統資源。拆箱的時候類型驗證也會消耗CPU資源。裝箱和拆箱的過程中都會設計到數據的值復制,大批量數據復制會導致程序性能變差。
了解清楚了堆、棧與裝箱拆箱的機制后,下面我們來討論幾個解決性能影響的方案。
2.3.1 泛型集合
泛型的關鍵特性是在編譯時為不同的類型參數生成具體的類型實例,而不是依賴object作為中間類型。比方說ArrayList和List< T>。
給ArrayList添加值,最終值是被裝箱成object對象,讀取值本身也會經歷一次拆箱
ArrayList arrayList = new ArrayList();
arrayList.Add("int"); //裝箱
arrayList.Add("byte");
arrayList.Add("float");
string str = (string)arrayList[0]; //拆箱
而使用泛型集合,泛型通過類型參數化和編譯時才把類型具體化,讓值類型能夠直接被存儲和操作,無需轉換為object類型。避免了裝箱和拆箱。
List<string> list = new List<string>();list.Add("string");list.Add("int");string str = list[0];
2.3.2 泛型參數
C#中方法參數的傳遞方式默認是按值傳遞的。對于值類型,傳遞的是變量的副本,方法內部修改參數變量不會改變外部原始變量。對于引用類型傳遞的是引用的副本,方法內部通過這個引用副本可以修改對象的內容。但是如果一旦修改引用副本本身這個引用值,比如在方法內部將引用副本重新賦值一個新的對象,這樣副本引用值對應的堆上引用就和原始對象對應的堆上引用不同。
當值類型作為參數傳遞給方法參數是object時,默認會按值傳遞。值變量先裝箱為object,再將裝箱對象的引用傳入方法。
public void Print(object obj) {Console.WriteLine(obj);
}int num= 1;
Print(num); //裝箱
和上面的思路一樣,使用泛型通過類型參數化和編譯時才把類型具體化,讓值類型能夠直接被存儲和操作,無需轉換為object類型。避免了裝箱和拆箱。
void Print<T>(T obj) where T : struct
{Console.WriteLine(obj);
}int num = 1;
Print<int>(num);
總結
文章講解了.NET 中托管堆與托管棧的特性與數據存儲差異,深入剖析了裝箱、拆箱的原理及性能損耗,理解內存機制以優化程序性能。并給出泛型集合、泛型方法來等避免不必要裝箱拆箱的方案。