Нещодавно провів досить цікаву презентацію на тему управління памятью в .NET аплікаціях, чим й хочу поділитись з вами в цьому пості. Через те, що тема сама по собі досить обємна, я розібю її на декілька і буду додавати їх сюди по мірі того як зявиться вільний час.
Отож сьогодні ми поговоримо про те, скільки памяті займають обєкти, де і ким вони створюються, та ще деякі цікаві особливості їх існування.
На початок хочу представити вам невеличку табличку з прикладами розмірів обєктів:
Type | x86 size | x64 size |
object | 12 | 24 |
object[] | 16 + length * 4 | 32 + length * 8 |
int[] | 12 + length * 4 | 28 + length * 4 |
byte[] | 12 + length | 24 + length |
string | 14 + length * 2 | 26 + length * 2 |
Отож давайте розглянемо чому object займає 12 байт і що в цих 12 байт входить.
Кожен обєкт в .NET має так званий оверхед, це службові дані, в даному випадку таких даних 8 байт. Перших чотири байти це SyncBlock наступны 4 байти MethodTable, ще 4 байти залишають пустими і в них ми можемо зберрегти дані(наприклад int, так як його розмір якраз 4 байти). Якщо ми захочемо записати ще якісь дані в обєкт, нам автоматично буде виділено ще 4 байти(х86), тобто як тільки ми перевищуємо поточну вмістимість обєкту нам автоматично виділяється ще 4 байти(х86) або більше в залежності від того обєкт якого розміру ми хочемо додати в обєкт.
Схематично пустий обєкт виглядає наступним чином:
Про те що зберігаться в SyncBlock та MethodTable ми поговоримо трохи пішніше.
Сам обєкт розміщується в керованій купі, де за ним пильно слідкує збирач мусору(Garbage collector). Сама купа може містити довільну кількість обєктів, як тільки хтось з них стає не потрібним його видаляють а сама купа дефрагментується. Детальніше про купу видалення обєктів та інші речі буде в наступному пості, поки що, давайте зосередимось на розмірах обєктів, а саме перевіримо розміри пустого обєкті в коді:
1: private static void Main(string[] args)
2: {
3: long beforeObj = GC.GetTotalMemory(true);
4: object obj = new object();
5: long afterObj = GC.GetTotalMemory(true);
6: long diff = afterObj - beforeObj;
7:
8: Console.WriteLine("object: \t" + diff);
9: Console.ReadLine();
10: }
Код сам по собі досить простий, винісши його в окремий метод отримаємо наступне:
1: private static void ObjectSizes()
2: {
3: int size = 1000;
4: double diff;
5:
6: long beforeObj = GC.GetTotalMemory(true);
7: object obj = new object();
8: long afterObj = GC.GetTotalMemory(true);
9: diff = afterObj - beforeObj;
10:
11: Console.WriteLine("obj: \t\t" + diff);
12:
13: //---------------------------------------
14:
15: long beforeObjArray0 = GC.GetTotalMemory(true);
16: object[] array0 = new object[0];
17: long afterObjArray0 = GC.GetTotalMemory(true);
18: diff = afterObjArray0 - beforeObjArray0;
19:
20: Console.WriteLine("array0: \t" + diff);
21:
22: //---------------------------------------
23:
24: long beforeObjArray = GC.GetTotalMemory(true);
25: object[] arrayEmpty = new object[size];
26: long afterObjArray = GC.GetTotalMemory(true);
27: diff = afterObjArray - beforeObjArray;
28:
29: Console.WriteLine("arrayEmpty: \t" + (diff));
30: Console.WriteLine("Per object: \t" + ((diff - 16) / size));
31:
32: //---------------------------------------
33:
34: //PLEASE COMMENT CODE ABOVE AS GC DOESN'T ALLOW TO MEASURE NEXT array100 PROPERLY
35:
36: long before = GC.GetTotalMemory(true);
37: object[] array100 = new object[100]; //416 + 100 * 12
38:
39: for (int i = 0; i < 100; i++)
40: {
41: array100[i] = new object();
42: }
43: long after = GC.GetTotalMemory(true);
44: diff = after - before;
45:
46: Console.WriteLine("array100: \t" + diff);
47: Console.WriteLine("Per object: \t" + (diff - 416) / 100);
48: }
В результаті отримаємо:
obj: 12
array0: 16 arrayEmpty: 4016
Per object: 4 array100: 1392
Per object: 9.76 Останні два рядки не відображають реальні дані, для того щоб побачити обєм даних закоментуйте визначення розміру для попередніх обєктів.Як бачимо дійсно пустий обєкт займає 12 байт. Масив обєктів на 4 байти більше, в ці 4 байти записано Element Type Pointer, 4 байти які для обєкту по замовчуванню вільні, містять розмір масиву. Та на даний час це не суттєво.
Я б хотів зосередитись більше на визначенні розмірів елементарних обєктів тому що, по перше це можуть спитати на інтервю а по друге це дуже просто і робити таких елементарних помилок не варто.
Отож маємо наступний класс:
1: class EmptyWithMethods
2: {
3: public void DO(int x)
4: { }
5:
6: public virtual void DOVirtual(int x)
7: { }
8: }
Запитання досить просте, скільки займатиме цей обєкт в памяті?
З першого погляду можна подумати що дві змінні які приймають методи будуть давати нам додаткове навантаження відповідно 8 байт оверхед плюс 8 байт для двох інтів і в результаті матимемо 16 байт. Та й в принципі стає цікаво стільки займають і чи займають щось взагалі методи? Відповідь проста, пустий клас по замовчуванню займає 12 байт (4 з яких пусті і в них можна записати 1 int або 4 byte), методи відносяться до класу а не до обєтку, і як ми знаємо з книжок клас це як заготовка на основі якої створюється обєкт, отож опис класу і методів до розміру обєкту значення не мають, вони оголошені в одному місці і є спільними для всіх нащадків класу. В результаті скільки б методів не було в нашому класі, і скільки б вони параметрів не приймали наш обєт займає тих самих 12 байт! при чому 4 з них пусті.
1: class OneInt32 { int x; }
Тут все також дуже просто як я вже писав обєкт має окрім 8 байт оверхеду ще 4 байти вільних в ці 4 байти якраз чудово вписується наш int отож наш обэкт буде мати розмір 12 байт з тою лише різницею що останні 4 байти будуть зайняті інтом навідміну від попереднього прикладу.
1: class TwoInt32 { int x, y; }
Цей обєкт займе 16 байт так як окрім заповнених 4 пустих байт нам потрібно ще 4 байти на ще один int y.
1: class Mixed1
2: {
3: int x;
4: byte b1;
5: }
Цей приклад спершу може здатись простим клас (8 байт) + x (4 байти) + b1 (1 байт) = 13 байт, але слід памятати що для х86 память виділяється кусками по 4 байти. Беручи це до уваги давайте порахуємо:
клас (8 байт) + x (4 байти) + b1 (1 байт) =
16 байт тому що для зберігання обєкту виділиться мінімальний обсяг памяті в 4 байти і лише один з цих 4 байтів буде зайнятий а ще три лишаться вільними.
1: class Mixed1
2: {
3: int x;
4: byte b1, b2, b3, b4;
5: }
Цей обєкт також займе лише 16 байт тому місце під змінні b1, b2, b3, b4 буде відповідно в байтах від 13-го до 16-го включно.
Знаючи ці основи можна легко обчислити розміри обєктів, а якщо знати в що перетворюються стандартні проперті, то можна вирахувати розмір і більш важких типів.
String
Уважний читач мав би звернути увагу на те що розмір стрічки зазначено 14 байт +… і реально в памяті він займає 14 байт, діло в тому що стрінги це особливий тип даних в .NET і він працює трохи по іншоиму а ніж звичайні обєкти. перших 8 байт ті самі що й в звичайному обєкті, в наступні 4 записано розмір стрічки, а останні 2 байти це
unicode NULL terminator. Він використовується для того щоб передавати ці стрічки в Win32 API. Отакий то нюанс)
Overhead
Ну і на останок трохи про такзваний оверхед. Перші 4 байти це посилання на синкблок, який використовується для синхронізації в мультипотокових аплікаціях та містить деякі інші службові дані. Наступних 4 байти містять інформацію про тип, це фактично його опис. Детальніше про це планую написати в наступних постах.
Код на гітхабі:
https://github.com/BatsIhor/ObjectSizeDemo/