이번 포스트에서는 JVM(Java Virtual Machine)의 메모리 관리방법에 대해서 약간 설명하도록 한다. JVM의 메모리 관리방법을 알기 위해서는 JVM이 무엇인지에 대해 먼저 알아야한다. 따라서 JVM에 대해 간단히 설명하고, JVM이 메모리를 관리하는 방법인 Garbage Collector와 Mark & Sweep 알고리즘에 대해 설명하도록 한다. 초심자를 위한 글이기 때문에 가비지컬렉터의 기본 원리만 설명하고 자바 메모리 모델에 대해서는 다른 포스트에서 다루도록 하겠다.
JVM(Java Virtual Machine)이란?
JVM은 프로그램이다. 이 프로그램에 자바 언어를 컴파일한 *.class파일을 인풋으로 줘 실행하면 JVM 프로그램이 실행하면서 입력받은 .class파일을 실행한다.
$ javac GarbageCollectorTutorialMain.java
위처럼 자바 파일을 class파일로 컴파일한다.
$ java GarbageCollectorTutorialMain
위처럼 컴파일된 클래스파일의 이름을 매개변수로 주며 JVM을 실행시킨다. 그러며 JVM이 실행하면서 GarbageCollectorTutorialMain.class를 실행한다.
JVM은 클래스파일을 실행시키는 것 외에도 다른 많은 작업들을 한다. 그 중 대표적으로 JIT 컴파일러[프로그래밍 언어, 컴파일러, 인터프리터 참고]가 있고, 또 가비지 컬렉터(Garbage Collector), 즉 메모리 관리가 있다. (다른것도 많다.) 가비지 컬렉터(Garbage Collector) - gc() 를 부르면 생기는 일
가비지 컬렉터가 하는 일은 간단하다, 사용되지 않는 메모리를 다시 사용할 수 있도록 하는 것(Memory Reclamation)이다. 이게 무슨 뜻인가? JVM이 할당된 모든 메모리를 가지고 있고, 이를 관리한다는 뜻이다. JVM 실행시에 JVM 프로그램 내부에서 Stack과 Heap이라는 메모리 공간이 생성(초기화)된다. 메서드는 Stack에서 실행된다, 따라서 로컬 변수들은 Stack에 생성되고, 자바의 오브젝트와 클래스들은 Heap 공간에 할당된다. 가비지컬렉터는 우리가 인풋으로 준 클래스파일을 실행하는 도중에 실행되며 Heap공간에서 더이상 참조(reference)되지 않는 메모리 공간을 수거한다.
주의! 메모리에서 말하는 Heap은 자료구조의 Heap과는 다른 개념이다. Heap메모리는 Stack과는 다르게 메모리를 중구 난방으로 할당/해제 할 수 있는 공간정도로 생각하면 된다.
마크 앤 스윕(Mark and Sweep)
가비지 컬렉터에는 GC Root라는 것이 있다. GC Root들은 힙 외부에서 접근할 수 있는 변수나 오브젝트를 뜻한다. GC Root는 말그대로 가비지 컬렉션의 Root라는 뜻이다. GC Root에서 시작해 이 Root가 참조하는 모든 오브젝트, 또 그 오브젝트들이 참조하는 다른 오브젝트들을 탐색해 내려가며 마크(Mark)한다. 이게 바로 가비지 컬렉션의 첫번째 단계인 Mark단계이다.
<GC Root에서 시작해 참조된 오브젝트 Mark>
아래는 GC Root가 될 수 있는 것들이다.
1. 실행중인 쓰레드 (Active Thread)
2. 정적 변수 (Static Variable)
3. 로컬 변수 (Local Variable)
4. JNI 레퍼런스 (JNI Reference)
<Sweep후 Heap공간의 모습>Mark가 끝나면 가비지 컬렉터는 힙 내부를 전체를 돌면서 Mark되지 않은 메모리들을 해제(Reclaim)한다. 이 과정을 Sweep이라고 부른다.
알고리즘은 간단하다. 좀더 자세한 예를 보자.
오브젝트 생성
일단은 JVM에서 오브젝트를 생성하는 경우 어떻게 되는지 보자.
예를들어 String str = new String("Hello World");라는 코드가 있다고 치자. 그러면 str이라는 변수가 스택(Stack)에 생기고 new String("Hello World") 오브젝트는 Heap공간에 생성된다. 그리고 str의 변수가 생성된 String 오브젝트를 참조(reference)한다.
<자바 메모리 할당>Mark and Sweep
여기서 GC Root가 누군지 보이는가? GC Root는 바로 지역변수인 str이다. 가비지 컬렉터가 실행되면 GC root인 str이 참조하는 메모리인 new String("Hello World");를 Mark할 것이다. 이후 메서드가 리턴하면서 str이라는 변수가 사라지면 new String("Hello World");는 아무도 레퍼런스 하지 않게 된다. 따라서 다음 가비지 컬렉터 실행시 Sweep될 수 있다.
<GC될 가능성이 있는 오브젝트>
"Sweep 될 수 있다"라고 한 이유는 JVM 메모리 모델에 따라 당장 Sweep이 될 수도, 아니면 다른 공간으로 옮겨질 수도 있기 때문이다.
구현이 잘 와 닿지 않으면 아래처럼 생각해보자.
1. 어떤 변수나 오브젝트는 자기 자신이 참조하고 있는 다른 오브젝트 리스트를 가지고 있다.
2. JVM혹은 GC(가비지 컬렉터)는 힙(Heap)공간의 모든 오브젝트에 접근 할 수 있으며, GC Root 리스트를 가지고 있다.
3. GC는 Map<오브젝트 주소, boolean>를 가지고 있고 이 Map은 힙 공간의 모든 오브젝트의 주소, false로 초기화된다.
4. GC Root부터 시작해 위의 맵에 map.put(오브젝트주소, true)와 같이 Mark 할 수 있다.
5. Sweep은 이 맵을 탐색해 false인 오브젝트주소의 메모리를 수거한다.
실제로 일어나는 일은 가비지 컬렉터마다 다를 수 있고, 실제 Map<오브젝트주소, boolean>은 메모리가 너무 크므로 최적화된 다른 종류의 맵(ex, bitmap)이나 다른 구현방법을 사용할 수 있다. 하지만 지금 단계에서는 위처럼 이해해도 상관 없다.
이 포스트에서 더 이상의 자세한 내용은 다루지 않도록 하겠다. 더 깊이 공부하고 싶은 독자들은 JVM Memory Model에 대해 찾아보면 된다.
코드를 통한 GC 확인
import java.util.ArrayList;
import java.util.List;
public class GarbageCollectorTutorialMain {
public static void main(String [] args) {
List<String> list1 = new ArrayList<>();
List<String> list2 = new ArrayList<>();
for(int i = 0; i < 10000; i++) {
list1.add("Random-String1" + Math.random());
list2.add("Random-String2" + Math.random());
}
Runtime.getRuntime().gc();
list1 = null; // remove the reference to list1
Runtime.getRuntime().gc();
list2 = null; // remove the reference to list2
Runtime.getRuntime().gc();
}
}
이해를 돕기 위해 코드 예제를 가지고 왔다. 가비지 컬렉터 로그를 보기 위해서는 프로그램 실행시 몇가지 매개변수를 넘겨주어야 한다.
▲javac GarbageCollectorTutorialMain.java
터미널에서 자파 파일이 존재하는 디렉토리로 들어가 자바 파일을 컴파일 하도록 한다. 컴파일 후 아래처럼 JVM 매개변수를 명시해 가비지 컬렉터 실행시 로그가 나오도록 한다.
▲ java -XX:+UseSerialGC -Xms16m -Xmx16m -verbose:gc -XX:+PrintGCDetails GarbageCollectorTutorialMain
-XX:+UseSerialGC : 여러가지 가비지 컬렉터중 SerialGC를 사용하라는 뜻.
-Xms16m 최소 힙 메모리 16 메가바이트
-Xmx16m 최대 힙 메모리 16 메가바이트 (일부러 작게 했다)
-verbose:gc 가비지컬렉터 로그 출력
-XX:+PrintGCDetails 실행 종료시 종료 당시의 메모리 상태 출력
실행하면 아래처럼 가비지 컬렉터의 로그가 출력 될 것이다.
[GC (Allocation Failure) [DefNew: 4416K->511K(4928K), 0.0036435 secs] 4416K->1547K(15872K), 0.0036715 secs]
[Times: user=0.00 sys=0.00, real=0.00 secs] <- 메모리 할당을 하려는데 부족해서 GC를 실행했다는 뜻.
[Full GC (System.gc()) [Tenured: 1035K->2633K(10944K), 0.0051777 secs] 4337K->2633K(15872K), <- 첫번쨰 .gc()
[Metaspace: 3132K->3132K(1056768K)], 0.0052066 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[Full GC (System.gc()) [Tenured: 2633K->2085K(10944K), 0.0035206 secs] 2633K->2085K(15872K), <- 두번째 .gc()
[Metaspace: 3132K->3132K(1056768K)], 0.0035610 secs] [Times: user=0.01 sys=0.00, real=0.00 secs]
[Full GC (System.gc()) [Tenured: 2085K->442K(10944K), 0.0018681 secs] 2085K->442K(15872K), <- 세번째 .gc()
[Metaspace: 3132K->3132K(1056768K)], 0.0018869 secs] [Times: user=0.00 sys=0.00, real=0.01 secs]
Heap <- PrintGCDetils
def new generation total 4928K, used 44K [0x00000007bf000000, 0x00000007bf550000, 0x00000007bf550000)
eden space 4416K, 1% used [0x00000007bf000000, 0x00000007bf00b218, 0x00000007bf450000)
from space 512K, 0% used [0x00000007bf4d0000, 0x00000007bf4d0000, 0x00000007bf550000)
to space 512K, 0% used [0x00000007bf450000, 0x00000007bf450000, 0x00000007bf4d0000)
tenured generation total 10944K, used 442K [0x00000007bf550000, 0x00000007c0000000, 0x00000007c0000000)
the space 10944K, 4% used [0x00000007bf550000, 0x00000007bf5be8b0, 0x00000007bf5bea00, 0x00000007c0000000)
Metaspace used 3139K, capacity 4496K, committed 4864K, reserved 1056768K
class space used 333K, capacity 388K, committed 512K, reserved 1048576K
끝
질문이 많을 것이다. Mark라는게 정확히 어떻게 일어나는지, Sweep이 정확히 어떻게 일어나는지. 위 예제의 Eden, tenure, metaspace등이 무엇을 의미하는지 궁금할 것이다. 이 내용들은 한 포스트에 작성하기 힘들고 또 JVM내부 구현에 대해 설명해야 하기 때문에 초보자를 위한 포스팅에는 적합하지 않다. 이후 가비지 컬렉터(Garbage Collector)에 대해 더 심도있게 다루는 포스팅을 작성하도록 하겠다.