Tiêu đề: C# Heap(ing) Vs Stack(ing) in .NET: Part I
Tác giả: Matthew Cochran
Nguồn: C# Heap(ing) Vs Stack(ing) in .NET: Part I

Mặc dù với .Net Framework, chúng ta không phải lo lắng về việc quản lý vùng nhớ và thu gom rác (Garbage collection - GC), tuy nhiên chúng ta vẫn nên tự quản lý chúng để tối ưu hiệu suất của ứng dụng. Ngoài ra, có được sự hiểu biết căn bản về cách thức quản lý bộ nhớ sẽ giúp ích cho chúng ta trong việc phân tích cách thức làm việc của các biến (variables) trong mỗi chương trình.

Trong .Net Framework, có hai nơi (places) trong bộ nhớ dùng để chứa các phần tử của chương trình khi thực thi. Nếu bạn chưa thật sự am hiểu về chúng, hãy để tôi giới thiệu cho bạn về Stack và Heap. Cả Stack và Heap đều giúp ích trong việc thực thi code của chúng ta, nó tồn tại như là một phần của bộ nhớ và chứa các thông tin liên quan đến việc thực thi của một chương trình.

Sự khác biệt giữa Stack và Heap là gì?


Stack là thứ dùng để chịu trách nhiệm trong việc bám sát, theo dõi những gì đang thực thi trong code (hoặc là những gì mà chương trình gọi ra - "called").
Heap là thứ dùng để theo dõi những đối tượng - objects (data... tất cả những gì liên quan đến object).

Nghĩ về Stack như là những chiếc hộp xếp chồng lên nhau. Bạn giữ cho chúng làm việc luôn theo cách này, bằng cách là xếp chồng lên trên thêm 1 chiếc hộp (box) nữa mỗi khi mà bạn gọi ra 1 phương thức (method). Bạn chỉ có thể sử dụng những gì thuộc về chiếc hộp nằm phía trên cùng của Stack. Khi bạn sử dụng xong chiếc hộp ở trên cùng (tức là thực thi xong 1 phương thức), nó sẽ được vứt đi (throw away), và bạn sẽ tiếp tục sử dụng chiếc hộp kế tiếp.

Heap cũng tương tự như Stack nhưng Heap dùng để lưu trữ thông tin (hold information), do đó, bạn có thể truy cập đến Heap để lấy thông tin bất cứ lúc nào.
Với Heap, không có những ràng buộc - hạn chế (no constrains) đối với những gì được truy cập như trong Stack.

Heap như là một chồng quần áo đã được giặt sạch và xếp ngay ngắn, đặt trên giường của bạn. Bất kỳ khi nào bạn cũng có thể lấy, và không quan trọng đến thứ tự từ trên xuống.

Stack giống như những chiếc hộp đựng giày để trong tủ quần áo và xếp chồng lên nhau. Mỗi khi cần, bạn phải lấy chiếc hộp ở phía trên cùng ra trước, mới có thể lấy được những chiếc hộp bên dưới nó.



Hình ảnh phía trên, tuy không thực sự diễn tả được những gì xảy ra bên trong nó, nhưng chúng thực sự giúp chúng ta phân biệt được giữa Stack và Heap.

Stack là tự duy trì (self-maintaining), nghĩa là nó sẽ tự quản lý bộ nhớ của chính nó. Khi chiếc hộp ở trên cùng không còn được sử dụng, nó sẽ bị vứt đi (it's throw out).
Trái ngược với Stack, vùng nhớ của Heap được quản lý bởi bộ thu gom rác (Garbage collection - GC), GC sẽ biết làm thế nào để giữ cho Heap sạch sẽ (keep the Heap clean).

Điều gì diễn ra trên Stack và Heap?

Chúng ta có 4 phân loại chính được đặt trên Stack và Heap khi thực thi code:
-> Kiểu giá trị (Value types)
-> Kiểu tham chiếu (Reference types)
-> Con trỏ (Pointers)
-> Instructions (cái này cho phép tui để nguyên nha, dịch thấy không sát nghĩa lắm ^^!)

Các kiểu giá trị - Value types:
Trong C#, những thứ được khai báo cùng các các kiểu khai báo dưới đây được gọi là kiểu giá trị (bởi vì chúng được dẫn xuất từ System.ValueType):

  • bool byte char decimal double enum float int long sbyte short struct uint ulong ushort


Các kiểu tham chiếu - Reference types:
Những thứ được khai báo cùng với các kiểu khai báo này được gọi là kiểu tham chiếu (được dẫn xuất từ System.Object, ngoại trừ và cũng tất nhiên là object cũng được dẫn xuất từ System.Object).


  • class interface delegate object string


Con trỏ - Pointer:
Loại thứ ba được đưa vào trong cơ chế quản lý bộ nhớ là một tham chiếu tới một kiểu (a Reference to a Type). Thông thường, một tham chiếu được xem như là một con trỏ (a Pointer). Chúng ta không sử dụng tường minh (explicitly use) con trỏ. Chúng được quản lý bởi Common Language Runtime (CLR).

Một Pointer (hoặc là một Reference) thì rất khác biệt so với một kiểu tham chiếu (Reference Type) (Theo mình hiểu, thì ý tác giả muốn nói là một kiểu dữ liệu tham chiếu thì khác biệt so với một hành động tham chiếu tới một thứ gì đó). Điều đó có nghĩa là chúng ta sẽ truy cập đến chúng thông qua Pointer.

Một Pointer là một không gian trong bộ nhớ mà nó trỏ tới một không gian bộ nhớ khác. Pointer chiếm khoảng không gian tương tự như tất cả những gì khác mà bạn đặt chúng trong Stack và Heap, và giá trị của nó là cả địa chỉ bộ nhớ, có khi là null (its value is either a memory address or null).



Instructions.


Bạn sẽ thấy cách thức mà Instructions làm việc trong chương này.

How is it decided what goes where? (Huh?) (Nó quyết định cái gì sẽ đi về đâu như thế nào [IMG]images/smilies/laughing.gif[/IMG])

Ok, đây là thứ cuối cùng, và chúng ta sẽ bàn về những điều thú vị.

Đây là 2 nguyên tắc vàng của chúng ta:

  • Kiểu dữ liệu tham chiếu (a reference type) luôn luôn được chứa trên Heap. Quá dễ, đúng không?Kiểu giá trị và con trỏ (value type and pointer) luôn luôn nằm tại nơi mà nó được khai báo. Cái này phức tạp hơn một chút và bạn phải biết về cách thức hoạt động của Stack để tìm ra nơi (where) mà chúng đã được khai báo (tương tự như kiểu bạn nói là: tôi sinh nó ra ở đây trên đất nước Việt Nam, vậy thì nó sẽ tồn tại ở đây. Vấn đề là bạn phải biết được bà ta sinh nó ra ở đâu, thì biết biết được ở đây là ở đâu háhá).


Stack, như chúng ta đã đề cập trước đó, nó chịu trách nhiệm theo dõi mỗi luồng (thread) thực thi trong code của bạn (hoặc là những gì được gọi - "called").

Khi code của bạn gọi thực thi 1 phương thức (method), thì luồng (the thread) bắt đầu thực thi Instructions, thứ mà được biên dịch bởi JIT (Just-In-Time) và lưu vào method table (live on the method table - cái này không dịch sát nghĩa được ^^!). Ngoài ra, nó còn đem các tham số của phương thức (method's parameters) đưa vào trong Stack (Ở trên mình vừa nói là các biến cục bộ - local variable thì được lưu trữ trên Stack khi thực thi ấy @@). Sau đó, chúng ta sẽ duyệt qua code một lượt, rồi di chuyển đến các biến (variables) nằm trong phương thức để thực thi.

Đây sẽ là một ví dụ để dễ hiểu hơn:
Ta có phương thức sau:


Mã:
           public int AddFive(int pValue)          {                int result;                result = pValue + 5;                return result;          }
Đây là những điều diễn ra ở đỉnh của Stack (chiếc hộp đầu tiên trên cùng). Ghi nhớ rằng bạn đang nhìn thấy những thứ nằm ở trên cùng của hàng loạt những thứ khác đang cùng nằm trên Stack.

Khi chúng ta thực thi phương thức này 1 lần, các tham số (parameters) được đặt trên Stack.

Ghi chú: Phương thức này không thực sự sống (live) trên Stack, nó chỉ ở vị trí đó để tham chiếu (reference) thôi.




Tiếp theo, thread thực thi phương thức thông qua Instructions tới phương thức AddFive()



Khi thực thi phương thức, chúng ta cần 1 ít bộ nhớ cho biến result, và nó được cấp phát trên Stack.



Phương thức được thực thi xong và biến result được trả về.



Và tất cả bộ nhớ được cấp phát trên Stack được dọn sạch bằng cách di chuyển con trỏ (pointer) tới địa chỉ ô nhớ trống, nơi mà phương thức AddFive() bắt đầu và chúng ta quay ngược trở lại phương thức trước đó trên Stack (and we go down to the previous method on the stack - dịch không biết có đúng không mà thấy đọc không hiểu gì hết @@).



Trong ví dụ này, biến result được đặt trên Stack. Thực tế là, mỗi một kiểu dữ liệu giá trị (value type) được khai báo bên trong phương thức, nó sẽ được đặt trên Stack.

Bây giờ, kiểu giá trị đôi khi cũng được đặt trên Heap. Hãy nhớ lại các quy tắc, kiểu giá trị luôn luôn nằm ở nơi mà nó được khai báo. Tốt, nếu một kiểu giá trị được khai báo bên ngoài một phương thức nhưng bên trong một kiểu tham chiếu (reference type) thì nó sẽ được đặt trên Heap.

Đây là một ví dụ khác:
Nếu chúng ta có lớp MyInt (nó là kiểu tham chiếu bởi vì nó là một class):


Mã:
          public class MyInt          {                      public int MyValue;          }
và thực thi phương thức sau:


Mã:
          public MyInt AddFive(int pValue)          {                MyInt result = new MyInt();                result.MyValue = pValue + 5;                return result;          }
Giống như lần trước, thread thực thi phương thức và các tham số của nó được đặt trên Stack.



Giờ thì nó trở nên thú vị rồi.

Bởi vì MyInt là một kiểu tham chiếu, nó được đặt trên Heap và được tham chiếu bởi con trỏ trên Stack



Sau khi phương thức AddFive() thực thi xong (giống như trong ví dụ đầu tiên), và chúng ta sẽ dọn sạch....



MyInt đứng mồ côi (háhá [IMG]images/smilies/laughing.gif[/IMG]) một mình trên Heap cùng với không có thứ nào trỏ tới nó ở bên trái.



Đây là nơi mà GC sẽ tiến hành làm việc (comes into play). Khi mà chương trình của bạn sử dụng bộ nhớ đạt đến ngưỡng nhất định, và bạn cần nhiều không gian trên Heap hơn. GC sẽ ngừng tất cả các threads đang chạy (running threads), tìm các đối tượng (objects) trong chương trình chính mà nó không còn được truy cập nữa để hủy nó.

GC sẽ điều chỉnh lại tất cả các đối tượng còn lại trong Heap để tối ưu không gian (make space), và điều chỉnh tất cả con trỏ trỏ tới các đối tượng này trong cả Stack và Heap.

Bạn có thể hình dung rằng, điều này sẽ làm giảm hiệu suất, vì vậy, hãy chú ý tới cách làm việc của nó để tối ưu hóa mã của bạn đạt hiệu suất cao.

Ok. Thật tuyệt, nhưng nó thực sự đã tác động đến tôi như thế nào?

Good question.

Khi chúng ta sử dụng kiểu dữ liệu tham chiếu, chúng ta sẽ làm việc với con trỏ để trỏ đến chúng. Khi chúng ta sử dụng kiểu giá trị, chúng ta sẽ sử dụng chính bản thân chúng. (When we are using Reference Types, we're dealing with Pointers to the type, not the thing itself. When we're using Value Types, we're using the thing itself.)

Một lần nữa, điều này được mô tả tốt nhất thông qua ví dụ.

Nếu chúng ta thực thi phương thức sau:


Mã:
          public int ReturnValue()          {                int x = new int();                x = 3;                int y = new int();                y = x;                      y = 4;                         return x;          }
Chúng ta sẽ được giá trị (value) 3. Quá đơn giản, đúng không?

Tuy nhiên, nếu chúng ta sử dụng lớp MyInt ở ví dụ trên:


Mã:
     public class MyInt          {                public int MyValue;          }
Và thực thi phương thức sau:


Mã:
          public int ReturnValue2()          {                MyInt x = new MyInt();                x.MyValue = 3;                MyInt y = new MyInt();                y = x;                                 y.MyValue = 4;                              return x.MyValue;          }
Chúng ta sẽ được bao nhiêu? 4

Tại sao? x.MyValue nhận giá trị 4 như thế nào? Hãy cùng xem lại:

Trong ví dụ đầu tiên, mọi thứ có vẻ diễn ra theo đúng như dự tính:


Mã:
          public int ReturnValue()          {                int x = 3;                int y = x;                    y = 4;                return x;          }


Trong ví dụ kế, chúng ta không nhận giá trị 3, bởi vì cả biến x và y đều trỏ đến cùng 1 đối tượng trên Heap.


Mã:
          public int ReturnValue2()          {                MyInt x;                x.MyValue = 3;                MyInt y;                y = x;                                y.MyValue = 4;                return x.MyValue;          }


Hy vọng rằng những điều này sẽ giúp cho bạn có một sự hiểu biết hơn về sự khác biệt giữa biến kiểu dữ liệu giá trị và biến kiểu dữ liệu tham chiếu trong C#, và căn bản về con trỏ khi nó được sử dụng. Trong phần tiếp theo của loạt bài này, chúng ta sẽ bàn thêm về cách quản lý bộ nhớ và đặc biệt là nói về tham số của phương thức.

Còn nữa...

Happy coding.