본문 바로가기

Unity(C#)

Unity GC를 호출했는데 왜 메모리가 바로 줄어들지 않을까?

Unity에서 메모리 프로파일링을 하다 보면 이런 상황을 자주 보게 된다.

객체를 많이 생성하면 메모리 사용량이 증가한다.
그리고 더 이상 사용하지 않으려고 참조를 끊고 GC.Collect()를 호출한다.

그런데 이상하게도 메모리 사용량이 바로 줄어들지 않는 경우가 있다.

분명히 객체는 더 이상 사용하지 않는데, 왜 Unity Profiler나 OS 메모리 사용량은 그대로 남아 있을까?

이를 이해하려면 메모리를 하나의 큰 덩어리로 생각하면 안 된다.

메모리는 하나의 큰 통이 아니라, 여러 개의 박스처럼 나뉘어 관리된다.

예를 들어 A박스, B박스, C박스가 있다고 생각해보자.

우리가 메모리 10만큼을 처음 할당한다고 해서 시스템이 정확히 10만큼만 가져오는 것은 아니다.

Unity의 GC는 관리형 객체를 저장하기 위해 더 큰 단위의 메모리 박스를 확보한다.

예를 들어 내가 10만큼의 메모리를 요청했는데, GC가 내부적으로 A박스 100짜리를 확보할 수 있다.

A박스 크기: 100
실제 사용량: 10
빈 공간: 90

이때 내가 실제로 사용한 메모리는 10이다.

하지만 시스템이나 Unity GC 입장에서 확보해 둔 메모리는 100이다.

그래서 Profiler에서 보면 GC Used Memory는 10에 가깝게 보일 수 있지만, GC Reserved Memory나 Total Reserved Memory는 100처럼 더 크게 보일 수 있다.

여기서 다시 메모리 10을 추가로 할당한다고 해보자.

그러면 GC는 새로운 박스를 바로 만들지 않고, 기존 A박스에 빈 공간이 있다면 그 안에 객체를 배치한다.

A박스 크기: 100
실제 사용량: 20
빈 공간: 80

이 경우 실제 사용량은 20으로 증가했지만, 시스템에 예약된 메모리는 여전히 100일 수 있다.

즉, 내가 할당한 객체의 크기와 시스템이 확보해 둔 메모리 크기는 항상 같지 않다.

객체를 해제해도 박스는 남아 있을 수 있다

이번에는 처음 할당했던 메모리 10을 더 이상 사용하지 않는다고 해보자.

C#에서는 직접 메모리를 해제하는 것이 아니라, 해당 객체를 참조하지 않도록 만든다.

예를 들어 리스트를 비우거나, 변수를 null로 만들 수 있다.

objects.Clear();

하지만 이 작업은 메모리를 바로 반환하는 것이 아니다.

이건 단지 객체로 가는 참조를 끊는 것이다.

GC가 실행되기 전까지는 해당 객체가 실제로 수집되었다고 볼 수 없다.

그리고 GC가 실행되어 그 객체가 수집되었다고 해도, A박스 자체가 바로 사라지는 것은 아니다.

A박스 크기: 100
실제 사용량: 10
빈 공간: 90

처음 10은 사라졌지만, 두 번째로 할당한 10은 아직 살아 있다.

A박스 안에 살아있는 객체가 하나라도 남아 있다면, GC는 A박스 전체를 운영체제에 반환하기 어렵다.

그래서 객체 하나를 해제해도 시스템 메모리 사용량은 그대로일 수 있다.

이때 중요한 차이가 있다.

객체가 해제됨

박스가 운영체제에 반환됨

은 같은 말이 아니다.

GC는 우선 객체를 수집하고, 그 공간을 다시 사용할 수 있는 빈 공간으로 표시한다.

하지만 그 빈 공간이 들어있는 박스 자체는 Unity가 계속 들고 있을 수 있다.

왜냐하면 다음에 다시 객체를 할당할 때, 운영체제에서 새 메모리를 요청하는 것보다 기존 박스를 재사용하는 편이 더 효율적이기 때문이다.

박스가 완전히 비어야 반환 가능해진다

이제 남아 있던 두 번째 10도 해제한다고 해보자.

GC가 실행된 후 A박스 안에 살아있는 객체가 하나도 없다면 이런 상태가 된다.

A박스 크기: 100
실제 사용량: 0
빈 공간: 100

이제 A박스는 완전히 비어 있다.

이 상태가 되면 A박스는 운영체제에 반환 가능한 상태가 된다.

하지만 여기서도 주의해야 한다.

박스가 완전히 비었다고 해서 Unity가 반드시 즉시 운영체제에 반환하는 것은 아니다.

Unity GC는 비어 있는 박스를 바로 없애기보다, 이후 할당을 위해 잠시 유지할 수 있다.

즉, 다음과 같은 상태가 가능하다.

GC Used Memory: 감소
GC Reserved Memory: 그대로
Total Reserved Memory: 그대로

이 말은 살아있는 C# 객체는 줄었지만, GC가 확보해 둔 박스는 아직 유지되고 있다는 뜻이다.

Unity 입장에서는 이 박스를 다음 할당에 재사용할 수 있으므로 굳이 바로 운영체제에 돌려주지 않을 수 있다.

왜 큰 배열을 해제하면 바로 줄어드는 경우가 있을까?

그런데 테스트를 하다 보면 byte[] 같은 큰 배열을 많이 만들고, 참조를 끊은 뒤 GC를 호출했을 때 메모리가 바로 줄어드는 경우가 있다.

이 경우는 앞의 설명과 모순되는 것이 아니다.

큰 배열은 일반적인 작은 객체들과 다르게 큰 연속 메모리 영역을 차지할 수 있다.

예를 들어 1MB짜리 byte[]를 많이 만들면, GC 입장에서는 작은 객체들이 박스 안에 조각조각 들어간 상황이 아니라, 큰 객체들이 큰 영역을 차지하고 있는 상황에 가깝다.

이런 큰 객체들이 한꺼번에 죽으면, GC는 큰 연속 영역이 완전히 비었다고 판단할 수 있다.

그러면 Unity나 런타임이 해당 영역을 비교적 빨리 반환하거나 재정리할 수 있다.

그래서 GC.Collect() 직후 Total Reserved Memory나 GC Reserved Memory가 바로 줄어드는 것처럼 보일 수 있다.

하지만 이것은 “GC를 호출하면 항상 메모리가 바로 줄어든다”는 뜻이 아니다.

큰 객체가 한꺼번에 사라져서 반환하기 좋은 조건이 만들어졌기 때문에 그런 결과가 나온 것이다.

작은 객체가 섞여 있으면 다르게 보인다

실제 게임에서는 큰 배열 하나만 만들고 지우는 상황보다, 작은 객체들이 여러 곳에 섞여 있는 경우가 많다.

예를 들어 A박스 안에 작은 객체들이 다음처럼 들어 있다고 생각해보자.

A박스
[죽은 객체][살아있는 객체][죽은 객체][죽은 객체][살아있는 객체]

여기서 죽은 객체들은 GC가 수집할 수 있다.

하지만 중간중간 살아있는 객체가 남아 있다.

그러면 A박스 전체는 비어 있지 않다.

결과적으로 GC가 실행되어 죽은 객체를 수집해도, A박스 자체는 운영체제에 반환되기 어렵다.

이때 Profiler에서는 이런 식으로 보일 수 있다.

GC Used Memory는 감소
GC Reserved Memory는 유지
Total Reserved Memory는 유지

즉, 실제 살아있는 객체의 양은 줄었지만, 시스템에 예약된 메모리는 줄지 않는 것이다.

이게 Unity에서 GC를 호출했는데도 메모리 사용량이 바로 내려가지 않는 주된 이유다.

GC Used와 GC Reserved는 다르다

여기서 GC Used Memory와 GC Reserved Memory를 구분해야 한다.

GC Used Memory는 현재 살아있는 관리형 객체들이 실제로 사용하고 있는 메모리에 가깝다.

반면 GC Reserved Memory는 GC가 관리형 힙을 위해 확보해 둔 전체 박스 크기에 가깝다.

예를 들어 이런 상태가 가능하다.

A박스 크기: 100
실제 사용량: 20
빈 공간: 80

이 경우:

GC Used Memory = 20
GC Reserved Memory = 100

처럼 볼 수 있다.

객체를 해제해서 실제 사용량이 5로 줄어도, 박스가 유지된다면:

GC Used Memory = 5
GC Reserved Memory = 100

이 될 수 있다.

그래서 GC를 호출했을 때 GC Used Memory는 줄어드는데, GC Reserved Memory나 Total Reserved Memory는 그대로 남는 상황이 발생한다.

정리

Unity에서 메모리는 내가 요청한 크기만큼 정확히 할당되고 해제되는 구조가 아니다.

GC는 관리형 객체를 저장하기 위해 더 큰 단위의 메모리 박스를 확보한다.

내가 10만큼의 객체를 할당해도, 내부적으로는 100짜리 박스가 확보될 수 있다.

그리고 다음 객체는 그 박스 안의 빈 공간에 들어간다.

객체를 더 이상 사용하지 않게 되면 GC는 그 객체를 수집하고, 해당 공간을 재사용 가능한 빈 공간으로 표시한다.

하지만 박스 자체는 바로 운영체제에 반환되지 않을 수 있다.

특히 박스 안에 살아있는 객체가 하나라도 남아 있다면, 박스 전체를 반환하기 어렵다.

그래서 GC를 호출했는데도 메모리 사용량이 바로 줄어들지 않는 상황이 발생한다.

즉, 핵심은 다음과 같다.

객체 해제 = 박스 안의 공간이 비는 것
박스 반환 = Unity/GC가 박스 자체를 운영체제에 돌려주는 것

이 둘은 같은 작업이 아니다.

따라서 GC.Collect()를 호출했다고 해서 항상 물리 메모리나 Total Reserved Memory가 바로 줄어드는 것은 아니다.

GC는 우선 죽은 객체를 수집하고, 그 공간을 재사용 가능한 상태로 만든다.

운영체제에 메모리를 반환하는 것은 별도의 조건이 맞을 때 일어날 수 있다.

그래서 Unity에서 메모리를 볼 때는 단순히 “GC를 했는데 왜 메모리가 안 줄지?”라고 보기보다,

GC Used Memory가 줄었는지
GC Reserved Memory가 유지되는지
Total Reserved Memory가 실제로 줄었는지
OS의 Resident / Dirty Memory도 줄었는지

를 나누어 봐야 한다.

결론적으로 GC는 메모리를 무조건 운영체제에 반환하는 기능이 아니라, 사용하지 않는 객체를 찾아서 재사용 가능한 공간으로 만드는 기능에 가깝다.

Unity는 그 공간을 바로 버리기보다, 이후 할당을 위해 들고 있을 수 있다.

그래서 GC 이후에도 메모리 사용량이 줄어들지 않는 것은 이상한 현상이 아니라, 관리형 힙이 동작하는 자연스러운 방식이다.

참고자료
https://discussions.unity.com/t/understanding-memory-management-in-unity/1700107

 

Understanding Memory Management in Unity

Hi! I’m Jemin Lee, a Partner Engineer at Unity. Over the years, there are two questions I’ve heard many times whenever developers start profiling memory in Unity: Why doesn’t my managed heap or virtual memory usage drop, even after the GC runs? Is it

discussions.unity.com

-