메모리 최적화 측면에서 배열 vs 변수 방식
메모리 최적화 측면에서 배열 vs 변수 방식
n개의 타일을 채우는 방법을 구하는 문제를 풀다가, BigInteger를 이용한 두 가지 방식의 메모리 사용량이 다르게 나오는 걸 발견했다.
-
BigInteger로 반복 계산을 하는 방식
public BigInteger solution(int n) { BigInteger count1 = new BigInteger("1"); BigInteger count2 = new BigInteger("1"); for (int i = 2; i <= n; i++) { BigInteger count = count1.multiply(BigInteger.valueOf(2)).add(count2); count1 = count2; count2 = count; } return count2; }
-
배열을 이용한 Bottom-Up 방식
dp[0] =new BigInteger("1"); dp[1] =new BigInteger("1"); dp[2] =new BigInteger("3"); for(int i=3; i<251; i++) { dp[i] = dp[i-1].add(dp[i-2].multiply(new BigInteger("2")));
어떤 방식이 메모리를 덜 잡아먹을까?
‘배열을 이용한 Bottom-Up 방식’이 메모리를 좀 더 적게 잡아먹었다.
겉보기에는 변수를 3개만 사용하여 반복문을 도는 첫번째 방식이 메모리를 덜 잡아먹는 것으로 보인다.
하지만 메모리 사용량을 확인해보면
==== 변수 방식 ====
결과: 1206167596222043702328864427173832373471562340267089208744349833415761767083
소요 시간: 6 ms
메모리 사용량: 125848 bytes (122.90 KB)
==== 배열 방식 ====
결과: 1206167596222043702328864427173832373471562340267089208744349833415761767083
소요 시간: 1 ms
메모리 사용량: 84904 bytes (82.91 KB)
배열 방식이 메모리를 덜 차지하고, 소요 시간도 적다.(소요 시간 차이는 캐시 효율성 때문)
GC(Garbage Collection)
GC는 JVM(Java Virtual Machine)에서 더 이상 사용하지 않는 객체를 자동으로 메모리에서 해제해주는 역할을 한다.
GC가 정리하는 대상
더 이상 참조되지 않는 객체
→ 어떤 변수나 필드, 스택에서도 접근할 수 없는 객체
GC 동작 방식
-
GC 대상 객체 탐색 (Marking)
GC는 주기적으로 힙 메모리의 객체들을 탐색한다.
GC는 도달가능한 객체와 도달 불가능한 객체를 구분한다.
- 도달 가능한 객체: 프로그램의 어떤 변수나 필드, 스택에서 참조할 수 있는 객체
- 도달 불가능한 객체: 더 이상 프로그램에서 참조할 수 없는 객체 (주로 참조가 끊어진 객체)
GC는 루트 객체부터 탐색을 시작한다. 루트 객체는 보통 스택 프레임, 전역 변수, 정적 변수 등이다. 루트 객체에서부터 시작해서, 해당 객체가 참조하는 모든 객체를 탐색한다. 사용 중인 객체를 마킹한다.
-
참조되지 않는 객체 정리(Sweeping)
마킹되지 않은 객체들은 더 이상 사용하지 않으므로 제거 대상이 된다.
GC는 힙 메모리를 다시 순회하며 마킹되지 않은 객체들을 제거한다. 이때, 해당 객체가 점유한 메모리는 해제된다.
-
메모리 압축(Compact)
메모리 해제 후 힙 메모리에서 빈 공간이 발생할 수 있다. 이때 메모리 단편화가 발생할 수 있다.
GC는 컴팩션(compaction) 작업을 수행하여, 사용되지 않는 메모리 공간을 압축하고, 빈 공간을 모아 연속적인 메모리 공간을 제공한다.
-
일정 시간마다 반복
GC는 일정 시간마다 주기적으로 위의 과정을 반복한다. 자주 실행될수록 더 많은 메모리 정리가 이루어지지만, GC를 실행하는데 시간이 들기 때문에 프로그램의 성능에 영향을 줄 수 있다.
GC는 언제 동작하는가?
GC는 반드시 작동하지는 않는다.
- JVM이 할당한 힙 메모리가 부족할 때
- 일정 시간 이상 지나서 GC 조건이 만족될 때
- 명시적으로 System.gc()을 호출했을 때
→ GC가 있다고 해서 무조건 객체가 정리되는 것은 아니며, 단순히 변수를 계속 새로 생성한다고 해서 메모리가 즉시 회수되는 것도 아니다.
변수 방식은 GC가 돕긴 하지만, 실제로는 GC가 작동하지 않을 가능성이 높다.
배열은 생성 시점에 필요한 만큼의 메모리를 정확하게 확보한다. 모든 요소는 명시적으로 관리되기 때문에 GC 대상이 되지 않는다. 이 방식은 메모리 예측이 가능하고, 입력 크기가 고정되거나 상한이 있는 경우 특히 유리하다.
실전에서는 GC를 기대하며 코드를 짜는 것보다, 명확한 메모리 관리를 우선시해야한다.
GC는 매우 훌륭한 도구이지만, 언제 작동할지 확신할 수 없다.
- 알고리즘 문제나 온라인 저지 환경에서는 GC가 작동할 시간도, 필요도 없는 경우가 많다.
- 고성능 시스템에서는 GC로 인한 어플리케이션 일시중지가 성능 병목이 되기도 한다(GC가 동작하는 동안에는 어플리케이션의 동작이 잠시 멈춘다).
→ 실무에서는 GC의 개입 없이도 안정적으로 돌아가는 코드가 더 좋다. 배열 방식은 이런 측면에서 예측가능하고 안정적이다.
GC는 부족한 메모리를 보완해주는 백업 수단일 뿐, 메모리 관리를 대신해주는 마법사가 아니다.
추가로, GC를 자주 실행할 경우 Mark - Sweep - Compact 과정을 거치기 때문에 성능 저하가 발생할 수 있다.