Xin chào mọi người, mình là Summer, mình hiện đang là C# và Unity developer dưới 20 năm kinh nghiệm Gần đây có thời gian nên mình sẽ viết 1 series về tối ưu bộ nhớ trong C#
Bài đầu tiên sẽ trả lời cho câu hỏi kinh điển cho các buổi phỏng vấn "Em hãy phân biệt Class và Struct trong C#?"
Mọi người thường sẽ trả lời là Class có thế kế thừa còn Struct thì không, Struct thường dùng cho những data đơn giản hơn, Class là Reference Type còn Struct là Value Type... chúng ta sẽ tìm hiểu về cụm mà mình đã tô đậm, đó cũng là thứ mà người phỏng vấn muốn nghe
Stack với Heap
Nếu bạn nào từng học qua môn Kĩ thuật lập trình
hay Hệ điều hành
sẽ biết về 2 khái niệm này, mình sẽ tóm tắt ngắn gọn, bạn biết rồi có thể
Các chương trình khi hoạt động cần sử dụng bộ nhớ trên RAM. Trước tiên ta hãy nhớ về khái niệm cấp phát (allocate) và thu hồi (deallocate) vùng nhớ
- Chương trình không thể tự tiện truy xuất vùng nhớ mà phải "xin phép" hệ điều hành, khi đó chương trình "sở hữu" vùng nhớ này và hệ điều hành đảm bảo sẽ không có 1 chương trình nào khác can thiệp vào, quá trình này gọi là cấp phát vùng nhớ
(memory allocation)
- Sau khi sử dụng xong vùng nhớ đã cấp phát, chương trình cần "trả" nó lại cho hệ điều hành (để chương trình khác có thể sử dụng), quá trình này gọi là thu hồi vùng nhớ
(memory deallocation)
Về cơ bản, vùng nhớ của 1 chương trình có 2 loại chính là Stack
và Heap
(ngoài ra còn có Global segment và Code segment, 2 thằng fixed size nên không bàn nhé ). Stack và Heap khác nhau trong cách quản lý dữ liệu đã cấp phát
-
Stack memory: Quá trình cấp phát và thu hồi trong
Stack
sẽ hoạt động theo kiểu LIFO, nghĩa là dữ liệu nào được cấp phát trước sẽ bị thu hồi sau, tại sao nó lại thế?- Do Stack được sinh ra để phục vụ cho quá trình gọi hàm: ví dụ hàm
A()
gọi hàmB()
, khi đóA()
bắt đầu trướcB()
nhưng lại kết thúc sau, thì các dữ liệu trong hàmA()
sẽ được cấp phát trước, sau đó mới tớiB()
; còn khi thu hồi thì ngược lại, dữ liệu trongB()
bị thu hồi trước, sau đó tớiA()
// Allocate sau A() // Deallocate trước B() public void B() { int b = 5; } // Allocate trước B() // Deallocate sau B() public void A() { int a = 3; }
- Thế nên,
Stack
chỉ chứa dữ liệu phát sinh trong quá trình gọi hàm (akalocal variable
), các dữ liệu trong hàm sẽ bị thu hồi sau khi hàm kết thúc. Tuy nhiên, 1 chương trình phải phát sinh dữ liệu nằm ngoài scope của hàm akaglobal variable
(không bị thu hồi sau khi hàm kết thúc), từ đóHeap
ra đời
- Do Stack được sinh ra để phục vụ cho quá trình gọi hàm: ví dụ hàm
-
Heap memory: Dữ liệu được cấp phát sẽ không có thứ tự xác định, để truy cập dữ liệu trong
Heap
cần phải thông qua địa chỉ (khi cấp phát xong thì hệ điều hành sẽ trả về địa chỉ này)- Mục đích của Heap là tạo ra các
global variable
, nên dữ liệu trênHeap
sẽ không tự động thu hồi sau khi kết thúc hàm (quá trình thu hồi cần phải được chương trình tự thực hiện, tuy nhiên Garbage collector đã làm hộ chúng ta)
public class AllocatedClass { } public void A() { // Biến "a" sẽ không bị tự động thu hồi sau khi A() kết thúc // Quá trình thu hồi sẽ do Garbage collector đảm nhận var c = new AllocatedClass(); }
- Mục đích của Heap là tạo ra các
Hiệu năng (performance) và kích thước (size) là thứ đáng chú ý giữa Stack
và Heap
!
Stack
có kích thước nhỏ hơnHeap
nhiều, do Stack chỉ cần chứa các local variables, trong khi đó Heap cần phải chứa tất cả dữ liệu "sống" trong chương trình- Hiệu năng của
Stack
(cấp phát/thu hồi/truy xuất) cao hơnHeap
rất nhiều, điều này hiển nhiên do các hàm sẽ được gọi rất nhiều lần trong chương trình - Thêm vào đó, Garbage collector là thành phần ảnh hưởng lớn đến hiệu năng của chương trình, càng nhiều dữ liệu trên
Heap
thì nó sẽ cần phải xử lý nhiều hơn.
Vậy bài học rút ra là... Hãy tận dụng Stack, và hạn chế Heap nhiều nhất có thể đối với các giá trị tạm thời
Value type và Reference type
Trong C# có 2 loại dữ liệu là Value type (managed data types) và Reference type (unmanaged data types), mỗi loại sẽ đại diện cho 1 số kiểu dữ liệu
- Reference type: nó chính là class (dạo gần đây còn có thêm record), các kiểu dữ liệu khi khai báo dưới dạng
Reference type
thì sẽ luôn luôn nằm trên Heap - Value type: bao gồm các kiểu dữ liệu các bạn thường thấy
int
,float
,bool
,byte
,char
,enum
,struct
... (string
làclass
nhé), các dữ liệu này có thể nằm trên Heap hay Stack tùy vào cách sử dụng
Các ảnh chụp dưới đây mình sử dụng dotMemory của Rider để thống kê
Reference type
Như đã nói, class
và record
sẽ luôn nằm trên Heap
cho dù bạn có khai báo là nó là local variable
hay global variable
public class MyClass { }
public record MyRecord { }
void HeapOnly()
{ // Class/Record instance sẽ luôn nằm trên Heap dù có là local variable var onlyHeapClass = new MyClass(); var onlyHeapRecord = new MyRecord();
}
Khi chạy hàm HeapOnly();
sẽ cho thấy có 2 dữ liệu được cấp phát trên Heap
Value type
Value type (int
, bool
, struct
, enum
) sẽ nằm trên Stack nếu nó là local variable
của 1 hàm.
public struct StackOnlyStruct { }
void StackOnly() { int stackOnlyInt = 5; AnotherStackOnly(stackOnlyInt, ref stackOnlyInt, false);
}
void AnotherStackOnly(int valueParameterWillBeCopiedIntoTheFunction, ref int useRefModifierForPassingByReferenceToAvoidCopying, //Pass by reference in bool useInModifierForPassingByReferenceAndReadonly) //Pass by reference and readonly
{ var stackOnlyStruct = new StackOnlyStruct();
}
Ta có thể thấy khi gọi hàm StackOnly()
sẽ không cấp phát trên Heap
do đều sử dụng các Value type
(hình dưới cho thấy không có instance nào của StackOnlyStruct
trên Heap
)
Cần phải chú ý các Value type
khi được gửi vào các hàm sẽ bị copy vào hàm (tự động tạo 1 biến mới tương tự trong hàm), để tránh quá trình này cần sử dụng ref (Pass by reference) hay in (Tương tự ref nhưng readonly) (quá trình này gọi là Defensive copy
, bài sau mình sẽ hướng dẫn cách hạn chế nó)
Nếu struct
có chứa class
thì cũng chỉ có class
đó cấp phát trên Heap
, còn struct
vẫn trên Stack
(và giữ địa chỉ của class instance nằm trong Heap
)
public struct StackOnlyStruct
{ public MyClass AReferenceDoesNotMakeTheStructStayOnHeap;
}
void StackOnly()
{ var stackOnlyStruct = new StackOnlyStruct { AReferenceDoesNotMakeTheStructStayOnHeap = new MyClass() };
}
Có thể thấy chỉ có MyClass
là trên Heap
còn StackOnlyStruct
vẫn trên Stack
Value type sẽ trên Heap nếu nó nằm trong 1 Reference type
Khi một Value type
trở thành field của Reference type
thì nó sẽ nằm trên Heap
, điều này tất nhiên, do nó cần phải "sống" cùng với instance này (không thể bị thu hồi khi kết thúc hàm)
public class MyClass { public StackOrHeapStruct AStructFieldWillBeOnHeapToo; public int IntAlso;
}
public struct StackOrHeapStruct {
}
void HeapOrStackStruct() { // StackOrHeapStruct là Value type nên nằm trên Stack var onlyStack = new StackOrHeapStruct(); // Lúc này AStructFieldWillBeOnHeapToo sẽ nằm trên Heap do là 1 field của class // Dù cho nó có là Value type đi chăng nữa var myClass = new MyClass { AStructFieldWillBeOnHeapToo = new StackOrHeapStruct(), IntAlso = 1 };
}
Ta kiểm tra Heap
của chương trình, sẽ thấy không chỉ MyClass
mà còn có StackOrHeapStruct
và int
được cấp phát chung
ref struct sẽ đảm bảo 1 struct luôn nằm trên Stack
Thỉnh thoảng bạn sẽ thấy có kiểu khai báo public ref strut MyRefStruct
, thật ra nó không có gì đặc biệt hết, đây chỉ là khai báo đảm bảo struct
này không thể là field của 1 Reference type
(để không thể nằm trên Heap
)
public ref struct StackOnlyStruct {}
public class MyClass { public StackOnlyStruct AReferenceTypeCanNotEmbedARefStructField;
}
Đoạn code bên trên sẽ không thể compile
, do StackOnlyStruct
là 1 ref struct
Cấp phát mảng trên Stack với Span<T>
Trước khi kết thúc, mình muốn giới thiệu đến mọi người 1 ref struct
là Span<T>, thông thường nó được dùng để trỏ tới 1 mảng trong Heap (tương tự ArraySegment), chúng ta sẽ xem 1 cách dùng hay ho khác nữa
Với kiểu dữ liệu tuần tự (linear sequence), thì bạn sẽ sử dụng []T (array)
hay List<T>
, nhưng cả 2 thằng này đều là class (Reference type
) và sẽ nằm trên Heap
Nếu muốn sử dụng mảng trên Stack, bạn cần kết hợp Span<T>
và stackalloc
(chú ý T
phải là Value type
)
public struct Struct {}
public void StackAllocSpan() { // Cần phải khai báo kích thước của span // T phải là Value type Span<int> notPlayWithHeapIntSpan = stackalloc int[3]; notPlayWithHeapIntSpan[0] = 0; Span<Struct> notPlayWithHeapStrutSpan = stackalloc Struct[3];
}
Khi gọi hàm StackAllocSpan();
sẽ không cần cấp phát trên Heap
. Tuy nhiên cần phải chú ý! Stack
có kích thước nhỏ, khi sử dụng Span
thì các phần tử trong Span
cũng nằm trên Stack
(thay vì chỉ 1 con trỏ tới dữ liệu như mảng thông thường), do đó việc cấp phát Span quá lớn có thể dẫn đến StackOverflow
Sử dụng Span<T>
sẽ đem lại hiệu năng tốt hơn rất nhiều, tuy nhiên phải lưu ý stackalloc Span
chỉ được sử dụng trong scope của hàm, không đươc return
hay out
nó ra , ngoài ra các async
method cũng không cho phép Span
Kết luận
Vậy bạn đã có câu trả lời cho câu hỏi ban đầu rồi đúng không? Class (Reference type)
sẽ luôn trên Heap
, trong khi đó struct (Value type)
sẽ còn tùy vào tình huống sử dụng
Hãy thử làm 1 quiz nho nhỏ để kiểm tra kiến thức nãy giờ nhé!
public class MyStruct_1 { public int MyInt; public MyClass MyClass;
}
public class MyClass { public MyStruct_2 MyFieldStruct_2;
}
public class MyStruct_2 {
}
public void MyFunc() { var myStruct_1 = new MyStruct_1 { MyInt = 1, MyClass = new MyClass() { MyFieldStruct_2 = new MyStruct_2() } };
}
Nếu ta chạy hàm MyFunc();
thì MyInt
, MyStruct_1
, MyStruct_2
, MyClass
cái nào sẽ nằm trên Heap
?
Vậy là kết thúc bài đầu tiên trong series này. Ở post sau, mình sẽ đề cập đến 1 vấn đề khi sử dụng struct là Defensive copy và cách giải quyết