[Java] JVM 청소부 GC: GC를 어떤 관점으로 봐야하는가, GC 알고리즘

 

JVM 에서의 메모리 관리

JVM 안에는 모두 공유되는 영역인 Heap 부터 각각의 메서드의 작업별로 할당되는 Stack 까지 다양한 영역이 존재한다. 프로그램을 실행하면 메서드를 실행하며 새로운 데이터들이 메모리에 추가될 가능성이 높다. Java로 구성된 프로그램을 작동시켜 놓으면 메모리가 계속 증가하게 되는데, 이런 문제점에 해답을 주는 것이 Garbage Collection, GC이다.

 

GC는 JVM 상에서 더 이상 사용되지 않는 데이터가 할당되어 있는 메모리를 해제시켜 주는 장치이다. JVM에서 자동으로 동작하기 때문에 Java는 특별한 경우가 아니면 메모리 관리를 개발자가 직접 해줄 필요가 없다. GC 가 주로 동작하는 대상은 Heap 영역 내의 객체 중에 참조되지 않은 데이터이다.

public class Main {
	public static void main(String[] args) {
		Phone phone = new Phone("13 mini"); // 곧 참조되지 않음
		phone = new Phone("15"); // 참조가 유지됨
	}
}
  • reachability : 참조되고 있는지에 대한 개념
  • reachable : 유효한 참조
  • unreachable : 유효하지 않은 참조

 

처음에 13 mini라는 폰이 생성되고 phone은 13 mini를 바라보고 있다. 이때는 garbage가 존재하지 않는다.

15를 바라보게 되는 시점부터는 13 mini는 더 이상 참조되지 않기 때문에, GC는 unreachable 한 객체들을 garbage라고 판단하고 메모리 공간을 회수하게 된다.

 


 

기본 개념 : 반드시 알아야 하는 키워드

1. Mark, Sweep, Compact

https://inpa.tistory.com/entry/JAVA- ☕-가비지-컬렉션GC-동작-원리-알고리즘-💯-총정리#mark_and_sweep

 

Mark

더 이상 어떤 오브젝트가 계속해서 참조를 가지고 있는지 아닌지 체크해 놓는 것이다. 해당 객체가 가비지 컬렉션의 대상이 되는 참조가 없는 레퍼런스인지 확인하고, 참조가 없다면 사라져도 되는 인스턴스로 체크한다.

마크가 된다 : 한 번 쓰고 버려서 곧바로 가비지 컬렉션 대상이 된다.

 

Sweep

메모리에서 오브젝트들이 시퀀스 하게 혹은 띄엄띄엄 배정됐을 수도 있지만 가급적이면 가까이 배정되는 것이 좋다. 메모리 공간 내에 오브젝트가 중간중간 비워지게 되면, 비워진 크기보다 큰 메모리 공간을 필요로 하는 오브젝트가 새로 생성되었을 때 메모리를 할당할 수 없는 경우가 생깁니다. 이를 파편화라고 한다. 파편화를 방지하려면, 공간을 컴팩트하게 한 쪽으로 밀고, 할당 요청에 충분한 연속적인 메모리 공간을 만들어야 한다. 필요 없는 오브젝트를 실제로 메모리 공간(Heap)에서 날리는 것이다. Mark 단계에서 사용되지 않는 것을 체크한 후, 식별된 메모리를 해제하는 작업이라 할 수 있다.

 

Compact

파편화를 방지하는 작업이다. Sweep 후에 분산된 객체들을 Heap의 시작 주소로 모아 메모리가 할당된 부분과 그렇지 않은 부분으로 압축한다. 가비지 컬렉터 종류에 따라 하지 않는 경우도 있다.

 


 

2. Young Generation (Eden, S0, S1)과 Old Generation

 

JVM의 메모리 구조와 Heap 영역

https://velog.io/@agugu95/자바와-JVM-그리고-메모리-구조

 

Young Generation

 

  1. 1개의 Eden 영역과 2개의 Survivor 영역, 총 3가지로 나뉜다.
    • Eden 영역
      • 새로 생성된 객체가 할당(Allocation)되는 영역
    • Survivor 영역 :
      • 최소 1번의 GC 이상 살아남은 객체가 존재하는 영역
      • 영역이 두 개가 존재하는데 Eden 영역이 꽉 차면 Survivor 영역으로 온다.
      • 영역 1, 2의 우선순위가 존재하는 것은 아니다.
  2. 동작 과정

https://d2.naver.com/helloworld/1329

  • 새로 생성한 객체는 Eden 영역에 생성된다.
  • Eden 영역이 꽉 차면, 개발자가 보는 에러가 아닌 GC 상으로 메모리를 더 이상 할당할 수 없는 에러가 발생한다.
  • 이때 Eden → Survivor 영역 1 또는 2에서 한쪽으로 물을 부으면서 모래를 걸러내는 것처럼 Minor GC 가 일어난다.
  • Survivor 영역 1 또는 2 중 하나의 영역이 꽉 차면, 공간이 남아있는 다른 Survivor 영역으로 이동하면서 Minor GC 가 발생한다.
✔️ Survivor 영역 중 하나는 반드시 비어 있는 상태로 남아 있어야 한다. 만약 두 Survivor 영역에 모두 데이터가 존재하거나, 두 영역 모두 사용량이 0이라면 해당 시스템은 정상적인 상황이 아니라고 생각하면 된다.
  • Survivor 영역의 객체 중에 여러 번의 Minor GC 과정에서 살아남은 객체들은 오랫동안 살아남아야 하는 객체로 판단하고, Old 영역으로 넘긴다.

 

Old Generation

 

Survivor 영역의 객체 중에 여러 번의 Minor GC 과정에서 살아남은 객체들은 오랫동안 살아남아야 하는 객체로 판단하고, Old 영역으로 넘어온다. Old 영역은 Young 영역보다 크게 할당하며, GC 도 발생 빈도가 적다. Old 영역에서 발생하는 GC를 Major GC 또는 Full GC라고 부른다.

 

해당 링크 는 Oracle 사의 Java 8 에서 관찰 자료이다. 대부분의 객체가 빠르게 소멸하는 것을 알 수 있다. 짧은 생명주기를 가지는 객체가 많기 때문에, 최종적으로 오래 살아남는 객체만 Old 영역에서 인스턴스가 관리되도록 Young, Old 영역을 나눈 것이다.

 


3. Minor GC, Full GC

https://d2.naver.com/helloworld/1329

 

  • Minor GC
    • Young 영역에서 일어나는 GC
  • Full GC
    • Old 영역과 Young 영역이 같이 GC를 하는 대규모 청소 작업.
    • 그런데 이 청소 작업이 굉장히 중요하다. 여러 알고리즘이 있다.

 

Stop-The-World

JVM은 GC를 통해 JVM 에서의 여유 메모리를 확보할 수 있다. 이 때문에 GC 를 자주 실행시키는 것이 여유 메모리를 최대한 확보하여 성능이 좋아지리라 추측할 수 있다. 하지만 GC 가 일어나면 GC 를 담당하는 스레드를 제외한 모든 스레드들은 작동이 일시적으로 정지하게 되는 Stop-The-World 현상이 발생하게 된다. 스레드가 모두 멈추게 되면 애플리케이션 또한 멈추게 되기에 Stop-The-World 발생시간을 최소화시켜야 한다.

 

어떠한 GC 알고리즘을 사용해도 STW 상태를 피할 수 없으나, GC 튜닝을 통해 발생 시간을 최소화할 순 있다. 그렇다면 어떠한 관점으로 GC 알고리즘을 살펴봐야 할까? 아래에서 살펴보자.

 

-추가. Java에서 개발자가 직접 System.gc() 코드로 GC를 실행할 수 있다. 하지만 사용하지 않는 것이 좋다. 해당 링크 에서 이에 대한 이유를 확인할 수 있다.

 

Throughput과 Latency, Footprint

Serial GC, Parallel GC, CMS GC, G1 GC, Z GC, Shenandoah GC 등 많은 Full GC 알고리즘이 있다. 해당 링크 에서 GC를 아래 세 가지 관점으로 봐야 한다고 설명하고 있다.

1. Throughput

애플리케이션이 처리할 수 있는 처리량을 의미한다.

 

CPU나 PC 서버 역량이 100으로 할 때, 애플리케이션 리소스 100을 다 사용하면 애플리케이션 Throughput이 좋은 것이라 할 수 있다. 그러나 애플리케이션이 동작하면서 Mark, Sweep 그리고 Compact 과정에서 GC 일부를 계속 수행할 수 있다. 별도의 스레드를 조금 가지고 시스템의 리소스 일부를 GC에 계속 사용하는 것이다. 당연히 이 과정에서 Throughput이 그만큼 줄어들게 된다.

 

즉, Thoughput 측면에서 GC 알고리즘을 다음 측면으로 볼 수 있다. “애플리케이션의 Throughput을 얼마나 잡아먹는지, 내 서버의 리소스를 얼마나 사용하는지 등 각각의 GC 알고리즘이 Throughput에 얼마만큼의 영향을 주는가?”

 

2. Latency 💫

애플리케이션 서버가 돌고 있는데 수백만~수천 명의 요청이 들어왔다고 가정해 보자. 3초 동안 아무도 응답을 못 받는다면, Full GC 가 발생한 것이다. 특히 Compact 과정에서, GC 관련 스레드를 제외하고 애플리케이션이 멈추는 Stop-The-World 현상이 발생한다. 멈추는 시간이 길어질수록 더 많은 시스템 장애가 발생할 수 있다.

 

그렇다면 어떻게 해야 Latency, Full GC 타임을 줄일 수 있을까? 과거에는 메모리를 많이 쓸수록 Full GC 가 오래 걸렸지만, 지금은 GC 기술이 좋아져서 그렇지는 않다. 따라서 GC 알고리즘을 볼 때, Latency를 봐야한다. Serial GC, Parallel GC 는 Latency 관점에서 그렇게 좋은 알고리즘은 아니다. 메모리를 많이 쓰는 JVM 으로 Parallel GC 를 사용하면 STW를 2~3초 넘게 경험하는 경우도 생길 수 있다. 반면에 Z GC 는 Full GC 타이밍, Latency 측면에서 매우 좋다. CMS GC 이후는 거의 대부분 Latency 측면에서 특화되어있다고 보면 된다.

 

3. Footprint

어떤 한 알고리즘이 쓸 때, 얼마만큼의 메모리를 추가적으로 사용하는가

 

 

세 가지 측면 중, 가장 중요한 것은 Latency 라 생각한다. 서버에서 가장 중요한 것은 응답시간이고, Stop-The-World 발생은 응답시간에 많은 영향을 미칠 수 있다. 아무리 많은 메모리를 쓰더라도 가장 짧은 Latency 를 보장하는 GC라면, Throughput을 조금 더 많이 쓰더라도 Latency를 선택할 것 같다. (→ 메모리 공간을 버리고, 빠른 응답 속도를 택한다.)

 

Throughput 을 늘리고 싶다면 서버를 늘릴 수 있지만, Latency 가 해결되지 않으면 서버를 늘려도 각각의 서버마다 Stop-The-World 때문에 2~3초간의 중단이 발생한다면 시스템 장애로 이어질 수 있기 때문에, Latency 측면에서 유리한 GC를 선택할 것이다.

 

 


 

GC 알고리즘

JVM GC는 알고리즘에 관계없이 메커니즘은 동일하다. JVM heap 메모리의 사용가능한 모든 오브젝트를 트래킹 하고 참조되지 않는 것들은 폐기한다. 그리고 각 GC의 종류에 따라 Stop-The-World로 인해 성능에 크게 영향을 준다. 이를 극복하기 위해, 다양한 GC 알고리즘이 지속적으로 개발되어 왔다.

 

✔️ Java 버전 별 Default GC

  • Java 5~8 : Parallel GC
  • Java 9+ : G1 GC (Java 7 release, 현재까지도 default GC)

 

Serial GC

가장 간단한 GC 구현체로 서버의 CPU 코어가 1개일 때 사용하기 위해 개발되었으며, GC 처리를 위해 1개의 스레드만을 이용한다. GC 가 실행할 때 모든 애플리케이션 스레드를 정지시킨다. 멀티스레드 프로그램에서는 좋지 않은 방법으로, 애플리케이션을 일시정지해도 상관없을 때 사용한다.

Young 영역은 Mark Sweep 알고리즘을, Old 영역에서는 Mark Sweep Compact 알고리즘이 사용된다.

Parallel GC

Throughput GC라고도 부른다. Serial GC와 기본적인 알고리즘은 동일하고, 차이점으로는 GC 처리를 위한 스레드가 여러 개다. Serial GC와 달리 힙 공간 관리를 위해 멀티 스레드를 사용해서 Stop-The-World 시간을 최소화한다. 메모리가 충분하고 코어의 개수가 많을 때 유리하다.

다음 그림은 Serial GC와 Parallel GC의 스레드를 비교한 그림이다.

 

Serial GC와 Parallel GC의 차이 (이미지 출처: "Java Performance", p. 86)

 

CMS GC

Concurrent Mark Sweep의 약자이다.

Serial GC와 CMS GC( 이미지 출처 )

 

 

CMS GC의 동작방식 은 다음과 같다.

  1. 초기 Initial Mark 단계에서는 클래스 로더에서 가장 가까운 객체 중 살아있는 객체만 찾는 것으로 끝내기 때문에, 멈추는 시간이 매우 짧다.
  2. Concurrent Mark 단계에서는 방금 살아있다고 확인한 객체에서 참조하고 있는 객체들만 따라가면서 확인한다. 이 단계에서는 다른 스레드가 실행 중인 상태에서 동시에 진행된다.
  3. Remark 단계에서는 Concurrent Mark 단계에서 새로 추가되거나 참조가 끊긴 객체를 확인한다.
  4. Concurrent Sweep 단계에서는 쓰레기를 정리하는 작업을 실행한다. 이 단계에서도 다른 스레드가 실행 중인 상태에서 동시에 진행한다.

 

이러한 동작방식을 가지기 때문에 stop-the-world 시간이 매우 짧기에 Low Latency를 가지는 장점을 가진다. 그러나 다른 GC 방식보다 메모리와 CPU를 더 많이 사용하고, Compaction 단계가 기본적으로 제공되지 않는다. 그렇기 때문에 조각난 메모리가 많아 Compaction 작업을 실행하면 다른 GC 방식보다 stop-the-world 시간이 길기 때문에 Compaction 작업이 얼마나 자주, 오랫동안 수행되는지 확인해야 한다. 따라서, CMS GC 사용에는 신중한 검토가 필요하다.

 

정리하면, Mark와 Sweep 과정에서 동시에 애플리케이션을 진행할 수 있기에, throughput을 적게 쓰는 대신 latency를 굉장히 줄일 수 있는 방식이다. 결국 이러한 CMS를 발전시켜 나온 게 G1 GC와 ZGC이다. CMS는 개념적으로만 공부해도 충분할 것 같다.

 

G1 GC

Garbage First GC란 의미를 가진다. 장기적으로 문제가 많은 CMS GC를 대체하기 위해 만들어졌다. 가장 큰 장점은 성능으로, 그 어떠한 GC 방식보다 빠르다.

 

G1 GC의 레이아웃(이미지 출처: "The Garbage-First Garbage Collector" (TS-5419), JavaOne 2008, p. 19)

 

기존까지의 GC는 Young의 세 가지 영역에서 데이터가 Old 영역으로 이동하는 방식이었다. 그러나 G1 GC는 해당 객체를 더 효율적이라고 생각하는 위치로 즉시 재할당(Reallocate) 시킨다. 즉, Young 영역에서 순서가 보장되지 않는다.

 

한 번에 더 많은 객체를 이동하고 처리하기 때문에, 작은 단위로 GC를 수행하므로 힙 메모리가 클수록 GC 비용을 더 분산시킬 수 있다. 따라서 Heap Memory가 4GB 이상인 환경에서 권장된다.

 

JDK 6에서 early access라고 부르며 시험 삼아 사용할 수만 있도록 했고, JDK 7에서 정식으로 G1 GC를 포함하여 제공하였으며, JDK 9부터 현재까지 default로 채택된 GC이다.

 

Z GC

ZGC는 Z Garbage Collector의 약자로 Serial Gc와 Parallel Old GC, Parallel GC, CMS GC, G1 GC를 지나 새로운 세대로 등장한 Java GC이다. JDK 11에 실험적 기능으로 추가되었고, JDK 15에서 정식으로 GC로 인정된 후 JDK 17에도 반영되었다.

 

ZGC는 G1 GC 이전 GC의 문제점인 Major GC 시 STW 문제를 또다시 겪지 않고자 STW 상태를 10ms 아래로 가져가는 것이다. 해당 링크 에서 ZGC에 대한 방식을 읽어보면, 어떻게 풀어냈는지 알 수 있다. 다만 STW가 짧은 것이 항상 최선의 선택지는 아니기에, 서비스 환경을 고려하여 GC를 선택해야 한다.

 

 

 


기본 개념 참고자료
가비지 컬렉터 비교 참고자료