Программирование PC .NET: стек, куча, значение, ссылка, упаковка, распаковка Tue, April 16 2024  

Поделиться

Нашли опечатку?

Пожалуйста, сообщите об этом - просто выделите ошибочное слово или фразу и нажмите Shift Enter.

.NET: стек, куча, значение, ссылка, упаковка, распаковка Печать
Добавил(а) microsin   

В этой статье (перевод [1, 4]) делается попытка на пальцах объяснить назначение шести (возможно самых важных) концепций .NET: стек (stack), куча (heap), тип значения (value type), тип ссылки (reference type), преобразование-упаковка (boxing), преобразование-распаковка (unboxing). Будет показано, что на самом деле происходит в программе, когда Вы декларируете переменную, как работают стек и куча, когда используются ссылки на объекты, и когда объекты работают как значения, что такое упаковка и распаковка, и как эти операции влияют на скорость работы кода.

[Что происходит при декларации переменной]

Когда Вы декларируете переменную в приложении .NET (C#, C++ или Visual Basic), то она занимает некоторый кусочек оперативной памяти (RAM). С этой памятью связаны 3 понятия: имя переменной, тип данных переменной (от типа зависит размер куска памяти, который занимает переменная) и значение переменной. На рисунке ниже показан случай простого выделения памяти из стека под локальную переменную базового типа.

Dot Net declare local variable

Есть 2 типа выделения памяти для переменной - из памяти стека и из памяти кучи.

[Стек и куча]

Многие не имеют ясного представления, в чем разница между стеком и кучей. Оба этих хранилища находятся в оперативной памяти системы (Random Access Memory, RAM), но программно-аппаратный комплекс (процессор + операционная система + библиотеки .NET) работают с этими областями памяти по-разному.

Чтобы понять смысл стека и кучи, давайте посмотрим, что происходит в следующем примере кода.

public void Method1()
{
   // Строка 1:
   int i=4; 
 
   // Строка 2:
   int y=2; 
 
   //Строка 3:
   class1 cls1 = new class1();
}

Строка 1: когда выполняется код в этой строке, компилятор выделяет маленький участок памяти в стеке для хранения переменной i, куда записывается значение 4. Стек отвечает за временное хранение значений локальных переменных функции, что требуется для работы Вашего приложения.

Строка 2: теперь выполнение переходит к следующему шагу. В точном соответствии с названием термина "стек", здесь память для переменной y выделяется сверху первого выделения памяти, в сторону уменьшения адреса. Можно представить размещение переменных в стеке как последовательность нагроможденных друг на друга коробок. Самая первая выделенная коробка находится внизу (переменная i), на неё ставится еще коробка (переменная y), и так далее.

Примечание: если быть абсолютно точным, то выделение памяти для переменных i и y в строках 1 и 2 происходит в момент вызова процедуры Method1.

Выделение памяти в стеке и соответствующее освобождение этой памяти в стеке происходит по принципу LIFO (Last In First Out), "последним пришел первым вышел". Т. е. операции выделения и освобождения происходят только в строго определенном месте памяти, которая называется "вершина стека".

Строка 3: здесь мы создаем объект. Когда эта строка выполняется, то в стеке создается указатель на реальный объект, который сохранен в другом типе памяти, которая называется "кучей". Куча это просто хранилище для нагромождения различных объектов, к которым может произойти обращение в любой момент времени. Куча использует динамическое выделение и освобождение памяти (этим занимается специальный менеджер памяти и сборщик мусора).

Важный момент - в строке 3 задействовано два выделения памяти - одно в стеке для сохранения указателя cls1, и второе в куче, где реально сохраняются данные объекта. Оператор Class1 cls1 не выделяет память для экземпляра класса Class1, он только лишь выделяет в стеке место под переменную указателя cls1 (и неявно присваивает ей значение null). Когда выполнение достигает до ключевого слова new, тогда память выделяется из кучи, инициализируется экземпляр объекта Class1, и его адрес в куче присваивается указателю cls1.

При выходе из процедуры Metod1 происходит следующее: все переменные, размещенные в стеке (i, y, cls1), автоматически уничтожаются. Освобождение происходит в обратном порядке, по принципу LIFO, однако это происходит очень быстро, за одну инструкцию ассемблера, путем простого изменения значения вершины стека.

Еще один важный момент - здесь не освобождается память в куче. В случае программы C++ это ошибка, произойдет утечка памяти. Для программы на Java или C# ошибки тут нет, освобождение памяти кучи произойдет автоматически с помощью сборщика мусора, без вмешательства программиста - в фоновом процессе, когда у операционной системы появятся для этого ресурсы процессора.

Dot Net stack heap objects

Почему разработчики реализовали такую сложную систему выделения памяти, разве нельзя было оставить память только одного типа?

Причина в том, что типы объектов бывают разной сложности, и в зависимости от сложности объекта удобны различные способы манипуляции над данными. Самые простые это типы наподобие int, они занимают область памяти из 4 байт. Такие данные при присваивании можно просто копировать, это происходит быстро, без лишних накладных расходов (одной инструкцией ассемблера). Данные объектных типов могут быть очень сложными, они могут ссылаться на другие объекты или другие примитивные типы данных. Прямое присваивание таких объектов методом копирования может занимать слишком много времени, поэтому здесь применяются ссылки. Другими словами, переменные объекта содержат ссылки на многие другие значения, каждое из которых должно быть сохранено где-то в памяти. Таким образом, объектные типы нуждаются в динамической памяти (это куча), в то время как простые типы требуют использования статической памяти (стек).

В многопоточном приложении у каждого потока имеется свой отдельный стек. Однако все потоки используют одну и ту же кучу. Из-за того, что разные потоки в многопоточном приложении используют общую кучу, требуется некая координация между потоками, чтобы они не манипулировали одновременно одной и той же областью (или областями) памяти в куче.

Это стандартная проблема безопасности потоков, традиционно возникающая с глобальными данными. Она решается с помощью блокировок, очередей, семафоров и других конструкций синхронизации.

Да, объект может быть сохранен в стеке. Если Вы создаете объект внутри функции без оператора "new", то это создаст и сохранит объект в стеке, а не в куче. Предположим, что у нас есть C++ класс Member, для которого мы хотим создать объект. Также у нас есть функция somefunction( ). Вот так может выглядеть код, когда объект создается в стеке:

void somefunction()
{
   /* Создание объекта "m" класса Member поместит его в стек,
      потому что здесь не используется ключевое слово "new",
      и объект создается внутри функции. */
   Member m;
} // Объект "m" уничтожается при завершении функции.

Итак, объект "m" немедленно уничтожается, когда функция доходит до точки своего завершения, или, другими словами, когда управление исполнением операторов в коде выходит из области действия переменной m ("out of scope"). Никаких специальных действий по освобождению памяти не требуется.

Если мы хотим внутри функции создать объект в куче, то код будет выглядеть следующим образом:

void somefunction()
{
   /* Создание объекта "m" как экземпляра класса Member произведет выделение
      для него памяти в куче, потому что здесь мы использовали ключевое слово
      "new", и создали экземпляр объекта внутри функции. */
   Member* m = new Member();
   /* Обратите внимание, что сама переменная m здесь является указателем,
      и память для этого указателя выделяется не в куче, а в стеке.
      Таким образом, в этом примере для создания объекта задействовано
      два вида памяти - стек (для хранения указателя как локальной
      переменной функции) и куча (для хранения реальных данных экземпляра
      объекта).
 
   /* Данные объекта "m" должны быть явно удалены, иначе произойдет утечка
      памяти. Память, выделенную в стеке под указатель m, освобождать не нужно. */
   delete m;
}

В этом примере кода можно увидеть, что объект "m" был создан с помощью ключевого слова "new". Это означает, что данные для объекта "m" будут созданы в куче.

Примечание: это пример кода C++, в нем всегда важно парно вызывать операторы new и delete. Управляемый код C# свободен от такого "недостатка" - объекты, выделенные оператором new, освобождаются автоматически сборщиком мусора, когда в них отпадает надобность.

Как только функция завершила свое выполнение, любые локальные данные функции (выделенные в стеке), немедленно освобождаются. То же самое касается параметров функции, это те же самые локальные переменные, хранящиеся в стеке в момент вызова функции. При выходе из функции все локальные данные и параметры функции немедленно теряют свою актуальность (считаются уничтоженными).

Любые данные, выделенные в куче оператором new (либо функциями динамического выделения памяти), нуждаются в явном освобождении кодом, написанным программистом (это касается только языка программирования C++, объекты языка C# уничтожаются автоматически сборщиком мусора).

Размер стека фиксирован, и после создания стека его размер не может быть изменен (хотя некоторые языки программирования имеют расширения для выполнения такой функции, что очень редкий случай). Таким образом, если в стеке выделено недостаточно места, то произойдет переполнение стека и как результат сбой приложения или системы. Такое часто встречается из за большого количества вызовов вложенных функций, или из-за бесконечных рекурсивных вызовов.

Если текущий размер кучи слишком мал, чтобы удовлетворить требованиям рабочего окружения, то требуется увеличение памяти кучи. Как это реально происходит - зависит от текущих условий. Если кучей управляет операционная система, то она сама меняет размер кучи в зависимости от требований запускаемых приложений. Если проектируется встраиваемая система, то размер кучи определяет разработчик приложения в момент компиляции (с помощью настройки карты памяти и библиотек).

В действительности реализация зависит от языка программирования, компилятора и процессора, на котором выполняется код в реальном времени. Но чаще всего используется одна и та же аналогичная схема - указатель стека при выделении в нем памяти смещается в сторону уменьшения адресов (декрементом регистра указателя стека), а куча реализуется программно средствами операционной системы или используемых библиотек.

Конечно, стек намного быстрее кучи. Причина в том, каким образом выделяется стек - простым назначением указателя стека (единственная команда ассемблера). В отличие от стека куча обслуживается программно, с помощью специальных вызовов API операционной системы или библиотек. Кроме того, при интенсивном использовании кучи некоторые языки, использующие автоматический сбор мусора (например, Java, C# .NET), могут вводить не прогнозируемые дополнительные задержки при выполнении кода в реальном времени.

Данные переменной в стеке автоматически освобождаются, когда переменная теряет область своего действия (go out of scope). Однако на языках наподобие C и C++ данные, сохраненные в куче, нуждаются в явном ручном удалении программистом (с помощью ключевых слов наподобие free, delete или delete[]).

Другие языки, наподобие Java и C# .NET используют специальный автоматически работающий сборщик мусора, который сам заботится об удалении ненужных объектов из кучи (программисту для этого ничего делать не нужно). Это большое удобство, за которое приходится расплачиваться замедлением работы кода в некоторых особо не благоприятных случаях.

Если закончится память, отведенная под стек, то это вызовет непредсказуемое поведение программы и чаще всего закончится её падением. Переполнение и порча содержимого стека часто используется хакерами для взлома программ и повышения привилегий в операционных системах, если в них имеется известная уязвимость, связанная с ошибкой в коде.

Сбой программы также может произойти и с исчерпанием памяти в куче, если нет возможности увеличить её размер. Кроме того, при неправильном обращении к данным кучи есть опасность повредить чужие данные. Также если данные кучи совместно используются разными потоками возникает проблема целостности данных (проблема безопасности потоков, проблема синхронизации).

У кучи существует проблема фрагментации памяти, которая происходит при выделении блоков памяти случайного размера, когда они образуются в не смежных областях памяти. В такой ситуации работа сборщика мусора усложняется, потому что перед ним встает задача обнаружения "дырок" в памяти и по возможности их устранения (это зависит от реализации системы динамической памяти). Когда память чрезмерно фрагментирована, становится невозможным выделить большой кусок непрерывной памяти.

Для новичков в программировании скорее всего хорошей идеей будет использовать стек, потому что это как минимум проще и работает быстрее.

Чаще всего стек используется для небольшого количества выделяемых данных. При обработке большого объема данных, или когда объем обрабатываемых данных меняется (Вы не знаете заранее, какой будет объем данных), может потребоваться использовать кучу.

[Типы-значения и типы-ссылки]

Когда мы разобрались с общей концепцией стека и кучи, настало время понять концепцию типов значений и ссылочных типов. Типы значений это такие типы переменной, которые хранят данные о себе только в одном месте. Ссылочный тип состоит как минимум из указателя на объект, когда он равен null, и также из динамического хранилища, на которое ссылается этот указатель. Таким образом, рабочий ссылочный тип использует 2 области памяти - место для хранения указателя (это стек) и место, где непосредственно содержатся данные объекта (это куча), на который ссылается указатель.

Ниже показана переменная целочисленного типа данных с именем i, значение которого присваивается другой переменной с именем j. Обе этих переменных типа значения, и они обе выделяются в стеке.

Dot Net assign value data types

Когда мы присваиваем значение типа int другому значению типа int, то это создает отдельную копию данных одной переменной в месте размещения другой переменной (данные переменных физически копируются). Другими словами, если Вы поменяете одну из этих двух переменных, то в другой переменной значение не поменяется. Данные такого вида называются "типами значений".

Примечание: к типам значений на C# также относятся структуры.

Когда мы создаем объект, и когда мы присваиваем один объект другому, они оба будут ссылаться на одну и ту же область памяти, как это показано в коде ниже. Когда мы присваиваем переменную obj переменной obj1, то обе этих переменных имеют тип указателя, и обе они ссылаются на одну и ту же область в памяти (в куче).

Другими словами, если мы поменяем данные одного из объектов (например obj), то это немедленно отразится на значениях другого объекта (obj1). Такое поведение обозначается термином "ссылочные типы" (ref).

Dot Net assign reference data types

Каким объектам относятся ссылочные типы, и к каким типы значений? .NET в зависимости от типа данных назначает место для хранения переменной либо в стеке, либо в куче. Объекты строк (String) и почти все другие более-менее сложные библиотечные объекты имеют ссылочный тип (реальные данные хранятся в куче, ссылки на них в стеке), и под любые другие примитивные типы данных используется стек (это типы значений). На рисунке ниже то же самое объясняется подробнее.

Dot Net types tree

[Boxing и unboxing]

Ого, теперь мы знаем много тонкостей о том, как все устроено, но какой прок от этого в реальном программировании? Самое лучшее применение этих знаний - четкое понимание того, как хранение данных в приложении влияет на его производительность, и как ухудшается скорость работы при перемещении данных из стека в кучу и наоборот.

Рассмотрим небольшой пример кода. Когда мы перемещаем тип значения в ссылочный тип, данные перемещаются (копируются) из стека в кучу. Когда мы перемещаем ссылочный тип в тип значения, то данные перемещаются (копируются) из кучи в стек.

Такие перемещения данных негативно сказываются на производительности кода программы.

При перемещении данных из типов значения в ссылочные типы (т. е. стек -> куча) такое действие обозначается термином "Boxing" (преобразование-упаковка), и соответственно обратное действие (из ссылочных типов в тип значения, куча -> стек) обозначается "UnBoxing" (преобразование-распаковка).

Dot Net boxing unboxing

Если Вы скомпилируете этот код и просмотрите результат в ILDASM, то можете увидеть в коде IL словечки box и unbox.

Dot Net boxing unboxing IL code

Чтобы понять, насколько ухудшается скорость работы, мы запустили две показанные ниже функции 10000 раз. В функции слева используется boxing, и в функции справа используется простое присваивание значения.

Boxing-функция выполнилась за 3542 мс, в то время как функция без boxing выполнилась за 2477 мс. Вывод: старайтесь избегать операций boxing и unboxing, используйте их в проекте только тогда, когда абсолютно необходимо, особенно это касается долго работающих, многочисленных повторений циклов.

Dot Net boxing performance impact

Демонстрационный код этого примера можно скачать по ссылке [2].

[Ссылки]

1. Six important .NET concepts: Stack, heap, value types, reference types, boxing, and unboxing site:codeproject.com.
2. source_code.zip - исходный код примеров из статьи.
3. Интересные моменты в C# (boxing unboxing) site:habrahabr.ru.
4. What’s the difference between a stack and a heap? site:programmerinterview.com.

 

Комментарии  

 
+3 #2 Григорий 13.04.2022 20:19
Отличная статья, но по boxing/unboxing хочу добавить замечание. Когда происходит boxing, у нас КОПИРУЕТСЯ значение в новую переменную типа object со сылкой на heap, а сама изначально созданная переменная примитивного типо никак не меняется, как была 1 так и останется 1 после unboxing в новую переменную. А у Вас на картинке, i = 4, что неверно, должно быть 1.
Цитировать
 
 
+1 #1 idxi 04.05.2019 15:05
По ссылке [2] невозможно скачать код примеров.

microsin: чтобы было возможно, нужно зарегистрироват ься на сайте codeproject.com.
Цитировать
 

Добавить комментарий


Защитный код
Обновить

Top of Page