[제목] C# 클래스 객체는 어떻게 Managed Heap에 표현되는가?
C#에서 클래스 객체 (오브젝트)를 생성하면 이는 CLR Managed Heap에 생성된다. 이 아티클은 이러한 객체가 어떻게 Heap에서 표현되는지, 즉 객체가 Heap의 메모리 공간에 어떤 형태로 저장되는지를 살펴본다.
아래와 같이 간단한 Customer 클래스를 가정해 보자. 이 클래스의 객체 하나를 생성한 후, 이 객체가 Heap 상에 어떻게 존재하는지 살펴 보도록 하자. 예제의 Main()에서는 Customer 객체 c 를 생성한 후, 속성값을 지정하고 디버거 접속을 위해 Console.ReadLine을 호출하고 있다.
using System; namespace ConsoleApp1 { class Program { private static void Main(string[] args) { Customer c = new Customer(); c.Id = 101; c.Name = "Lee"; // 디버거 접속을 위한 대기 Console.ReadLine(); c.Display(); } } public class Customer { private int id; private string name; public int Id { get { return id; } set { id = value; } } public string Name { get { return name; } set { name = value; } } public void Display() { Console.WriteLine(id + name); } } }
프로그램을 Windbg 디버거로 디버깅하기 위해 아래와 같은 명령을 실행한다. (주: Windbg 디버거는 Debugging Tools for Windows에 포함된 대표적인 디버거임)
C> windbg ConsoleApp1.exeConsole.ReadLine()에서 프로그램이 일시 정지하면, Windbg의 Pause 툴바를 눌러 디버깅 모드로 들어간다. Pause는 Debugger 쓰레드에서 멈추므로, 콘솔 메인 쓰레드인 0 번으로 아래와 같이 스위치한다.
0:004> ~0s.NET 디버깅을 위해 SOS 디버거 Extension을 로딩한다. sos.dll은 .NET Framework에 기본으로 내장되어 있다.
0:000> .load sos메인 쓰레드의 Call Stack 및 로컬 변수, 파라미터 확인을 위해 !clrstack -a 를 실행한다. (주: 디버거 출력중 불필요한 부분은 생략)
0:000> !clrstack -aOS Thread Id: 0x3fc (0) Child SP IP Call Site 004ef218 770776e2 [InlinedCallFrame: 004ef218] NativeImages_v4.0.30319_32mscorlib51e2934144ba15628ba5a31be2dae7dcmscorlib.ni.dll DomainNeutralILStubClass.IL_STUB_PInvoke 004ef218 6ee8ffd0 [InlinedCallFrame: 004ef218] Microsoft.Win32.Win32Native.ReadFile 004ef27c 6ee8ffd0 System.IO.__ConsoleStream.ReadFileNative 004ef2b0 6ee8fec7 System.IO.__ConsoleStream.Read(Byte[], Int32, Int32) 004ef2d0 6e6e7298 System.IO.StreamReader.ReadBuffer() 004ef2e4 6e6e7050 System.IO.StreamReader.ReadLine() 004ef300 6ee97269 System.IO.TextReader+SyncTextReader.ReadLine() 004ef310 6ed752ea System.Console.ReadLine() 003ff2b8 002600b9 ConsoleApp1.Program.Main(System.String[]) PARAMETERS: args (0x003ff2c4) = 0x0227229c LOCALS: 0x003ff2c0 = 0x022722c0
Call Stack중 Main()함수에 있는 로컬 변수는 Customer 객체일 것이므로 위의 LOCALS에서 변수 0x004ef320 의 값인 Reference 포인터값 0x023722c0을 조사해 본다. SOS 의 !do 명령은 !dumpobj의 축약형으로 Reference Type 객체의 내용을 표시해 준다.
0:000> !do 0x022722c0Name: ConsoleApp1.Customer MethodTable: 00143864 EEClass: 001412ec Size: 16(0x10) bytes File: C:\test\ConsoleApp1\bin\Debug\ConsoleApp1.exe Fields: MT Field Offset Type VT Attr Value Name 7128c770 4000001 8 System.Int32 1 instance 101 id 7128afb0 4000002 4 System.String 0 instance 022722ac name
여기서 !do 의 출력을 살펴보면, 클래스명이 (네임스페이스를 포함하여) ConsoleApp1.Customer임을 알 수 있고, 객체 사이즈는 16 바이트임을 알 수 있다. Managed Heap 상의 레퍼런스 타입 객체는 x86 머신에서 다음과 같은 기본 구조를 가지고 있 다.
Object Header (4 byte) + Type Handle (4 byte) + 객체 필드들
클래스 객체를 생성한다는 것은 새로운 객체필드들을 힙 메모리 상에 만들다는 것이며, CLR Managed Heap 에서는 각 객체마다 Object Header와 Type Handle을 이들 객체 필드 앞부분에 추가로 갖게된다. 객체는 Managed Heap상에 클래스 필드들의 값을 갖는 일련의 데이타의 메모리 영역이며, 클래스의 메서드 코드는 클래스 인스턴스 즉 객체와 별도로 존재한다.
위의 C# 예제에서 Customer 클래스는 2개의 필드를 갖고 있는데, 정수형 id 와 문자열 name 필드이다. id 는 int 타입으로 4바이트이고, name은 문자열로 레퍼런스 객체이므로 다시 힙상에 System.String 객체를 만들고 객체 레퍼런스 포인터만을 저장하므로 4바이트를 차지한다. 따라서 객체 필드는 모두 8바이트를 차지하고, 여기에 Header와 Type Handle 크기를 더해 총 16바이트의 객체 크기를 갖는 것이다. 만약 Customer 클래스로부터 10개의 객체를 생성한다면, 16 x 10 = 160 바이트를 Managed Heap에 할당하게 된다. 또한, 만약 name 필드 문자열을 포함한 전체 객체 사이즈를 구하기 위해서는 !objsize 명령을 사용할 수 있다.
클래스 객체 포인터는 항상 Type Handle을 가리키고 있다. 따라서 Object Header를 보기 위해서는 아래와 같이 객체 포인터에서 4바이트를 빼서 출력해야 한다. Object Header는 상황에 따라 객체 해쉬값, Lock, Thunking, AppDomain 등 여러가지 정보들을 가질 수 있는데, 많은 객체가 이러한 정보를 필요로 하는 것이 아니므로 필요하지 않은 경우 경우 기본적으로 0을 할당한다. 객체가 Object Header를 사용하는 한 예를 살펴 보면, 만약 C# 코드에 Object.GetHashCode()를 호출했을 경우는 Header는 해당 객체의 해쉬코드 값을 갖는다. 이런 상황에서 만약 다시 lock이 사용되면 Sync Block이라는 CLR의 시스템 테이블에 영역을 할당하고 이곳에 해쉬값 등 기존 데이타를 옮기고 locking 정보를 추가로 저장한 후 Object Header에 Sync Block Index 번호 (예: 1, 2, ..)을 저장한다. 즉, 해당 객체 헤더는 4바이트이므로 객체의 여러 다른 정보를 저장할 필요가 있을 경우 CLR은 Sync Block 을 non GC 메모리에 자동 할당한 후 여러 정보를 이곳에 이동 복제하는 것이다 (이를 Header Inflation이라 부른다).
0:000> dd 0x022722c0-4022722bc 00000000 00143864 022722ac 00000065
(주: dd 는 display dword의 약자로 특정 메모리 주소로부터 dword 형식으로 메모리 내용을 출력)
한가지 주목할 점은 C# 클래스에서는 id가 name보다 먼저 위치하는데, !do의 필드 출력은 Offset을 보면 name은 offset 4, id는 8로 되어 있는 점이다. 이는 .NET 에서 메모리 효율성을 높이기 위해 필드의 위치를 내부적으로 변경하기 때문이다. 따라서 많은 경우 클래스 필드의 순서가 메모리 상에 바뀌어서 표현된다 (주: 이러한 디폴트 자동 레이아웃은 개발자가 LayoutKind.Explicit을 사용하여 특정 레이아웃으로 지정할 수 있다). 위의 메모리 내용은 Object Header는 0, TypeHandle은 00143864 , name 필드는 022722ac, id는 0x65 (101) 임을 나타낸다.
그러면 CLR은 어떻게 각 메모리 Offset에 위치한 필드 데이타를 해석하는 것일까? 이는 CLR이 Type (Reference 타입 혹은 Value 타입)에 대한 정보를 모두 갖고 있기 때문에 가능하다. 한 프로세스 내에서 사용되는 모든 Type들 각각에 대해 CLR은 Type 객체를 하나씩 만들어 가지고 있다. CLR은 프로세스 내에서 사용되는 각각의 Type에 대해 해당 프로세스 내에 반드시 1개의 Type객체를 생성하게 되며, 이 Type 객체는 고유한 Type Handle을 갖게 된다.
C# 프로그램에서 Type의 TypeHandle 속성을 호출하면 이 Type Handle값을 얻을 수 있고, Type Handle로부터 Type을 구하기 위해서는 Type.GetTypeFromHandle() static 메서드를 사용한다.
Customer c = new Customer(); var htype = c.GetType().TypeHandle; Type t= Type.GetTypeFromHandle(htype);
위의 !do 의 출력을 살펴 보면, MethodTable: 000d3864 을 볼 수 있다. 이 MethodTable은 Type Handle을 의미하는 것으로 해당 Type 객체 내용 일부를 살펴보기 위해 !dumpmt 명령을 실행할 수 있다. (주: !dumpmt 명령은 해당 Type에 대한 자세한 정보를 출력해 준다. 예를 들어, virtual method수, interface 수, 메서드 코드가 저장된 위치, JIT 상태 등을 출력)
0:000> !dumpmt -md 00143864EEClass: 001412ec Module: 00142e94 Name: ConsoleApp1.Customer mdToken: 02000003 File: C:\test\ConsoleApp1\bin\Debug\ConsoleApp1.exe BaseSize: 0x10 ComponentSize: 0x0 Slots in VTable: 10 Number of IFaces in IFaceMap: 0 -------------------------------------- MethodDesc Table Entry MethodDe JIT Name 71194960 70e96728 PreJIT System.Object.ToString() 71188790 70e96730 PreJIT System.Object.Equals(System.Object) 71188360 70e96750 PreJIT System.Object.GetHashCode() 711816f0 70e96764 PreJIT System.Object.Finalize() 002600e0 00143850 JIT ConsoleApp1.Customer..ctor() 0014c02d 00143814 NONE ConsoleApp1.Customer.get_Id() 00260118 00143820 JIT ConsoleApp1.Customer.set_Id(Int32) 0014c035 0014382c NONE ConsoleApp1.Customer.get_Name() 00260158 00143838 JIT ConsoleApp1.Customer.set_Name(System.String) 0014c03d 00143844 NONE ConsoleApp1.Customer.Display()
그러면 이제 Managed Heap 상에 클래스 객체가 몇 개 존재하는지 검색하는 방법을 살펴보자. 우선 Managed Heap의 주소를 파악하기 위해 !eeheap 명령을 사용한다. 아래 명령은 GC Heap 영역의 주소를 알아보기 위한 명령으로, 85,000 바이트 이상의 객체는 Large object heap에 그 미만은 ephemeral segment 에 할당되므로 예제의 Customer 객체는 02271000 - 02273ff4 영역에 존재할 것이다.
0:000> !eeheap -gcNumber of GC Heaps: 1 generation 0 starts at 0x02271018 generation 1 starts at 0x0227100c generation 2 starts at 0x02271000 ephemeral segment allocation context: none segment begin allocated size 02270000 02271000 02273ff4 0x2ff4(12276) Large object heap starts at 0x03271000 segment begin allocated size 03270000 03271000 03275320 0x4320(17184) Total Size: Size: 0x7314 (29460) bytes. ------------------------------ GC Heap Size: Size: 0x7314 (29460) bytes.
이 GC 힙메모리에서 Customer 객체를 찾기 위해서 한 방법으로 (Brute Force 방식) 해당 힙의 모든 메모리 영역에서 Customer 타입의 Type Handle을 찾아볼 수 있다. 아래는 Windbg의 s (search)명령으로 dword (-d)를 검색하는 것으로 02271000으로부터 길이 (L) 0x2ff4 만큼의 메모리 영역에서 Customer Type (00143864)을 찾는 것이다. 결과는 1개의 객체가 메모리 주소는 022722c0 에서 발견되었다.
0:000> s -d 02271000 L?0x2ff4 00143864022722c0 00143864 022722ac 00000065 00000000 d8..."'.e.......
SOS는 힙 메모리상의 특정 타입의 객체를 찾는 보다 편리한 명령을 제공하는데, 아래와 같이 !dumpheap -type 을 사용할 수 있다. 이 결과에서 알 수 있듯이, 현재 GC 힙 상에는 1개의 Customer 객체가 존재하며, 객체 위치는 022722c0 이다.
0:000> !dumpheap -type ConsoleApp1.CustomerAddress MT Size 022722c0 000d3864 16 Statistics: MT Count TotalSize Class Name 000d3864 1 16 ConsoleApp1.Customer Total 1 objects
본 아티클에서는 Managed Heap 상에 객체(Object)가 어떻게 표현되는지 디버거를 통해 메모리를 살펴 보면서 내부 구조를 살펴 보았다.
*참고: Native C++ 객체가 메모리에 표현되는 방식에 대해 영문이긴 하지만 예전에 쓴 블로그 참조: http://bit.ly/16SCCVz
본 웹사이트는 광고를 포함하고 있습니다. 광고 클릭에서 발생하는 수익금은 모두 웹사이트 서버의 유지 및 관리, 그리고 기술 콘텐츠 향상을 위해 쓰여집니다.