.NET Internals. Boxing and Unboxing

.NET Internals. Boxing and Unboxing

Дмитрий Бахтенков

Поскольку мы уже знакомы с основами памяти и структур данных, используемых .net-приложениями, в этом посте мы углубимся в такие явления, как упаковка и распаковка, а также их влияние на производительность.

Содержание:

  • Что такое упаковка и распаковка?
  • Взглянем на IL
  • Когда происходит упаковка и распаковка?

Необобщённые (Non-generic) коллекции

Конкатенация строк

  • Влияние упаковки и распаковки на производительность
  • Зачем использовать упаковку и распаковку?

Что такое упаковка (boxing) и распаковка (unboxing)?

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

Так почему же нас должно это волновать? Разве среда выполнения .net не сама управляет этими структурами данных и их хранением так, что нам не нужно беспокоиться об этом?

На самом деле нет. Очень важно знать и понимать последствия перемещения данных из стека в кучу и обратно.

Запомни:

  • Когда какая-либо переменная значимого типа присваивается переменной ссылочного типа, данные перемещаются из стека в кучу и это называется упаковкой
  • Когда какая-либо переменная ссылочного типа присваивается переменной значимого типа, данные перемещаются из кучи в стек и это называется распаковкой

Microsoft Docs examples прекрасно иллюстрирует это.

Рассмотрим следующий пример упаковки:

int i = 123;

// Boxing - copying the value of i into object o.
object o = i;  

И состояние памяти при выполнении:

Boxing, source

Чтобы поместить в память значение "123" в объект, оно пакуется в куче и копируется туда.

С другой стороны, когда происходит распаковка:

int i = 123;      // "i" is a value type
object o = i;     // boxing "i" into "o"
int j = (int)o;   // unboxing "o" into "j"

Содержимое стека и кучи меняется следующим образом:

Unboxing, source

Значение "123" вынимается из кучи и помещается обратно в стек.

Обратите внимание, что когда тип значения i помещается в объект o, в стеке хранится ссылка, а фактическая память выделяется в куче (как и для всех ссылочных типов). Как только произойдет распаковка, реальные данные, находящиеся в куче, должны быть скопированы в стек (переменная j). В обоих случаях наша цель состоит в том, чтобы иметь дело с одним и тем же значением (123).

Как вы могли заметить, эти операции приводят к некоторым дополнительным затратам и влияют на производительность, о чем мы поговорим чуть позже.

Взглянем на IL

При анализе аспектов производительности или управления памятью в нашем коде (в данном случае C#) часто стоит посмотреть, как выглядит промежуточный язык (IL).

Мы еще не рассматривали эту концепцию, но, как вы, вероятно, знаете, когда код C# компилируется в DLL или EXE, эти выходные файлы фактически содержат код IL, который позже JIT-компилируется и выполняется виртуальной машиной (подробнее здесь). Среда выполнения .NET должна каким-то образом знать, следует ли вставлять или распаковывать конкретную переменную, так как для этого требуются некоторые специальные действия по выделению памяти.

Давайте создадим какое-нибудь простое консольное приложение .NET со следующим кодом в его основном методе:

  namespace BoxingUnboxingTest
  {
      class Program
      {
          static void Main(string[] args)
          {
              int a = 5;
              object o = a;

              int b = (int)o;
          }
      }
  }

Давайте скомпилируем приложение, чтобы можно было найти BoxingUnboxingTest.exe файл в выходном каталоге. Теперь мы будем использовать приложение ILSpy для просмотра кода IL внутри исполняемого файла.

Как только EXE-файл будет открыт в ILSpy, мы сможем перейти непосредственно к просмотру скомпилированного содержимого метода Main(string[]) : void, выбрав представление “IL с C#”, чтобы упростить его для нас:

Boxing and Unboxing in I

Обратите внимание на оператор box сразу после присвоения типа значения ссылочному типу (object obj = num). Аналогично для оператора unbox.any сразу после присвоения ссылочного типа типу значения (int num2 = (int)obj).

Вот как упаковка и распаковка представлены в IL.

Когда происходит упаковка и распаковка?

Приведенный выше пример кода может показаться наивным, и вы можете подумать: “Эй, но я никогда не делаю таких вещей”. Это верно в большинстве случаев, но значения в нашем коде часто упаковываются/распаковываются, даже если мы об этом не знаем.

Необобщённые коллекции

Например, все еще существует олдскульная коллекция ArrayList:

ArrayList

в которой, как вы можете видеть выше, метод Add принимает параметр object. Это означает, что когда мы хотим добавить целое число в ArrayList:

  ArrayList al = new ArrayList();
  int i = 8;
  al.Add(i);

В этом примере присутствует упаковка:

ArrayList.Add(int) – boxing (IL)

Эта проблема была устранена с помощью generics и обобщённых коллекций.

Конкатенация строк

Другим интересным примером является конкатенация строк со значимыми типами с помощью оператора "+":

  int i = 8;
  string helloText = "Hello";
  string result = helloText + i;

Такая операция включает в себя String.Concat - версию метода Concat, которая принимает два параметра object, поэтому она подразумевает, что сначала упакуется целое число:

String and integer concatenation – boxing in IL

Чтобы избежать этого, достаточно слегка изменить код, используя метод ToString() для целочисленной переменной.

  int i = 8;
  string helloText = "Hello";
  string result = helloText + i.ToString();

И упаковки больше нет, так как используется версия метода String.Concat, которая принимает два параметра с типом string:

String concatenation with ToString usage – no boxing

Влияние упаковки и распаковки на производительность

Как мы уже знаем, упаковка и распаковка подразумевают определенные затраты. В случае простых вещей, таких как объединение строки один раз с некоторым целым числом, выигрыш в производительности при введении ToString в целое число незаметен.

Перспектива меняется, когда вам нужно выполнять такие операции в цикле сотни или тысячи раз. В этом случае выполнение кода с использованием упаковки может быть даже на 150% дольше, чем его эквивалент без неё (вы можете создать простое приложение и измерить время выполнения кода с упаковкой и без него или проверить эту статью).

Упакованные значения также занимают больше места в памяти, чем типы значений, хранящиеся в стеке. Копирование значения в/из стека также является затратой. Согласно MSDN, упаковка, как правило, может занимать даже в 20 раз больше времени, чем простое ссылочное присваивание, в то время как распаковка может быть в 4 раза медленнее, чем присвоение.

Зачем использовать упаковку и распаковку?

Несмотря на все последствия для производительности, которые имеют упаковка и распаковка, эти концепции были введены в .net по некоторым причинам:

  • В .NET существует система унифицированных типов, которая позволяет “представлять” переменные как значимые, так и ссылочные – благодаря упаковке
  • Коллекции можно было использовать для значимых типов до того, как в .NET были введены универсальные типы
  • Это упрощает наш код, например при конкатенации строк, и в большинстве случаев эта ясность дает нам гораздо больше, чем производительность, которую мы получили бы, пытаясь избежать упаковки.

Упаковка и распаковка настолько распространены, что мы не можем их избежать. Мы должны знать, как это работает, чтобы иметь возможность минимизировать их использование, но это всегда следует делать разумно. Не тратьте время на оптимизацию своего кода, постоянно проверяя IL, чтобы у вас получалось наименьшее количество используемых операторов box. Имейте в виду, что ясность вашего кода, его понятная структура и удобство чтения иногда гораздо более ценны, чем небольшой или едва заметный прирост производительности.

Report Page