Garbage Collection은 Java의 메모리 관리 기법으로, Heap영역에 동적으로 할당된 메모리들 중 사용되지 않는 인스턴스를 자동으로 식별하여 해제하는 작업이다.
C/C++과는 달리 Java는 Garbage Collector가 메모리를 알아서 관리해주기 때문에 개발자가 동적으로 할당된 메모리 전체를 관리할 필요가 없다.
따라서 해제된 메모리에 접근하거나, 이미 해제된 메모리를 다시 해제하는 등의 버그나 불필요한 작업을 해소할 수 있고, Garbage Collector가 자동으로 사용되지 않는 인스턴스에 할당된 메모리를 해제하기 때문에 메모리의 누수를 막을 수 있다.
하지만 Garbage Collection이 수행되는 정확한 시점을 알 수 없고, GC작업은 순수 오버헤드이기 때문에 성능저하의 원인이 될 수 있다.
Garbage Collection이 수행될 때에는 다른 모든 쓰레드가 멈추고 GC를 수행하는 쓰레드만 동작하기 때문에(Stop-the-World) 실시간 통신이 필요한 어플리케이션의의 경우 GC를 위해 수 초간 쓰레드가 멈춘다면 장애로 이어질 수 있다. 또한 웹 어플리케이션의 경우에도 GC가 일어나는 동안 대기하는 수많은 요청으로 인해 시스템의 작동에 영향을 줄 수 있다.
따라서 시스템의 안정성을 위해서는 GC의 동작과정과 원리를 잘 이해하고, 서비스의 특징과 서비스가 돌아가는 환경에 적합한 Garbage Collector를 선택하는 것이 중요하다.
Java에서 인스턴스를 생성하면 Heap영역에 메모리 공간을 할당하고 레퍼런스를 통해 참조하는데, 이때 Heap영역의 인스턴스를 참조하는 레퍼런스가 저장되는 공간을 묶어 Root Space라고 한다.
Root Space는 구체적으로 각 쓰레드의 Stack, Native Method Stack과 Method Area의 Constant Pool로 나눌 수 있다.
JVM Heap영역의 인스턴스들은 Reachable과 Unreachable 두 종류로 나눌 수 있다.
즉, Garbage Collector는 Heap영역의 인스턴스 중 Root Space로부터 참조가 불가능한 인스턴스들을 Unreachable로 분류하여 Garbage Collection을 진행한다
Heap영역의 인스턴스 중 Reachable/Unreachable한 인스턴스를 명시적으로 지정하기 위해서는 Reference Type을 이용한다.
Object obj = new Object();
SoftReference<Object> softReference = new SoftReference<>(new Object());
WeakReference<Object> weakReference = new WeakReference<>(new Object());
JVM의 Garbage Collector는 GC가 필요하다고 판단되는 시점에
- 작업중인 쓰레드를 멈추고 (Stop The World)
- Garbage를 선정하여 정리 (Mark and Sweep)
의 순서로 Garbage Collection을 수행한다.
Garbage Collector가 Heap영역의 인스턴스를 구분하여 Garbage를 선정하는 방법은 Reference Counting과 Mark and Sweep 두 가지 방식이 있다.
JVM의 Garbage Collector는 Mark and Sweep알고리즘을 사용한다.
Heap영역의 인스턴스들이 각자 자신에게 접근 가능한 레퍼런스의 개수(Reference Counter)를 세어, Reference Counter가 0이 되면 Unreachable한 상태로 판단하고 Garbage로 분류하는 방법이다.
Reference Conting방식은 간단하면서 효율적인 알고리즘이기 때문에 python, C#, Swift등의 언어에서 널리 사용되지만 가장 큰 단점으로 순환참조를 식별할 수 없는 문제점이 있다.
위의 그림에서 각 인스턴스들은 Reference의 개수를 저장하고 있어 Reference Counter가 0인 주황색 인스턴스들은 GC에 의해 관리된다.
하지만 빨간색 테두리로 표시한 인스턴스의 경우 서로를 참조하고 있어 Reference Counter가 1로 유지되므로 GC의 대상으로 분류되지 않지만, Root Space로부터 이 인스턴스에 접근할 방법이 없어 메모리 누수가 발생한다.
따라서 Reference Counting방식은 별도의 알고리즘을 통해 순환참조 문제를 해결해야 한다는 단점이 있다.
이러한 문제점을 해결하기 위해 Java는 Mark and Sweep 알고리즘을 통해 Garbage를 선정한다.
Root Space에서 시작하여 그래프 순회를 통해 연결된 인스턴스들을 찾아내고(Mark) Root로부터 연결되지 않은 인스턴스들을 정리하고(Sweep), 이후 선택적으로 메모리 단편화를 막기 위해 사용중인 메모리를 한 곳으로 모으는(Compact) 작업이다. (Compact과정은 필수가 아님)
Mark and Sweep알고리즘은 어떤 경우라도 Garbage를 모두 찾아낼 수 있고, 순환참조되는 인스턴스까지 정리가 가능하다는 장점이 있다.
하지만 Mark and Sweep알고리즘은 프로그램 후반부에 GC가 동작할 경우 메모리를 검색하는데 많은 비용이 소모되고, 멀티스레드 상황에서 동기화를 보장하기 어려워지기 때문에 GC가 수행될 때마다 프로그램이 멈추는 상황이 발생한다.
Mark and Sweep에서 프로그램이 멈추는 문제를 해결하기 위해 도입한 방법이 바로 앞서 설명한 Stop The World이다.
또한 Reference Counter가 0이 되면 자동으로 GC가 수행되는 Reterence Counting방식과 달리 Mark and Sweep방식은 의도적으로 GC를 실행시켜주어야 하는데, JVM의 Garbage Collector는 GC가 동작하는 시점을 Heap의 구조를 통해 효율적으로 구현하였다.
- 대부분의 객체는 금방 Unreachable상태가 된다
- 오래된 객체에서 젊은 객체로의 참조는 아주 적게 존재한다
JVM에서 GC가 발생하는 시점을 알아보기 전에, JVM의 Garbage Collector는 위의 두 가지 전제조건을 가지고 만들어졌다. 이 가설의 장점을 최대한 살리기 위해서 JVM은 Heap영역을 크게 2가지 공간(Young Generation, Old Generation)으로 나누어 효율적인 Garbage Collection을 구현하였다.
GC가 발생하는 시점을 알아보기 위해서는 먼저 Heap의 구조를 알아야 한다.
JVM의 Heap 구조에 대해서는 JVM Heap에 정리 해 두었다.
JVM의 Heap 영역은 크게 Young Generation과 Old Generation으로 나뉘는데, GC가 발생하는 영역에 따라 Minor GC, Major GC, Full GC로 나눌 수 있다.
Full GC는 Heap의 모든 영역을 탐색하여 Garbage를 찾아 정리하기 때문에 시간이 많이 소요된다. 따라서 JVM은 Full GC가 발생하는 횟수를 줄이기 위해 Heap영역을 나누어 Minor GC와 Major GC를 통해 Garbage를 관리한다.
Minor GC와 Major GC가 발생하는 시점을 알아보기 위해 인스턴스가 생성되고 GC에 의해 소멸되기까지의 모든 과정을 Heap영역 내에서의 인스턴스 이동에 초점을 맞추어 설명한다.
Heap의 Young Generation영역은 다시 Eden, Servivor 0, Servivor 1영역으로 나누어진다.
- Eden : 새롭게 생성된 객체들이 할당되는 영역
- Servivor : Minor GC로부터 살아남은 객체들에게 할당되는 영역
- Servivor0, 1영역중 하나는 항상 비어있어야 한다
먼저, 새로운 객체가 생성되면 가장 먼저 Heap의 Eden영역에 할당된다.
이후 새로운 객체들이 계속 생성되다가 Eden영역이 가득 차게되면 Minor GC가 발생한다.
Heap의 Eden영역이 가득 차게 되면 Eden영역에 있는 인스턴스에 대해 Mark and Sweep이 발생하고, JVM은 살아남은 객체들의 Age-bit를 1씩 증가시키고 Servivor 0영역으로 이동시킨다.
이후 다시 Eden영역이나 Servivor 0 영역이 가득 차게 되면 Eden영역과 Servivor 0영역에 있는 인스턴스에 대해 Minor GC가 수행되고, 살아남은 객체들은 Age-bit를 1씩 증가시키면서 Servivor 1영역으로 이동한다.
이 과정을 반복하다가 Age-bit가 임계값에 다다르면 Promotion이 발생한다
Minor GC에서 두 Servivor영역 사이를 반복해서 이동하는 이유는 Mark and Sweep 알고리즘이 진행되면서 메모리가 단편화되는데, 단편화된 메모리는 접근하기 불편해지므로 이를 피하기 위해 한 곳에 몰아넣어 관리하기 위한 것이다.
참고로 Minor GC 가 Stop and Copy알고리즘으로 불리는 이유는 GC가 발생할 때 Stop-the-World상태에서 Eden에서 Servivor로, 혹은 Servivor 사이에서 객체를 이동시킬 때 살아남은 객체를 복사하고 기존 영역을 모두 비우는 방식으로 동작하기 때문이다.
Garbage Collector는 Servivor영역에 있는 객체 중 Age-bit가 임계값에 다다른 객체를 오래 생존할 것으로 판단하여 Old영역으로 이동시킨다. 이 작업을 Promotion이라 한다.
시간이 지나 Old영역까지 가득 차게 되면 Old영역에 있는 객체에 대해 Mark and Sweep 알고리즘이 수행된다.
Old Generation에 있는 객체가 Young Generation에 있는 객체를 참조하는 경우
Minor GC에서 Mark and Sweep은 Root Space로부터 접근이 가능한 객체를 마킹하고 마킹되지 않은 객체는 정리한다. 이때, Root Space에 포함되는 공간은 Stack, Native Method Stack, Method Area이다.
그런데 만약 (아주 적은 경우이지만) Young Generation에 있는 객체가 Root Space로부터의 연결이 끊어졌지만 Old Generation에 있는 객체가 참조하고 있는 경우라면 어떨까?
기존 방식의 경우 Heap의 Old Generation은 Root Space에 포함되지 않기 때문에 Young Generation의 객체는 Garbage로 분류될 것이다.
하지만 JVM에서는 이러한 경우를 해결하기 위해 Old Generation에 Card Table이라는 특수한 공간을 두어 Old Generation에서 Young Generation을 참조하는 객체의 정보를 담고, Minor GC가 수행될 때 Root Space뿐만 아니라 Card Table로부터 접근이 가능한 객체또한 마킹하여 Garbage로 처리되지 않게 한다.
Old Generation영역에서 발생하는 Major GC는 Minor GC보다 더 많은 시간이 소요되기 때문에 구현 방식에 따라 어플리케이션의 성능이 달라진다. JDK7을 기준으로 GC는 총 5가지 방식이 있다.
- Initial Mark : Root에서 참조하는 객체들만 식별(STW)
- Concurrent Mark : 이전 단계에서 식별한 객체들이 참조하는 모든 객체를 추적(Concurrent)
- ReMark : 이전 단계에서 식별한 객체들을 다시 추적하여 새로 추가되거나 참조가 끊긴 객체를 확인, Garbage를 확정(STW)
- Concurrent Sweep : 이전 단계에서 확정된 Garbage를 정리(Concurrent)