박싱은 값을 힙으로 보내는 일이다
Esc

    박싱은 값을 힙으로 보내는 일이다

    –C#최적화메모리

    제네릭 편에서 이 질문을 던지고 답을 미뤄 뒀습니다.

    object 담기(object 값) { ... }

    아무거나 담고 싶으면 object 로 받으면 되지 않나. 그때는 “꺼낼 때 캐스팅을 해야 하고 타입이 틀리면 터진다” 까지만 말했습니다. 그것 말고 하나가 더 있습니다.

    값 타입을 object 에 넣는 순간 그 값은 힙으로 갑니다. 이걸 박싱이라고 합니다.

    왜 그래야 하나

    object 는 참조 타입입니다. 참조 타입 변수 안에 들어 있는 것은 값이 아니라 주소입니다.

    그런데 int n = 10 의 10 은 스택의 n 자리에 그대로 들어 있습니다. 줄 주소가 없습니다. 스택 자리의 주소를 줄 수도 없습니다. 그 자리는 메서드가 끝나면 사라지는데 object 는 더 오래 살 수 있으니까요.

    그래서 방법이 하나뿐입니다. 힙에 상자를 하나 만들고, 값을 거기 복사해 넣고, 그 주소를 줍니다.

    n = 20 을 했는데 (int)o 가 여전히 10 인 것에 주목할 만합니다. 박싱은 값을 복사해 넣는 것이지 원래 변수를 가리키는 것이 아닙니다.

    꺼낼 때는 타입이 정확히 맞아야 한다

    언박싱은 캐스팅보다 까다롭습니다.

    object o = 10;          // int 를 박싱
    
    int a = (int)o;         // 됩니다
    long b = (long)o;       // 터집니다. InvalidCastException

    int 에서 long 은 평소에 그냥 되는 변환입니다. 그런데 언박싱에서는 안 됩니다. 상자에 “이건 int 다” 라고 적혀 있고, 언박싱은 그 표를 그대로 확인하기 때문입니다.

    한 단계씩 나눠야 합니다.

    long b = (int)o;        // 먼저 int 로 꺼내고, 그다음 long 으로

    어디서 몰래 일어나는가

    박싱이라고 써 붙인 자리는 없습니다. 대입 하나로 조용히 일어납니다.

    목록에 넣을 때. 옛날 ArrayList 는 안이 object[] 입니다.

    ArrayList list = new ArrayList();
    for (int i = 0; i < 10000; i++)
    {
        list.Add(i);        // 만 번 박싱
    }

    List<int> 는 안이 int[] 라서 한 번도 박싱하지 않습니다. 제네릭이 있는 이유가 이것이었습니다. 제네릭 편에서 “우리가 쓰던 것들이 전부 이것이었다” 고 한 자리의 나머지 절반입니다.

    인터페이스로 받을 때.

    struct 점 : IComparable { ... }
    
    점 p = new 점();
    IComparable c = p;      // 박싱

    인터페이스는 참조 타입입니다. struct 를 인터페이스 자리에 넣으면 박싱됩니다.

    문자열에 섞을 때.

    Console.WriteLine("점수: " + score);

    + 로 잇는 쪽은 요즘 컴파일러가 score.ToString() 으로 바꿔 줘서 박싱이 안 일어납니다. 하지만 string.Format 이나 옛날 방식의 가변 인자는 object[] 를 만들기 때문에 박싱이 남아 있습니다.

    왜 신경 쓰나

    한 번은 아무것도 아닙니다. 힙에 아주 작은 상자 하나가 생길 뿐입니다.

    문제는 횟수입니다. 게임 루프처럼 초에 예순 번 도는 자리에서 프레임마다 수백 번 박싱하면 힙에 쓰레기가 계속 쌓입니다. 쌓이면 GC 가 돌고, GC 가 도는 동안 프레임이 한 번 튑니다.

    값 자체가 무거워서가 아니라 할당이 쌓여서 문제가 됩니다. 이건 오브젝트 풀이 푸는 문제와 정확히 같은 문제입니다.

    안 하는 법

    대부분 제네릭으로 해결됩니다.

    ArrayList list = new ArrayList();   // 박싱
    List<int> list = new List<int>();   // 안 함
    
    Hashtable h = new Hashtable();               // 박싱
    Dictionary<string, int> d = new ...();       // 안 함

    제네릭은 T 자리에 int 가 들어오면 int 를 그대로 담는 코드를 따로 만들어 줍니다. 그래서 object 를 거칠 일이 없습니다.

    정렬이나 비교에서는 IComparable 대신 IComparable<T> 를 씁니다. 제네릭 쪽은 인터페이스지만 struct 가 박싱 없이 구현할 수 있게 처리됩니다.

    정리

    • 값 타입을 object 나 인터페이스 자리에 넣으면 힙에 상자가 생깁니다. 이게 박싱입니다
    • 넣을 때 복사, 꺼낼 때 복사입니다. 원래 변수와는 남남이 됩니다
    • 언박싱은 상자에 적힌 타입과 정확히 같아야 합니다. int 를 (long) 으로 못 꺼냅니다
    • 한 번은 싸고 만 번은 비쌉니다. 문제는 값이 아니라 할당 횟수입니다
    • List<int>, Dictionary<K,V> 처럼 제네릭을 쓰면 안 일어납니다

    박싱은 한 번은 싸고 만 번은 비쌌습니다. 값이 무거워서가 아니라 횟수 때문이었습니다.