Có phải việc sử dụng mới trên một cấu trúc phân bổ nó trên heap hoặc stack không?


290

Khi bạn tạo một thể hiện của một lớp với newtoán tử, bộ nhớ sẽ được cấp phát trên heap. Khi bạn tạo một thể hiện của một cấu trúc với newtoán tử, nơi bộ nhớ được phân bổ, trên heap hoặc trên ngăn xếp?

Câu trả lời:


305

Được rồi, hãy xem nếu tôi có thể làm cho điều này rõ ràng hơn.

Thứ nhất, Ash đã đúng: câu hỏi không phải là về nơi các biến loại giá trị được phân bổ. Đó là một câu hỏi khác nhau - và một câu hỏi mà câu trả lời không chỉ là "trên ngăn xếp". Nó phức tạp hơn thế (và thậm chí còn phức tạp hơn bởi C # 2). Tôi có một bài viết về chủ đề này và sẽ mở rộng về nó nếu được yêu cầu, nhưng hãy giải quyết chỉ với newnhà điều hành.

Thứ hai, tất cả những điều này thực sự phụ thuộc vào mức độ bạn đang nói về. Tôi đang xem trình biên dịch làm gì với mã nguồn, theo thuật ngữ IL mà nó tạo ra. Nhiều khả năng trình biên dịch JIT sẽ thực hiện những điều thông minh về mặt tối ưu hóa khá nhiều phân bổ "hợp lý".

Thứ ba, tôi bỏ qua thuốc generic, chủ yếu là vì tôi thực sự không biết câu trả lời, và một phần vì nó sẽ làm phức tạp mọi thứ quá nhiều.

Cuối cùng, tất cả những điều này chỉ là với việc thực hiện hiện tại. Thông số kỹ thuật C # không chỉ định nhiều về điều này - đó thực sự là một chi tiết triển khai. Có những người tin rằng các nhà phát triển mã được quản lý thực sự không nên quan tâm. Tôi không chắc là tôi đã đi xa đến thế, nhưng thật đáng để tưởng tượng về một thế giới trong đó trên thực tế tất cả các biến cục bộ sống trên đống - vẫn phù hợp với thông số kỹ thuật.


Có hai tình huống khác nhau với newtoán tử về các loại giá trị: bạn có thể gọi hàm tạo không tham số (ví dụ new Guid()) hoặc hàm tạo tham số (ví dụ new Guid(someString)). Chúng tạo ra IL khác nhau đáng kể. Để hiểu lý do tại sao, bạn cần so sánh thông số kỹ thuật C # và CLI: theo C #, tất cả các loại giá trị đều có hàm tạo không tham số. Theo thông số CLI, không có loại giá trị nào có các hàm tạo không tham số. (Tìm nạp các hàm tạo của một loại giá trị với sự phản chiếu một thời gian - bạn sẽ không tìm thấy một tham số nào.)

Nó có ý nghĩa cho C # để đối xử với "khởi tạo một giá trị với zero" như một constructor, vì nó giữ ngôn ngữ phù hợp - bạn có thể nghĩ new(...)như luôn luôn kêu gọi một constructor. Thật hợp lý khi CLI nghĩ về nó một cách khác biệt, vì không có mã thực sự để gọi - và chắc chắn không có mã cụ thể theo loại.

Nó cũng tạo ra sự khác biệt những gì bạn sẽ làm với giá trị sau khi bạn khởi tạo nó. IL được sử dụng cho

Guid localVariable = new Guid(someString);

khác với IL được sử dụng cho:

myInstanceOrStaticVariable = new Guid(someString);

Ngoài ra, nếu giá trị được sử dụng làm giá trị trung gian, ví dụ: đối số cho lệnh gọi phương thức, mọi thứ sẽ hơi khác một lần nữa. Để hiển thị tất cả những khác biệt này, đây là một chương trình thử nghiệm ngắn. Nó không cho thấy sự khác biệt giữa các biến tĩnh và biến thể hiện: IL sẽ khác nhau giữa stfldstsfld, nhưng đó là tất cả.

using System;

public class Test
{
    static Guid field;

    static void Main() {}
    static void MethodTakingGuid(Guid guid) {}


    static void ParameterisedCtorAssignToField()
    {
        field = new Guid("");
    }

    static void ParameterisedCtorAssignToLocal()
    {
        Guid local = new Guid("");
        // Force the value to be used
        local.ToString();
    }

    static void ParameterisedCtorCallMethod()
    {
        MethodTakingGuid(new Guid(""));
    }

    static void ParameterlessCtorAssignToField()
    {
        field = new Guid();
    }

    static void ParameterlessCtorAssignToLocal()
    {
        Guid local = new Guid();
        // Force the value to be used
        local.ToString();
    }

    static void ParameterlessCtorCallMethod()
    {
        MethodTakingGuid(new Guid());
    }
}

Đây là IL cho lớp, ngoại trừ các bit không liên quan (chẳng hạn như nops):

.class public auto ansi beforefieldinit Test extends [mscorlib]System.Object    
{
    // Removed Test's constructor, Main, and MethodTakingGuid.

    .method private hidebysig static void ParameterisedCtorAssignToField() cil managed
    {
        .maxstack 8
        L_0001: ldstr ""
        L_0006: newobj instance void [mscorlib]System.Guid::.ctor(string)
        L_000b: stsfld valuetype [mscorlib]System.Guid Test::field
        L_0010: ret     
    }

    .method private hidebysig static void ParameterisedCtorAssignToLocal() cil managed
    {
        .maxstack 2
        .locals init ([0] valuetype [mscorlib]System.Guid guid)    
        L_0001: ldloca.s guid    
        L_0003: ldstr ""    
        L_0008: call instance void [mscorlib]System.Guid::.ctor(string)    
        // Removed ToString() call
        L_001c: ret
    }

    .method private hidebysig static void ParameterisedCtorCallMethod() cil  managed    
    {   
        .maxstack 8
        L_0001: ldstr ""
        L_0006: newobj instance void [mscorlib]System.Guid::.ctor(string)
        L_000b: call void Test::MethodTakingGuid(valuetype [mscorlib]System.Guid)
        L_0011: ret     
    }

    .method private hidebysig static void ParameterlessCtorAssignToField() cil managed
    {
        .maxstack 8
        L_0001: ldsflda valuetype [mscorlib]System.Guid Test::field
        L_0006: initobj [mscorlib]System.Guid
        L_000c: ret 
    }

    .method private hidebysig static void ParameterlessCtorAssignToLocal() cil managed
    {
        .maxstack 1
        .locals init ([0] valuetype [mscorlib]System.Guid guid)
        L_0001: ldloca.s guid
        L_0003: initobj [mscorlib]System.Guid
        // Removed ToString() call
        L_0017: ret 
    }

    .method private hidebysig static void ParameterlessCtorCallMethod() cil managed
    {
        .maxstack 1
        .locals init ([0] valuetype [mscorlib]System.Guid guid)    
        L_0001: ldloca.s guid
        L_0003: initobj [mscorlib]System.Guid
        L_0009: ldloc.0 
        L_000a: call void Test::MethodTakingGuid(valuetype [mscorlib]System.Guid)
        L_0010: ret 
    }

    .field private static valuetype [mscorlib]System.Guid field
}

Như bạn có thể thấy, có rất nhiều hướng dẫn khác nhau được sử dụng để gọi hàm tạo:

  • newobj: Phân bổ giá trị trên ngăn xếp, gọi hàm tạo tham số. Được sử dụng cho các giá trị trung gian, ví dụ để gán cho một trường hoặc sử dụng làm đối số phương thức.
  • call instance: Sử dụng một vị trí lưu trữ đã được phân bổ (cho dù trên ngăn xếp hay không). Điều này được sử dụng trong mã ở trên để gán cho một biến cục bộ. Nếu cùng một biến cục bộ được gán một giá trị nhiều lần bằng nhiều newcuộc gọi, nó chỉ khởi tạo dữ liệu trên đỉnh của giá trị cũ - nó không phân bổ nhiều không gian ngăn xếp mỗi lần.
  • initobj: Sử dụng một vị trí lưu trữ đã được phân bổ và chỉ xóa sạch dữ liệu. Điều này được sử dụng cho tất cả các cuộc gọi hàm tạo không tham số của chúng tôi, bao gồm cả các cuộc gọi gán cho một biến cục bộ. Đối với cuộc gọi phương thức, một biến cục bộ trung gian được giới thiệu một cách hiệu quả và giá trị của nó bị xóa sạch initobj.

Tôi hy vọng điều này cho thấy chủ đề phức tạp như thế nào, đồng thời chiếu một chút ánh sáng vào nó. Trong một số giác quan khái niệm, mọi cuộc gọi để newphân bổ không gian trên ngăn xếp - nhưng như chúng ta đã thấy, đó không phải là điều thực sự xảy ra ngay cả ở cấp độ IL. Tôi muốn làm nổi bật một trường hợp cụ thể. Thực hiện theo phương pháp này:

void HowManyStackAllocations()
{
    Guid guid = new Guid();
    // [...] Use guid
    guid = new Guid(someBytes);
    // [...] Use guid
    guid = new Guid(someString);
    // [...] Use guid
}

"Về mặt logic" đó có 4 phân bổ ngăn xếp - một cho biến và một cho ba trong số ba newcuộc gọi - nhưng thực tế (đối với mã cụ thể đó), ngăn xếp chỉ được phân bổ một lần và sau đó cùng một vị trí lưu trữ được sử dụng lại.

EDIT: Để rõ ràng, điều này chỉ đúng trong một số trường hợp ... cụ thể, giá trị của guidsẽ không hiển thị nếu hàm Guidtạo ném ngoại lệ, đó là lý do tại sao trình biên dịch C # có thể sử dụng lại cùng một vị trí ngăn xếp. Xem bài đăng trên blog của Eric Lippert về xây dựng loại giá trị để biết thêm chi tiết và trường hợp không áp dụng.

Tôi đã học được rất nhiều khi viết câu trả lời này - vui lòng yêu cầu làm rõ nếu bất kỳ câu hỏi nào không rõ ràng!


1
Jon, mã ví dụ HowManyStack ALLocations là tốt. Nhưng bạn có thể thay đổi nó để sử dụng Struct thay vì Guid hoặc thêm một ví dụ Struct mới. Tôi nghĩ rằng sau đó sẽ trực tiếp giải quyết câu hỏi ban đầu của @ kedar.
Tro

9
Guid đã là một cấu trúc. Xem msdn.microsoft.com/en-us/l Library / system.guid.aspx Tôi sẽ không chọn loại tham chiếu cho câu hỏi này :)
Jon Skeet

1
Điều gì xảy ra khi bạn có List<Guid>và thêm 3 cái đó vào nó? Đó sẽ là 3 phân bổ (cùng IL)? Nhưng chúng được giữ ở đâu đó kỳ diệu
Arec Barrwin

1
@Ani: Bạn đang thiếu thực tế là ví dụ của Eric có khối thử / bắt - vì vậy nếu một ngoại lệ được đưa ra trong hàm tạo của struct, bạn cần có thể nhìn thấy giá trị trước hàm tạo. Ví dụ của tôi không có tình huống như vậy - nếu nhà xây dựng thất bại với một ngoại lệ, sẽ không có vấn đề gì nếu giá trị của guidchỉ bị ghi đè một nửa, vì dù sao nó sẽ không hiển thị.
Jon Skeet

2
@Ani: Trên thực tế, Eric gọi điều này ở gần cuối bài đăng của mình: "Bây giờ, còn quan điểm của Wesner thì sao? Có, thực tế nếu đó là một biến cục bộ được cấp phát ngăn xếp (và không phải là một trường trong bao đóng) được khai báo ở cùng mức độ "thử" lồng nhau như cách gọi của nhà xây dựng, sau đó chúng ta không trải qua sự nghiêm khắc này để tạo tạm thời mới, khởi tạo tạm thời và sao chép nó sang cục bộ. Trong trường hợp cụ thể (và phổ biến) đó, chúng ta có thể tối ưu hóa đi việc tạo tạm thời và sao chép vì chương trình C # không thể quan sát được sự khác biệt! "
Jon Skeet

40

Bộ nhớ chứa các trường của cấu trúc có thể được phân bổ trên ngăn xếp hoặc heap tùy theo hoàn cảnh. Nếu biến kiểu cấu trúc là một biến cục bộ hoặc tham số không được bắt bởi một số đại biểu ẩn danh hoặc lớp lặp, thì nó sẽ được phân bổ trên ngăn xếp. Nếu biến là một phần của một số lớp, thì nó sẽ được phân bổ trong lớp trên heap.

Nếu cấu trúc được phân bổ trên heap, thì việc gọi toán tử mới là không thực sự cần thiết để phân bổ bộ nhớ. Mục đích duy nhất là đặt các giá trị trường theo bất cứ thứ gì có trong hàm tạo. Nếu hàm tạo không được gọi, thì tất cả các trường sẽ nhận các giá trị mặc định của chúng (0 hoặc null).

Tương tự cho các cấu trúc được phân bổ trên ngăn xếp, ngoại trừ C # yêu cầu tất cả các biến cục bộ được đặt thành một số giá trị trước khi chúng được sử dụng, do đó bạn phải gọi một hàm tạo tùy chỉnh hoặc hàm tạo mặc định (hàm tạo không có sẵn tham số nào cho cấu trúc).


13

Nói một cách gọn gàng, mới là một cách gọi sai cho các cấu trúc, gọi mới chỉ đơn giản là gọi hàm tạo. Vị trí lưu trữ duy nhất cho cấu trúc là vị trí được xác định.

Nếu nó là một biến thành viên, nó được lưu trữ trực tiếp trong bất cứ thứ gì nó được định nghĩa, nếu nó là một biến cục bộ hoặc tham số thì nó được lưu trữ trên ngăn xếp.

Đối chiếu điều này với các lớp, có một tham chiếu ở bất cứ nơi nào cấu trúc sẽ được lưu trữ toàn bộ, trong khi các điểm tham chiếu ở đâu đó trên heap. (Thành viên trong, cục bộ / tham số trên ngăn xếp)

Nó có thể giúp tìm hiểu một chút về C ++, nơi không có sự phân biệt thực sự giữa lớp / struct. (Có các tên tương tự trong ngôn ngữ, nhưng chúng chỉ đề cập đến khả năng truy cập mặc định của mọi thứ) Khi bạn gọi mới, bạn nhận được một con trỏ đến vị trí heap, trong khi nếu bạn có một tham chiếu không phải con trỏ thì nó được lưu trữ trực tiếp trên ngăn xếp hoặc trong đối tượng khác, các cấu trúc ala trong C #.


5

Như với tất cả các loại giá trị, các cấu trúc luôn đi đến nơi chúng được khai báo .

Xem câu hỏi này ở đây để biết thêm chi tiết về thời điểm sử dụng cấu trúc. Và câu hỏi này ở đây để biết thêm thông tin về cấu trúc.

Chỉnh sửa: Tôi đã trả lời sai rằng họ LUÔN LUÔN đi theo chồng. Điều này là không chính xác .


"Các cấu trúc luôn đi đến nơi chúng được tuyên bố", đây là một chút nhầm lẫn gây nhầm lẫn. Trường cấu trúc trong một lớp luôn được đặt vào "bộ nhớ động khi một thể hiện của kiểu được tạo" - Jeff Richter. Điều này có thể gián tiếp trên heap, nhưng không giống như một loại tham chiếu bình thường.
Tro

Không, tôi nghĩ nó hoàn toàn chính xác - mặc dù nó không giống như một loại tham chiếu. Giá trị của một biến sống trong đó nó được khai báo. Giá trị của biến loại tham chiếu là tham chiếu, thay vì dữ liệu thực tế, đó là tất cả.
Jon Skeet

Tóm lại, bất cứ khi nào bạn tạo (khai báo) một loại giá trị ở bất kỳ đâu trong một phương thức, nó luôn được tạo trên ngăn xếp.
Tro

2
Jon, bạn bỏ lỡ quan điểm của tôi. Lý do câu hỏi này được đặt ra đầu tiên là vì nhiều nhà phát triển không rõ ràng (bao gồm cả tôi cho đến khi tôi đọc CLR Via C #) nơi cấu trúc được phân bổ nếu bạn sử dụng toán tử mới để tạo nó. Nói rằng "các cấu trúc luôn đi đến nơi chúng được tuyên bố" không phải là một câu trả lời rõ ràng.
Tro

1
@Ash: Nếu tôi có thời gian, tôi sẽ cố gắng viết ra câu trả lời khi tôi đi làm. Mặc dù đây là một chủ đề quá lớn để cố gắng đưa lên tàu :)
Jon Skeet

4

Có lẽ tôi đang thiếu một cái gì đó ở đây nhưng tại sao chúng ta quan tâm đến việc phân bổ?

Các loại giá trị được truyền theo giá trị;) và do đó không thể bị đột biến ở một phạm vi khác với nơi chúng được xác định. Để có thể thay đổi giá trị, bạn phải thêm từ khóa [ref].

Các loại tham chiếu được thông qua tham chiếu và có thể được thay đổi.

Tất nhiên có các loại tham chiếu bất biến là chuỗi phổ biến nhất.

Bố cục / khởi tạo mảng: Các loại giá trị -> bộ nhớ 0 [tên, zip] [tên, zip] Loại tham chiếu -> bộ nhớ không -> null [ref] [ref]


3
Các kiểu tham chiếu không được truyền bởi tham chiếu - các tham chiếu được truyền theo giá trị. Điều đó rất khác biệt.
Jon Skeet

2

Một classhoặc structtuyên bố là giống như một kế hoạch chi tiết được sử dụng để tạo ra các trường hợp hoặc đối tượng tại thời gian chạy. Nếu bạn xác định một classhoặc structđược gọi là Person, Person là tên của loại. Nếu bạn khai báo và khởi tạo một biến p thuộc loại Person, p được gọi là một đối tượng hoặc thể hiện của Person. Nhiều thể hiện của cùng một loại Người có thể được tạo và mỗi phiên bản có thể có các giá trị khác nhau trong đó propertiesfields.

A classlà một loại tham chiếu. Khi một đối tượng của classđược tạo, biến mà đối tượng được gán chỉ giữ một tham chiếu đến bộ nhớ đó. Khi tham chiếu đối tượng được gán cho một biến mới, biến mới tham chiếu đến đối tượng ban đầu. Các thay đổi được thực hiện thông qua một biến được phản ánh trong biến khác vì cả hai đều tham chiếu đến cùng một dữ liệu.

A structlà một loại giá trị. Khi a structđược tạo, biến structđược gán sẽ giữ dữ liệu thực của cấu trúc. Khi structđược gán cho một biến mới, nó được sao chép. Do đó, biến mới và biến ban đầu chứa hai bản sao riêng biệt của cùng một dữ liệu. Thay đổi được thực hiện cho một bản sao không ảnh hưởng đến bản sao khác.

Nói chung, classesđược sử dụng để mô hình hóa hành vi phức tạp hơn hoặc dữ liệu được dự định sửa đổi sau khi một classđối tượng được tạo. Structsphù hợp nhất cho các cấu trúc dữ liệu nhỏ chứa dữ liệu chủ yếu không được sửa đổi sau khi structđược tạo.

để biết thêm ...


1

Khá nhiều các cấu trúc được coi là các loại Giá trị, được phân bổ trên ngăn xếp, trong khi các đối tượng được phân bổ trên heap, trong khi tham chiếu đối tượng (con trỏ) được phân bổ trên ngăn xếp.


1

Cấu trúc được phân bổ cho ngăn xếp. Đây là một lời giải thích hữu ích:

Cấu trúc

Ngoài ra, các lớp khi được khởi tạo trong .NET phân bổ bộ nhớ trên heap hoặc không gian bộ nhớ dành riêng của .NET. Trong khi đó các cấu trúc mang lại hiệu quả cao hơn khi khởi tạo do phân bổ trên ngăn xếp. Hơn nữa, cần lưu ý rằng việc truyền tham số trong các cấu trúc được thực hiện theo giá trị.


5
Điều này không bao gồm trường hợp khi một cấu trúc là một phần của một lớp - tại thời điểm nó sống trên đống, với phần còn lại của dữ liệu của đối tượng.
Jon Skeet

1
Có nhưng nó thực sự tập trung vào và trả lời câu hỏi đang được hỏi. Bỏ phiếu lên.
Tro

... Trong khi vẫn không chính xác và sai lệch. Xin lỗi, nhưng không có câu trả lời ngắn cho câu hỏi này - Jeffrey's là câu trả lời hoàn chỉnh duy nhất.
Marc Gravell
Khi sử dụng trang web của chúng tôi, bạn xác nhận rằng bạn đã đọc và hiểu Chính sách cookieChính sách bảo mật của chúng tôi.
Licensed under cc by-sa 3.0 with attribution required.