여러 곳에서 가비지 콜렉션을 고려하는 것이 중요하다고 들었는데,
그동안 제대로 알아보질 못했다.
근데 지금이 딱이다.
시험기간에 뭐든 재미있을 때..
실제로 학교 강의듣다가 이걸 알아보고 공부하니까 너무 재미있었다.
그 유명한 가비지 콜렉션에 대해 드디어 알게 되다니 ><
참고로 내용은 Unity 공식 문서와 거의 일치한다.
단순 타자연습이 되지 않기 위해 중간 중간 배운 점을 적었다.
오브젝트나 문자열, 배열을 생성 및 저장하려면 Heap 메모리 공간이 필요하다.
메모리 공간을 할당받은 항목이 더 이상 사용되지 않으면 차지하던 메모리를 회수하고, 다른 항목을 저장하는 데 사용할 수 있다.
원래는 프로그래머가 명시적으로 힙 메모리를 할당하고 회수하는 등 직접 관리를 해야 했는데,
유니티에서는 Unity Mono 엔진과 같은 런타임 시스템이 자동으로 메모리 관리를 수행한다.
자동으로 메모리 관리가 되기 때문에, 프로그래머가 신경쓸 부분도 적어지고 메모리 누수 현상의 발생 가능성도 낮아진다.
메모리 누수 현상
- 메모리가 할당된 이후 회수되지 않는 경우 발생
함수가 호출되면 하위 파라미터의 값이 해당 함수 호출을 위해 지정된 메모리 구역에 복사된다.
이 때 오브젝트, 문자열, 배열은 복사해야 할 바이트 값이 당연히 더 클 것이다.
다행히도 직접 위 항목들이 복사될 일은 없고, 힙에서 할당된 스토리지 공간을 가리키는 pointer를 사용하게 된다.
따라서 실제 함수가 호출될 때에는 pointer만 복사하면 된다.
이걸 레퍼런스(Reference) 타입이라고 하며, 변수에 저장되는 값은 어디까지나 실제 데이터를 '참조'한다.
값(Value) 타입은 파라미터가 전달되는 동안 사본이 직접 저장되는 타입이다. int, float, bool, Unity 구조체 타입 (Color, Vector3 등) 이 포함된다.
배운 점
- Color, Vector3 도 값 타입이다.
- Object, string, array 등의 레퍼런스 타입이 힙 메모리를 사용하는 경우 함수 호출 시 내용이 직접 복사되지 않고 포인터가 복사된다.
메모리 관리자는 힙에서 사용되지 않는 영역을 트래킹한다.
오브젝트가 인스턴스화되는 것과 같이 새로운 메모리 블록이 요청되는 경우,
블록을 할당하기 위해 미사용 영역을 선택한 후 할당된 메모리를 제거한다.
이 과정은 필요한 블록 크기를 할당 가능한 빈공간이 확보될 때까지 반복된다.
이 시점에는 힙에서 할당된 모든 메모리가 사용중일 가능성이 매우 낮다.
힙에 있는 참조 항목을 접근하려면 해당 항목을 찾을 수 있도록 하는 참조 변수가 필요하다.
참조 변수가 재할당되거나 로컬 변수로 변하는 경우와 같이 메모리 블록에 대한 모든 참조가 사라진 경우, 해당 메모리 블록을 안전하게 재할당할 수 있게 된다.
(메모리 블록에 대한 참조가 남아있는데 재할당하게, 되면 남아있는 참조가 다시 사용될 때 잘못된 블록을 참조하게 될 수 있기 때문인 것 같다)
어떤 힙 블록이 더 이상 사용되지 않고 있는지를 확인하기 위해, 메모리 관리자는 현재 모든 액티브 참조 변수를 검색하고, 이 변수가 참조하는 블록을 "살아있음(live)"이라고 표시한다.
검색이 끝나면 메모리 관리자는 살아 있는 블록 사이의 모든 공간을 비어 있다고 간주하며 다음 할당 요청 시 사용할 수 있다고 간주한다.
배운 점
- 오브젝트가 인스턴스화 될 때 새로운 메모리 블록이 요청된다. 이 때 메모리 블록을 할당하기 위한 메모리 관련 작업들이 수행된다.
- 이건 예전에 피드백 받은 부분이기도 한데, 되도록이면 실시간으로 인스턴스화를 자주 시키기보다는 Start()같은 곳에서 미리 인스턴스화 해두고 재활용하는 것이 좋을 것 같다.
- 가비지 컬렉션은 미사용 메모리를 파악하고 해제하는 프로세스이다.
가비지 컬렉션은 자동으로, 프로그래머에게 비 가시적으로 일어난다.
그러나 컬렉션 프로세스는 내부적으로 상당한 CPU 시간을 요구한다.
제대로 사용하면 자동 메모리 관리는 일반적으로 전반적인 성능에 있어 수동 할당과 비슷하거나 훨씬 더 나은 결과를 나타낸다.
그러가 프로그래머 입장에서는 가비지 컬렉터가 필요 이상으로 자주 실행되어 게임 실해 중에 멈추는 현상을 유발하는 실수를 막는 것이 중요하다
다음과 같은 문자열 접합 반복 알고리즘은 GC의 악몽을 불러올 수 있는 악명 높은 알고리즘이다.
//C# script example
using UnityEngine;
using System.Collections;
public class ExampleScript : MonoBehaviour {
void ConcatExample(int[] intArray) {
string line = intArray[0].ToString();
for (i = 1; i < intArray.Length; i++) {
line += ", " + intArray[i].ToString();
}
return line;
}
}
위 코드에서 주목할 점은 line에 += 연산자를 사용하고 있지만, 문자열이 한 개씩 추가되지는 않는다는 점이다.
실제로는 루프가 실행될 때마다 line 변수의 이전 내용이 삭제되고, 기존 문자열 끝에 새로운 부분이 더해진 형태의 새로운 문자열이 할당된다.
i의 값이 커질수록 문자열이 길어지므로, 사용되는 힙 공간도 증가한다.
따라서 이 함수는 매번 호출될 때마다 순식간에 빈 힙 공간을 수백 바이트 사용한다.
문자열을 동시에 여러 개 연결해야 하는 경우, Mono 라이브러리의 System.Text.StringBuilder 클래스를 사용하는 것이 좋다.
문자열 연결이 반복되어도 지나치게 자주 호출되지 않는 이상 큰 문제가 발생하지는 않는데, Update()에서 매 프레임 업데이트 마다 반복하면 문제가 될 수 있다.
using UnityEngine;
using System.Collections;
public class ExampleScript : MonoBehaviour {
public GUIText scoreBoard;
public int score;
void Update() {
string scoreText = "Score: " + score.ToString();
scoreBoard.text = scoreText;
}
}
위 코드는 Update가 호출될 때마다 새 문자열을 할당하며 지속적으로 새 가비지 메모리를 조금씩 생성한다.
score가 변경될 때마다 텍스트를 업데이트하면, 대부분의 메모리 누수를 줄일 수 있다.
using UnityEngine;
using System.Collections;
public class ExampleScript : MonoBehaviour {
public GUIText scoreBoard;
public string scoreText;
public int score;
public int oldScore;
void Update() {
if (score != oldScore) {
scoreText = "Score: " + score.ToString();
scoreBoard.text = scoreText;
oldScore = score;
}
}
}
배운점
- 가비지 컬렉터가 눈에 보이지는 않지만, 필요 이상으로 실행되도록 막는 것이 프로그래머의 중요한 역할 중 하나이다.
- 특히 프레임 업데이트마다 문자열을 연결하는 코드는 GC가 자주 발생하여 큰 문제가 될 수 있다.
- 문자열을 동시에 여러 개 연결해야 하는 경우, System.Text.StringBuilder 클래스를 활용할 수 있다.
- (+=) 연산자를 통해 string 값을 설정하는 경우 이전 내용 삭제 후 재할당하는 과정이 진행된다.
GC 관련해서 발생할 수 있는 또 다른 문제는 어떤 함수가 배열 값을 반환하는 경우이다.
using UnityEngine;
using System.Collections;
public class ExampleScript : MonoBehaviour {
float[] RandomList(int numElements) {
var result = new float[numElements];
for (int i = 0; i < numElements; i++) {
result[i] = Random.value;
}
return result;
}
}
보통 배열의 크기는 상당히 크므로 빈 힙 공간이 빠르게 소모되어 자주 가비지 컬렉션을 해야 한다.
배열이 참조 타입이라는 점을 활용하면 이 문제를 피할 수 있다.
바로 함수에서 배열을 반환하도록 하는 것이 아니라 파라미터로 배열을 전달하는 것이다.
배열은 참조 타입이므로 파라미터로 전달된 배열을 함수 안에서 수정할 수 있고 함수가 리턴되어도 그 값은 유지될 것이기 때문이다.
using UnityEngine;
using System.Collections;
public class ExampleScript : MonoBehaviour {
void RandomList(float[] arrayToFill) {
for (int i = 0; i < arrayToFill.Length; i++) {
arrayToFill[i] = Random.value;
}
}
}
위와 같이 void 타입으로 함수를 수정하면,
반환하기 위한 배열을 새로 할당받거나 하지 않기 때문에, 함수 호출 시 새로운 가비지를 생성하지 않는다.
배운 점
- 함수를 만들 때 배열을 반환하도록 하는 방식보다 out parameter로 배열을 전달하는 방식이 더 좋다.
- 배열은 빈 힙 공간을 많이 소모시키므로 가비지 컬렉션을 많이 필요로 한다.
Mono 또는 IL2CPP 스크립팅 백엔드를 사용하는 경우에는 런타임 시 가비지 컬렉션을 비활성화하여 가비지 컬렉션 동안 CPU 사용량 급증을 막을 수 있다.
가비지 컬렉션을 비활성화하면 가비지 컬렉터는 레퍼런스가 없는 오브젝트를 더 이상 수집하지 않기 때문에 메모리 사용량이 감소되지 않는다.
실제로 가비지 컬렉션을 비활성화하면 관리자가 없기 때문에 메모리 사용량이 계속 증가한다.
이상적으로는, 가비지 컬렉터를 비활성화하기 전에 모든 메모리를 할당하고, 비활성화된 시간 동안에는 추가 할당을 피하는 것이 좋다.
런타임 시 가비지 컬렉션을 활성화하거나 비활성화하는 방법은 Garbage Collector:Scripting API 참고하면 된다.
배운 점
- 가비지 컬렉션을 활성화하면 CPU 사용량이 급증할 수 있다. (그래서 프로그래머가 가비지 컬렉션이 자주 발생하지 않도록 신경써야 한다.)
- 가비지 컬렉션을 비활성화할 수도 있는데, 이 땐 메모리 할당 시 더 주의해야 한다.
이 방법은 오래 플레이되는 게임에서 부드러운 프레임률을 유지하는 데 가장 적합하다.
이러한 게임은 작은 블록을 자주 할당하게 되지만, 짧은 기간 동안만 사용된다.
이 방법을 iOS에서 사용할 때 할당할 일반적인 힙 크기는 200KB이며, 가비지 컬렉션은 이 경우 iPhone 3G에서 대략 5ms 정도 걸리게 된다. 힙 크기가 1MB로 증가하면 가비지 컬렉션은 7ms 정도 걸리게 된다.
힙 크기가 800 넘게 늘어났는데, 가비지 컬렉션에 걸리는 시간은 2ms밖에 늘지 않았다.
따라서 가비지 콜렉션을 일정 프레임 간격마다 주기적으로 요청하는 것이 좋다.
이렇게 하면 가비지 컬렉션이 실제로 필요한 만큼 이상으로 발생하게 되지만 더 빠르게 수행되어 게임플레이에 최소한의 지장을 주게 된다.
if (Time.frameCount % 30 == 0)
{
System.GC.Collect();
}
위 코드를 보면 주기적으로 GC.Collect()를 호출하게 되어있다.
그러나 이 기법은 주의해서 사용해야 하며 게임에서 실제로 가비지 컬렉션 시간을 감소시키는지는 프로파일러 통계를 확인해야 한다.
이 방법은 메모리 할당과 가비지 컬렉션이 비교적 자주 발생하지 않아 게임플레이 중간에 처리할 수 있는 게임에 적합하다.
힙 용량은 최대한 큰 것이 좋지만, 너무 커서 운영체제가 시스템 메모리를 확보하기 위해 앱을 강제종료하는 경우는 피해야 한다.
Mono 런타임은 자동으로 힙을 확장하는 것을 최대한 피한다.
따라서 수동으로 힙을 확장해야 하는데, 시작할 때 일부 플레이스 홀더를 미리 할당하면 된다.
예를 들어, 메모리 관리자에서 메모리를 할당받기 위해 "무의미한" 오브젝트를 인스턴스 처리하면 된다.
using UnityEngine;
using System.Collections;
public class ExampleScript : MonoBehaviour {
void Start() {
var tmp = new System.Object[1024];
// make allocations in smaller blocks to avoid them to be treated in a special way, which is designed for large blocks
for (int i = 0; i < 1024; i++)
tmp[i] = new byte[1024];
// release reference
tmp = null;
}
}
위 코드와 같이 충분히 큰 힙을 확보하면 게임플레이 중 일시정지가 발생할 때까지 완전히 힙이 차버려서 가비지 컬렉션이 일어나는 일은 발생하지 않는다.
일시정지가 발생하면 명시적으로 가비지 컬렉션을 요청할 수 있다.
배운 점
- 충분히 큰 힙을 확보하여 빈 메모리 블록을 미리 할당받는 특이한? 액션을 배웠다.
- System.GC.Collect()를 통해 명시적으로 가비지 컬렉션을 요청할 수 있지만, 진짜 잘 되고 있는지는 프로파일러 통계를 통해 확인해야 한다.
배운 점
- 런타임에 Instantiate하고 Destroy하는 것을 막기 위해 오브젝트 풀을 사용했었는데, 이 방식은 가비지 생성을 줄일 수 있음을 배웠다.
- 더불어 가비지 콜렉션 실행을 막고, 이로 인해 CPU 사용량이 늘어나는 것을 방지할 수 있음을 배웠다.