JAVA

[Java] 가비지 컬렉션 GC(Garbage Collection)의 동작원리와 동작하는 시점

로춘남 2022. 3. 29. 08:44
728x90


Garbage Collection(가비지 컬렉션)이란?

메모리 관리 기법 중 하나로 프로그램이 동적으로 할당했던 메모리 영역 중에서 필요없게 된 영역을 해제하는 기능. 즉, 동적 할당된 메모리 영역 가운데 어떤 변수도 가리키지 않는 메모리 영역을 탐지하여 자동으로 해제를 하는 기법이다.

  • 장점 : GC를 이용하게 되면 개발자가 동적으로 할당한 메모리 영역 전체를 완벽하게 관리하지 않아도 된다. GC를 통해 유효하지 않은 포인터 접근이나 메모리 누수 같은 버그를 줄이거나 막을 수 있다.
    •  유효하지 않은 포인터 접근 : 이미 동적 할당한 메모리를 해제한 영역에 접근하게 되는 버그
    • 이중 해제 : 이미 해제된 메모리를 또 다시 해제하는 오류를 줄일 수 있음.
    • 메모리 누수 : 더 이상 사용하지 않는 메모리 영역을 해제하지 않고 남겨진 것이 쌓이게 되면 메모리 누수가 일어난다는 것. 이러한 메모리 누수가 지속되면 메모리 고갈로 인하여 프로그램이 중단 될 수 있다
  • 단점 : 어떤 메모리를 해제해야 할 지 결정하는데 사용되는 알고리즘에 의해 비용이 든다. 객체가 필요없어지는 시점을 개발자가 알고 있는 경우에도 GC 알고리즘이 메모리 해제 시점을 추적해야하기에 비용이 들게된다. 또한 GC가 행동하는 타이밍이나 GC의 점유 시간을 사전에 예측하기 어렵기에 실시간 시스템에는 적합하지 않다. 할당된 메모리가 해제되는 시점을 알 수 없게 된다.

GC의 위험성

실시간 시스템에서 Garbage Collection이 사용된다면 치명적인 오류를 발생 할 수 있다. 특히나 군사목적의 프로그래밍(미사일 등) 혹은 비행시스템 등에서 실시간으로 목표물 지점으로 날아가고 있는 중간에 GC가 발생하여 동작하게 되면 알고리즘 동작이 멈출 수 잇는 가능성 때문에 실시간 시스템에서는 GC는 지양해야 한다.


개발하다보면 유효하지 않은 메모리 가비지(Garbage)가 발생하게 되는데, C언어에서는 보통 free()라는 함수를 통해 직접 메모리를 해제해야한다고한다. 하지만 Java를 이용하다보면 개발자가 직접 해제해주는 일이 없는데, JVM의 가비지 컬렉터가 불필요한 메모리를 알아서 정리해주기 때문. 대신 Java에서 명시적으로 불필요한 데이터를 표현하기 위해 일반적으로 null을 선언해준다.

 

ex)
Person person = new Person();
person.setName("Roma");
person = null;

// 가비지 발생
person = new Person();
person.setName("RomaMan");

기존의 Roma으로 생성된 person 객체는 더이상 참조를 하지 않고 사용이 되지 않아서 Garbage(가비지)가 되었다. Java나 Kotlin에서는 이러한 메모리 누수를 방지하기 위해 가비지 컬렉터(Garbage Collector, GC)가 주기적으로 검사하여 메모리를 청소해준다. (물론 Java에서도 System.gc()를 이용해 호출할 수 있지만, 해당 메소드를 호출하는 것은 시스템의 성능에 매우 큰 영향을 미치므로 절대 호출해서는 안된다고한다.)

 

Minor GC와 Major GC

JVM의 Heap영역은 처음 설계될 때 다음의 2가지를 전제(Weak Generational Hypothesis)로 설계되었다.

  • 대부분의 객체는 금방 접근 불가능한 상태(Unreachable)가 된다.
  • 오래된 객체에서 새로운 객체로의 참조는 아주 적게 존재한다.

즉, 객체는 대부분 일회성되며, 메모리에 오랫동안 남아있는 경우는 드물다는 것이다. 그렇기 때문에 객체의 생존 기간에 따라 물리적인 Heap 영역을 나누게 되었는데, 이에 따라 Young, Old 총 2가지 영역으로 설계되었다. (초기에는 Perm 영역이 존재하였지만 Java8부터 제거되었다.)

GC의 동작원리

 

GC 영역 및 흐름

  •  Young 영역(Young Generation)
    • 새롭게 생성된 객체가 할당(Allocation)되는 영역
    • 대부분의 객체가 금방 Unreachable 상태가 되기 때문에, 많은 객체가 Young 영역에 생성되었다가 사라진다.
    • Young 영역에 대한 가비지 컬렉션(Garbage Collection)을 Minor GC라고 부른다.
  • Old 영역(Old Generation)
    • Young영역에서 Reachable 상태를 유지하여 살아남은 객체가 복사되는 영역
    • 복사되는 과정에서 대부분 Young 영역보다 크게 할당되며, 크기가 큰 만큼 가비지는 적게 발생한다.
    • Old 영역에 대한 가비지 컬렉션(Garbage Collection)을 Major GC 또는 Full GC라고 부른다.

예외적인 상황으로 Old 영역에 있는 객체가 Young 영역의 객체를 참조하는 경우도 존재할 것이다. 이러한 경우를 대비하여 Old 영역에는 512 bytes의 덩어리(Chunk)로 되어 있는 카드 테이블(Card Table)이 존재한다.

 

카드 테이블에는 Old 영역에 있는 객체가 Young 영역의 객체를 참조할 때 마다 그에 대한 정보가 표시된다. 카드 테이블이 도입된 이유는 간단한다. Young 영역에서 가비지 컬렉션(Minor GC)가 실행될 때 모든 Old 영역에 존재하는 객체를 검사하여 참조되지 않는 Young 영역의 객체를 식별하는 것이 비효율적이기 때문이다. 그렇기 때문에 Young 영역에서 가비지 컬렉션이 진행될 때 카드 테이블만 조회하여 GC의 대상인지 식별할 수 있도록 하고 있다.

 

Garbage Collection(가비지 컬렉션)의 동작 방식 

Young 영역과 Old 영역은 서로 다른 메모리 구조로 되어 있기 때문에, 세부적인 동작 방식은 다르다. 하지만 기본적으로 가비지 컬렉션이 실행된다고 하면 다음의 2가지 공통적인 단계를 따르게 된다.

1. Stop The World(STW)

Stop The World는 가비지 컬렉션을 실행하기 위해 JVM이 애플리케이션의 실행을 멈추는 작업이다. GC가 실행될 때는 GC를 실행하는 쓰레드를 제외한 모든 쓰레드들의 작업이 중단되고, GC가 완료되면 작업이 재개된다. 당연히 모든 쓰레드들의 작업이 중단되면 애플리케이션이 멈추기 때문에, GC의 성능 개선을 위해 튜닝을 한다고 하면 보통 stop-the-world의 시간을 줄이는 작업을 하는 것이다. 또한 JVM에서도 이러한 문제를 해결하기 위해 다양한 실행 옵션을 제공하고 있다.

2. Mark and Sweep

  • Mark: 사용되는 메모리와 사용되지 않는 메모리를 식별하는 작업
  • Sweep: Mark 단계에서 사용되지 않음으로 식별된 메모리를 해제하는 작업

Stop The World를 통해 모든 작업을 중단시키면, GC는 스택의 모든 변수 또는 Reachable 객체를 스캔하면서 각각이 어떤 객체를 참고하고 있는지를 탐색하게 된다. 그리고 사용되고 있는 메모리를 식별하는데, 이러한 과정을 Mark라고 한다. 이후에 Mark가 되지 않은 객체들을 메모리에서 제거하는데, 이러한 과정을 Sweep라고 한다.

[Minor GC의 동작 방식]

Minor GC를 정확히 이해하기 위해서는 Young 영역의 구조에 대해 이해를 해야 한다. Young 영역은 1개의 Eden 영역과 2개의 Survivor 영역, 총 3가지로 나뉘어진다.

  • Eden 영역: 새로 생성된 객체가 할당(Allocation)되는 영역
  • Survivor 영역: 최소 1번의 GC 이상 살아남은 객체가 존재하는 영역

객체가 새롭게 생성되면 Young 영역 중에서도 Eden 영역에 할당(Allocation)이 된다. 그리고 Eden 영역이 꽉 차면 Minor GC가 발생하게 되는데, 사용되지 않는 메모리는 해제되고 Eden 영역에 존재하는 객체는 (사용중인) Survivor 영역으로 옮겨지게 된다. Survivor 영역은 총 2개이지만 반드시 1개의 영역에만 데이터가 존재해야 하는데, Young 영역의 동작 순서를 자세히 살펴보도록 하자.

  1. 새로 생성된 객체가 Eden 영역에 할당된다.
  2. 객체가 계속 생성되어 Eden 영역이 꽉차게 되고 Minor GC가 실행된다.
    1. Eden 영역에서 사용되지 않는 객체의 메모리가 해제된다.
    2. Eden 영역에서 살아남은 객체는 1개의 Survivor 영역으로 이동된다.
  3. 1~2번의 과정이 반복되다가 Survivor 영역이 가득 차게 되면 Survivor 영역의 살아남은 객체를 다른 Survivor 영역으로 이동시킨다.(1개의 Survivor 영역은 반드시 빈 상태가 된다.)
  4. 이러한 과정을 반복하여 계속해서 살아남은 객체는 Old 영역으로 이동(Promotion)된다.

객체의 생존 횟수를 카운트하기 위해 Minor GC에서 객체가 살아남은 횟수를 의미하는 age를 Object Header에 기록한다. 그리고 Minor GC 때 Object Header에 기록된 age를 보고 Promotion 여부를 결정한다. 또한 Survivor 영역 중 1개는 반드시 사용이 되어야 한다. 만약 두 Survivor 영역에 모두 데이터가 존재하거나, 모두 사용량이 0이라면 현재 시스템이 정상적인 상황이 아님을 파악할 수 있다.

 

이러한 진행 과정을 그림으로 살펴보면 다음과 같다.

 

HotSpot JVM에서는 Eden 영역에 객체를 빠르게 할당(Allocation)하기 위해 bump the pointer와 TLABs(Thread-Local Allocation Buffers)라는 기술을 사용하고 있다. bump the pointer란 Eden 영역에 마지막으로 할당된 객체의 주소를 캐싱해두는 것이다.

bump the pointer를 통해 새로운 객체를 위해 유효한 메모리를 탐색할 필요 없이 마지막 주소의 다음 주소를 사용하게 함으로써 속도를 높이고 있다. 이를 통해 새로운 객체를 할당할 때 객체의 크기가 Eden 영역에 적합한지만 판별하면 되므로 빠르게 메모리 할당을 할 수 있다.

싱글 쓰레드 환경이라면 문제가 없겠지만 멀티쓰레드 환경이라면 객체를 Eden 영역에 할당할 때 락(Lock)을 걸어 동기화를 해주어야 한다. 멀티 쓰레드 환경에서의 성능 문제를 해결하기 위해 HotSpot JVM은 추가로 TLABs(Thread-Local Allocation Buffers)라는 기술을 도입하게 되었다.

TLABs(Thread-Local Allocation Buffers) 각각의 쓰레드마다 Eden 영역에 객체를 할당하기 위한 주소를 부여함으로써 동기화 작업 없이 빠르게 메모리를 할당하도록 하는 기술이다. 각각의 쓰레드는 자신이 갖는 주소에만 객체를 할당함으로써 동기화 없이 bump the poitner를 통해 빠르게 객체를 할당하도록 하고 있다.

 

[ Major GC의 동작 방식 ]

Young 영역에서 오래 살아남은 객체는 Old 영역으로 Promotion됨을 확인할 수 있었다. 그리고 Major GC는 객체들이 계속 Promotion되어 Old 영역의 메모리가 부족해지면 발생하게 된다. Young 영역은 일반적으로 Old 영역보다 크키가 작기 때문에 GC가 보통 0.5초에서 1초 사이에 끝난다. 그렇기 때문에 Minor GC는 애플리케이션에 크게 영향을 주지 않는다. 하지만 Old 영역은 Young 영역보다 크며 Young 영역을 참조할 수도 있다. 그렇기 때문에 Major GC는 일반적으로 Minor GC보다 시간이 오래걸리며, 10배 이상의 시간을 사용한다.

3. Garbage Collection(가비지 컬렉션) 내용 요약


도대체 GC는 언제 발생할까?

자바 기반의 시스템을 개발하면서 쓰레기 객체 처리(Garbage Collection, 이하 GC)가 어떻게 수행되는지 잘 모르고 개발하는 개발자들이 더러 있다. 물론 이 부분에 대해서 반드시 암기하고 숙지해야 자바 개발을 할 수 있는 것은 아니다. 그러나 유닉스나 리눅스 서버든 윈도 서버든 풀(Full) GC를 수행하는 시점에는 해당 JVM에서 처리되지 않는다는 단점이 있다. 다시 이야기하면 GC를 많이 하면 할수록 응답 시간에 많은 영향을 끼친다는 것이다. 그러므로, 자신이 만든 자바 프로그램의 성능을 생각하는 자바 엔지니어라면, GC가 어떻게 처리되는지 기본 지식은 갖고 있는 것이 좋다.

GC란?

무엇보다도 자바에서 메모리 관리를 누가 해야 하는지에 대해서 생각해 보자. 자바는 누가하고 C는 누가 해야 하는가? C를 개발해 봤으면 알겠지만, C는 명시적으로 메모리를 건드리고 참조할 수 있다. 자바를 개발하면서 메모리 관리에 대해서 생각해 본 적이 있는가? 아마도 없을 것이다. 자바에서는 메모리를 GC라는 알고리즘을 통하여 관리하기 땜누에, 개발자가 메모리를 처리하기 위한 로직을 만들 필요가 없고, 절대로 만들어서는 안 된다.

Garbage Collection은 말 그대로 쓰레기를 정리하는 작업이다. 자바 프로그래밍을 할 때 쓰레기란 어떤 것일까? 자바에서 쓰레기는 객체이다. 하나의 객체는 메모리를 점유하고, 필요하지 않으면 메모리에서 해제되어야 한다. 메모리 점유는 다음과 같이 쉽게 할 수 잇다. 이러한 코드에서는 a라는 객체가 만들어져 메모리의 한 부분을 점유하게 된다.

String a = new String();

그럼 다음의 코드를 보자.

public String mekeQuery(String code) {
    String queryPre = "Select * from table_a where a = '";
    String queryPost = "' order by c ";
    return queryPre = queryPre + code + queryPost;
}

이 코드에서 makeQuery() 메서드를 호출한 후 수행이 완료되면 queryPre 객체와 queryPost 객체는 더 이상 필요가 없는 객체, 즉 쓰레기가 된다. 이 쓰레기 객체를 효과적으로 처리하는 작업을 GC라고 한다.

자바의 Runtime data area는 이렇게 구성된다

자바의 GC에 대해서 살펴보기 전에 먼저 자바에서 데이터를 처리하기 위한 영역에는 어떤 것들이 있는지 살펴보자.

  • PC 레지스터
  • JVM 스택
  • 힙(Heap)
  • 메서드 영역
  • 런타임 상수(constant) 풀
  • 네이티브 메서드 스택

이 영역에서 GC가 발생하는 부분이 바로 힙 영역이다. 거꾸로 말하면, 나머지 영역은 GC 대상이 아니라는 것이다. 이 영역들을 그림으로 나타내면 아래 그림과 같다.

여기서 상단에 있는 '클래스 로더 서브 시스템'은 클래스나 인터페이스를 JVM으로 로딩하는 기능을 수행하고, '실행 엔진'은 로딩된 클래스의 메서드에 포함되어 있는 모든 인스트럭션 정보를 실행한다. 이 그림을 보면 좀 복잡해 보이지만, 단순하게 이야기해서 자바의 메모리 영역은 'Heap 메모리'와 'Non-heap 메모리'로 나뉜다.

Heap 메모리

클래스 인스턴스, 배열이 이 메모리에 쌓인다. 이 메모리는 '공유(shared) 메모리'라고도 불리우며 여러 스레드에서 공유하는 데이터들이 저장되는 메모리이다.

Non-Heap 메모리

이 메모리는 자바의 내부 처리를 위해서 필요한 영역이다. 여기서 주된 영역이 바로 메서드 영역이다.

  • 메서드 영역 : 메서드 영역은 모든 JVM 스레드에서 공유된다. 이 영역에 저장되는 데이터들은 다음과 같다.
    - 런타임 상수 풀 : 자바의 클래스 파일에는 constant_pool이라는 정보가 포함된다. 이 constant_pool에 대한 정보를 실행 시에 참조하기 위한 영역이다. 실제 상수 값도 여기에 포함될 수 있지만, 실행 시에 변하게 되는 필드 참조 정보도 포함된다.
    - 필드 정보에는 메서드 데이터, 메서드와 생성자 코드가 있다.
  • JVM 스택 : 스레드가 시작할 때 JVM 스택이 생성된다. 이 스택에는 메모리가 호출되는 정보인 프레임(frame)이 저장된다. 그리고, 지역 변수와 임시 결과, 메서드 수행과 리턴에 관련된 정보들도 포함된다.
  • 네이티브 메서드 스택 : 자바 코드가 아닌 다른 언어로 된(보통은 C로 된) 코드들이 실행하게 될 때의 스택 정보를 관리한다.
  • PC 레지스터 : 자바의 스레드들은 각자의 pc(Program Counter) 레지스터를 갖는다. 네이티브한 코드를 제외한 모든 자바 코드들이 수행될 때 JVM의 인스터럭션 주소를 pc 레지스터에 보관한다.

※ 스택의 크기는 고정적이거나 가변적일 수 있다. 만약 연산을 하다가 JVM의 스택 크기의 최대치를 넘었을 경우에는 StackOverFlowError가 발생되고, 가변적인 경우 스택의 크기를 늘이려고 할 때 메모리가 부족하거나, 스레드를 생성할 때 메모리가 부족한 경우에는 OutOfMemoryError가 발생한다.

 

여기서 Heap 영역과 메서드 영역은 JVM이 시작될 때 생성된다. 지금까지 설명한 내용들을 그림으로 나타내면 다음과 같다.

GC의 원리

GC 작업을 하는 가비지 콜렉터(Garbage Collector)는 다음의 역할을 한다.

  • 메모리 할당
  • 사용 중인 메모리 인식
  • 사용하지 않는 메모리 인식

사용하지 않는 메모리를 인식하는 작업을 수행하지 않으면, 할당된 메모리 영역이 꽉 차서 JVM에 행(Hang)이 걸리거나, 더 많은 메모리를 할당하려는 현상이 발생할 것이다. 만약 JVM의 최대 메모리 크기를 지정해서 전부 사용한 다음, GC를 해도 더 이상 사용 가능한 메모리 영역이 없는데 계속 메모리를 할당하려고 하면 OutofMemoryError가 발생하여 JVM이 다운될 수도 있다. 행(Hang)이란 서버가 요청을 처리 못하고 있는 상태를 의미한다.

 

JVM의 메모리는 앞에서 설명한 여러 영역으로 나뉘는데, GC와 연관된 부분은 힙이다. 따라서 가비지 콜렉터가 인식하고 할당하는 자바의 힙 영역에 대해서 상세히 알아보자.

위 그림을 보면 크게 Young, Old, Perm 세 영역으로 나뉜다. 이 중 Perm(Permanent) 영역은 없는 걸로 치자. 이 영역은 거의 사용되지 않는 영역으로 클래스와 메서드 정보와 같이 자바 언어 레벨에서 사용하는 영역이 아니기 때문이다.

게다가 JDK 8부터는 이 영역이 사라진다. Virtual이라고 쓰여 있는 부분 또한 가상 영역이므로 고려하지 말자. 이 두 영역을 제외하면 Young 영역과 Old 영역 일부가 남는다. Young 영역은 다시 Eden 영역 및 두 개의 Survivor 영역으로 나뉘므로 우리가 고려해야 할 자바의 멤뢰 영역은 총 4개 영역으로 나뉜다고 볼 수 있다.

Young 영역 Young 영역 Young 영역 Old 영역
Eden Survivor 1 Survivor 2 메모리 영역

Perm 영역에는 클래스와 메서드 정보 외에도 intern된 String 정보도 포함하고 있다. String 클래스에는 intern()이라는 메소드가 존재한다. 이 메소드를 호출하면 해당 문자열의 값을 바탕으로 한 단순 비교가 가능하다.

즉, 참조 자료형은 equals() 메소드로 비교를 해야하지만, inter() 메소드가 호출된 문자열들은 == 비교가 가능해진다. 따라서, 값 비교 성능은 빨라지지만, 문자열 정보들이 Perm 영역에 들어가기 때문에 Perm 영역의 GC가 발생하는 원인이 되기도한다. 물론 이 현상은 JDK 8부터는 발생하지 않는다. 일단 메모리에 객체가 생성되면, 아래 그림의 가장 왼쪽인 Eden 영역에 객체가 지정된다.

Eden 영역에 객체가 꽉 차면, 이 영역에 있던 객체가 어디론가 옮겨지거나 삭제 되어야 한다.

이 때 옮겨 가는 위치가 Survivor 영역이다. 두 개의 Survivor 영역 사이에 우선 순위가 있는 것은 아니다. 이 두 개의 영역 중 한 영역은 반드시 비어 있어야 한다. 그 비어 있는 영역에 Eden 영역에 있던 객체 중 GC 후에 살아 남는 객체들이 이동한다.

이와 같이 Eden 영역에 있던 객체는 Survivor 영역의 둘 중 하나에 할당된다. 할당된 Survivor 영역이 차면, GC가 되면서 Eden 영역에 있는 객체와 꽉 찬 Survivor 영역에 있는 객체가 비어 있는 Survivor 영역으로 이동한다.

이러한 작업을 반복하면서, Survivor 1과 2를 왔다 갔다 하던 객체들은 Old 영역으로 이동한다.

그리고, Young 영역에서 Old 영역으로 넘어가는 객체 중 Survivor 영역을 거치지 않고 바로 Old 영역으로 이동하는 객체가 있을 수 있다. 객체의 크기가 아주 큰 경우인데, 예를 들어 Survivor 영역의 크기가 16MB인데 20MB를 점유하는 객체가 Eden 영역에서 생성되면 Survivor 영역으로 옮겨갈 수가 없다. 이런 객체들은 바로 Old 영역으로 이동하게 된다.

GC의 종류

GC는 크게 두 가지 타입으로 나뉜다. 마이너 GC와 메이저 GC의 두 가지 GC가 발생할 수 있다.

  • 마이너 GC : Young 영역에서 발생하는 GC
  • 메이저 GC : Old 영역이나 Perm 영역에서 발생하는 GC

이 두 가지 GC가 어떻게 상호작용하느냐에 따라서 GC 방식에 차이가 나며, 성능에도 영향을 준다.

GC가 발생하거나 객체가 각 영역에서 다른 영역으로 이동할 때 애플리케이션의 병목이 발생하면서 성능에 영향을 주게 된다. 그래서 핫 스팟(Hot Spot) JVM에서는 스레드 로컬 할당 버퍼(TLABs :Thread-Local Allocation Buffers)라는 것을 사용한다. 이를 통하여 각 스레드별 메모리 버퍼를 사용하면 다른 스레드에 영향을 주지 않는 메모리 할당 작업이 가능해진다.

5가지 GC 방식

JDK 7 이상에서 지원하는 GC 방식에는 다섯 가지가 있다.

  • Serial Collector(이하 시리얼 콜렉터)
  • Parallel Collector(이하 병렬 콜렉터)
  • Parallel Compacting Colletor(이하 병렬 콤팩팅 콜렉터)
  • Concurrent Mark-Sweep (CMS) Collector(이하 CMS 콜렉터)
  • Garbage First Collector(이하 G1 콜렉터)

여기 명시된 다섯 가지의 GC 방식은 WAS나 자바 애플리케이션 수행 시 옵션을 지정하여 선택할 수 있다. 그런데, G1 콜렉터는 JDK 7부터 정식으로 사용할 수 있다.

시리얼 콜렉터

Young 영역와 Old 영역이 시리얼하게(연속적으로) 처리되며 하나의 CPU를 사용한다. Sun에서는 이 처리를 수행할 때를 Stop-the-world(STW)라고 표현한다. 다시 말하면 콜렉션이 수행될 때 애플리케이션 수행이 정지된다.

그림의 내용은 다음과 같이 해석할 수 있다.

1) 일단 살아 있는 객체들은 Eden 영역에 있다(각각의 둥근 사각형이 객체 하나라고 보면 된다).
2) Eden 영역이 꽉차게 되면 To Survivor 영역(비어 있는 영역)으로 살아 있는 객체가 이동한다. 이때 Survivor 영역에 들어가기에 너무 큰 객체들은 바로 Old 영역으로 이동한다. 그리고 From Survivor 영역에 있는 살아 있는 객체는 To Survivor 영역으로 이동한다.
3) To Survivor 영역이 꽉 찼을 경우, Eden 영역이나 From Survivor 영역에 남아 있는 객체들은 Old 영역으로 이동한다.

이동한 결과는 다음과 같다.

이후에 Old 영역이나 Perm 영역에 있는 객체들은 Mark-sweep-compact 콜렉션 알고리즘을 따른다. 이 알고리즘에 대해서 간단하게 말하면, 쓰이지 않는 객체를 표시해서 삭제하고 한 곳으로 모으는 알고리즘이다. Mark-sweep-compact 콜렉션 알고리즘은 다음과 같이 수행된다.

1) Old 영역으로 이동된 객체들 중 살아 있는 객체를 식별한다(표시 단계).
2) Old 영역의 객체들을 훑는 작업을 수행하여 쓰레기 객체를 식별한다(스윕 단계).
3) 필요 없는 객체들을 지우고 살아있는 객체들을 한 곳으로 모은다(컴팩션 단계).

Mark-sweep-compact 단계를 거친 Old 영역은 다음과 같은 상태가 된다.

이렇게 작동하는 시리얼 콜렉터는 일반적으로 클라이언트 종류의 장비에서 많이 사용된다. 다시 말하면, 대기 시간이 많아도 크게 문제되지 않는 시스템에서 사용한다는 의미이다. 시리얼 콜렉터를 명시적으로 지정하려면 자바 명령 옵션에 -XX:+UseSerialGC를 지정하면 된다.

병렬 콜렉터

이 방식은 스루풋 콜렉터(throughput collector)로도 알려진 방식이다. 이 방식의 목표는 다른 CPU가 대기 상태로 남아 있는 것을 최소화하는 것이다. 시리얼 콜렉터와 달리 Young 영역에서의 콜렉션을 병렬(parallel)로 처리한다. 많은 CPU를 사용하기 때문에 GC의 부하를 줄이고 애플리케이션의 처리량을 증가시킬 수 있다.

Old 영역의 GC는 시리얼 콜렉터와 마찬가지로 Mark-sweep-compact 콜렉션 알고리즘을 사용한다. 이 방식으로 GC를 하도록 명시적으로 지정하려면 -XX:+UseParallelGC 옵션을 자바 명령 옵션에 추가하면 된다.

병렬 콤팩팅 콜렉터

이 방식은 JDK 5.0 업데이트 6부터 사용 가능하다. 병렬 콜렉터와 다른 점은 Old 영역 GC에서 새로운 알고리즘을 사용한다는 것이다. 그러므로 Young 영역에 대한 GC는 병렬 콜렉터와 동일하지만, Old 영역의 GC는 다음의 3단계를 거친다.

  • 표시 단계 : 살아 있는 객체를 식별하여 표시해 놓는 단계
  • 종합 단계 : 이전에 GC를 수행하여 컴팩션된 영역에 살아 있는 객체의 위치를 조사하는 단계
  • 컴팩션 단계 : 컴팩션을 수행하는 단계. 수행 이후에는 컴팩션된 영역과 비어 있는 영역으로 나뉜다.

병렬 콜렉터와 동일하게 이 방식도 여러 CPU를 사용하는 서버에 적합하다. GC를 사용하는 스레드 개수는 -XX:ParallelGCThreads=n 옵션으로 조정할 수 있다. 이 방식을 사용하려면 -XX:UseParallelOldGC 옵션을 자바 명령 옵션에 추가하면 된다.

 

※ 시리얼 콜렉터와 병렬 콜렉터의 Old 영역의 방식과 병렬 콤팩팅 콜렉터의 Old 영역의 방식은 어떠한 점이 다를까? 두 방식의 가장 큰 다른점은 두번째 단계이다. 스위(Sweep) 단계와 종합(Summary) 단계의 차이라고 볼 수 있는데, 스윕 단계에서는 단일 스레드가 Old 영역 전체를 훑는다. 종합 단계는 여러 스레드가 OId 영역을 분리하여 훑는다. 게다가 앞서 진행된 GC에서 컴팩션된 영역을 별도로 훑는다는 점도 다르다.

CMS 콜렉터

이 방식은 로우 레이턴시 콜렉터(low-latency collector)로도 알려져 있으며, 힙 메모리 영역의 크기가 클 때 적합하다. Young 영역에 대한 GC는 병렬 콜렉터와 동일하다.

Old 영역의 GC는 다음 단계를 거친다.

  • 초기 표시 단계 : 매우 짧은 대기 시간으로 살아 있는 객체를 찾는 단계.
  • 컨커런트 표시 단계 : 서버 수행과 동시에 살아 있는 객체에 표시를 해 놓는 단계.
  • 재표시(remark) 단계 : 컨커런트 표시 단계에서 표시하는 동안 변경된 객체에 대해서 다시 표시하는 단계.
  • 컨커런트 스윕 단계 : 표시되어 있는 쓰레기를 정리하는 단계.

CMS는 컴팩션 단계를 거치지 않기 때문에 왼쪽으로 메모리를 몰아 놓은 작업을 수행하지 않는다. 그래서 GC이후에 빈 공간이 발생하므로, XX:CMSInitiatingOccupancyFraction=n 옵션을 사용하여 Old 영역의 %를 n 값에 지정한다. 여기서 n 값의 기본값은 68이다.

CMS 콜렉터 방식은 2개 이상의 프로세스를 사용하는 서버에 적당하다. 가장 적당한 대상으로는 웹 서버가 있다. -XX:+UseConcMarkSweepGC 옵션으로 이 GC 방식을 지정할 수 있다.

CMS 콜렉터는 추가적인 옵션으로 점진적 방식을 지원한다. 이 방식은 Young 영역의 GC를 더 잘게 쪼개어 서버의 대기 시간을 줄일 수 있다. CPU가 많지 않고 시스템의 대기 시간이 짧아야 할 때 사용하면 좋다. 점진적인 GC를 수행하려면 -XX:+CMSIncrementalMode 옵션을 지정하면 된다. JVM에 따라서는 -Xincgc라는 옵션을 지정해도 같은 의미가 된다. 하지만 이 옵션을 지정할 경우 예기치 못한 성능 저하가 발생할 수 있으므로, 충분한 테스트를 한 후에 운영 서버에 적용해야 한다.

G1 콜렉터

지금까지 설명한 모든 Garbage Collector는 Eden과 Survivor 영역으로 나뉘는 Young 영역과 Old 영역으로 구성되어 있다. 하지만, Garbage First (이하 G1)는 지금까지의 Garbage Collector와는 다른 영역으로 구성되어 있다.

먼저 G1 콜렉터가 어떻게 구성되어 있는지 보자.

G1은 위의 그림과 같이 되어 있다. 여기서 각 바둑판의 사각형을 region이라고 하는데, Young 영역이나 Old 영역이라는 단어와 구분하기 위해서 한국말로 "구역"이라고 하자. 이 구역의 기본 크기는 1MB이며 최대 32MB까지 지정 가능하다. 그림에서 보듯이 G1은 Young 영역과 Old 영역이 물리적으로 나뉘어 있지 않고, 각 구역의 크기는 모두 동일하다. 앞서 살펴본 콜렉터들은 모두 Young과 Old 영역의 주소가 물리적으로 Linear하게 나열되지만, G1은 그렇지 않다. 여기서 구역의 개수는 약 2000개 정도라고 한다.

이 바둑판 모양의 구역이 각각 Eden, survivor, Old 영역의 역할을 변경해 가면서 하고, Humongous라는 영역도 포함된다.

 

G1이 Young GC를 어떻게 하는지 살펴보면 다음과 같다.

1) 몇 개의 구역을 선정하여 Young 영역으로 지정한다.
2) 이 Linear하지 않은 구역에 객체가 생성되면서 데이터가 쌓인다.
3) Young 영역으로 할당된 구역에 데이터가 꽉 차면, GC를 수행한다.
4) GC를 수행하면서 살아있는 객체들만 Survivor 구역으로 이동한다.

이렇게 살아 남은 객체들이 이동된 구역은 새로운 Survivor 영역이 된다. 그 다음에 Young GC가 발생하면 Survivor 영역에 계속 쌓인다. 그러면서, 몇 번의 aging 작업을 통해서(Survivor 영역에 있는 객체가 몇 번의 Old GC 후에도 살아 있는 한), Old 영역으로 승격된다.

G1의 Old 영역 GC는 CMS GC의 방식과 비슷하며 아래 여섯 단계로 나뉜다. 여기서 STW라고 표시된 단계에서는 모두 Stop the world가 발생한다.

  • 초기 표시 단계(Initial Mark) 단계(STW) : Old 영역에 있는 객체에서 Survivor 영역의 객체를 참조하고 있는 객체들을 표시한다.
  • 기본 구역 스캔(Root Region scanning) 단계 : Old 영역 참조를 위해서 Survivor 영역을 훑는다. 참고로 이 작업은 Young GC가 발생하기 전에 수행된다.
  • 컨커런트 표시 단계 : 전체 힙 영역에 살아있는 객체를 찾는다. 만약 이 때 Young GC가 발생하면 잠시 멈춘다.
  • 재표시(Remark) 단계(STW) : 힙에 살아있는 객체들의 표시 작업을 완료한다. 이 때 snapshot-at-the-begging (SATB)라는 알고리즘을 사용하며, 이는 CMS GC에서 사용하는 방식보다 빠르다.
  • 청소(Cleaning) 단계(STW) : 살아있는 객체와 비어 있는 구역을 식별하고, 필요없는 객체들을 지운다. 그리고 나서 비어 있는 구역을 초기화한다.
  • 복사 단계(STW) : 살아 있는 객체들을 비어 있는 구역으로 모은다.

G1은 CMS GC의 단점을 보완하기 위해 만들어졌으며 GC 성능도 매우 빠르다. 하지만 안정화 기능이 필요하기 때문에 G1이 빠르다고 무조건 이 콜렉터를 선택하는 것은 시스템의 장애로 연결될 수 있다.

강제로 GC 시키기

물론 강제로 GC를 발생시킬 수도 있다. System.gc() 메서드나 Runtime.getRuntime().gc() 메스드를 쓰면 된다. 하지만 코드에 사용하면 안되고, 특히 웹 기반의 시스템에서는 절대로 사용하지 말 것을 권장한다. GC를 강제로 하면 안 되는 이유를 알아보자.

<%
long mainTime = System.nanoTime();
for(int outLoop=0; outLoop<10; outLoop++) {
    String aValue = "abcdefghijklmnopqrstuvwxyz"
    for(int loop=0; loop<10; loop++) {
        aValue += aValue;
    }
    System.gc();
}
double mainTimeElapsed = (System.nanoTime() - mainTime) / 1000000.000;
out.println("<BR><B>"+mainTimeElapsed+"</B><BR><BR>");
%>

중간에 보면 System.gc() 메서드를 수행해 강제로 GC를 하도록 코딩하였다. 수행을 해 보면 결과가 어떨까?

구분 응답 시간
System.gc() 메서드 포함 750ms ~ 850ms
System.gc() 메서드 미포함 0.13ms ~ 0.16ms

약 5,000배 이상의 성능 차이가 발생한다. 이것은 하나의 웹 화면의 응답 속도를 비교했을 경우임을 감안하자. GC 방식이 무엇이든 관계없이 GC를 수행하는 동안 다른 애플리케이션의 성능에 영향을 미친다는 점은 변함이 없으므로 만약 실제 운영 중인 시스템에 이 코드가 있으면 실제 시스템의 응답 속도에 미치는 영향이 엄청나게 커질 것이

 

다양한 Garbage Collection(가비지 컬렉션) 알고리즘

JVM이 메모리를 자동으로 관리해주는 것은 개발자의 입장에서 상당한 메리트이다. 하지만 문제는 GC를 수행하기 위해 Stop The World(STW) 에 의해 애플리케이션이 중지되는 것에 있다. Heap의 사이즈가 커지면서 애플리케이션의 지연(Suspend) 현상이 두드러지게 되었고, 이를 막기 위해 다양한 Garbage Collection(가비지 컬렉션) 알고리즘을 지원하고 있다. 

Serial GC

Serial GC의 Young 영역은 앞서 설명한 알고리즘(Mark Sweep)대로 수행된다. 하지만 Old 영역에서는 Mark Sweep Compact 알고리즘이 사용되는데, 기존의 Mark Sweep에 Compact라는 작업이 추가되었다. Compact는 Heap 영역을 정리하기 위한 단계로 유요한 객체들이 연속되게 쌓이도록 힙의 가장 앞 부분부터 채워서 객체가 존재하는 부분과 객체가 존재하지 않는 부분으로 나누는 것이다.

java -XX:+UseSerialGC -jar Application.java

Serial GC는 서버의 CPU 코어가 1개일 때 사용하기 위해 개발되었으며, 모든 가비지 컬렉션 일을 처리하기 위해 1개의 쓰레드만을 이용한다. 그렇기 때문에 CPU의 코어가 여러 개인 운영 서버에서 Serial GC를 사용하는 것은 반드시 피해야 한다.

Parallel GC

Parallel GC는 Throughput GC로도 알려져 있으며, 기본적인 처리 과정은 Serial GC와 동일하다. 하지만 Parallel GC는 여러 개의 쓰레드를 통해 Parallel하게 GC를 수행함으로써 GC의 오버헤드를 상당히 줄여준다. Parallel GC는 멀티 프로세서 또는 멀티 쓰레드 머신에서 중간 규모부터 대규모의 데이터를 처리하는 애플리케이션을 위해 고안되었으며, 옵션을 통해 애플리케이션의 최대 지연 시간 또는 GC를 수행할 쓰레드의 갯수 등을 설정해줄 수 있다.

java -XX:+UseParallelGC -jar Application.java

// 사용할 쓰레드의 갯수
-XX:ParallelGCThreads=<N>

// 최대 지연 시간
-XX:MaxGCPauseMillis=<N>

Parallel GC가 GC의 오버헤드를 상당히 줄여주었고, Java8까지 기본 가비지 컬렉터(Default Garbage Collector)로 사용되었다. 그럼에도 불구하고 Application이 멈추는 것은 피할 수 없었고, 이러한 부분을 개선하기 위해 다른 알고리즘이 더 등장하게 되었다.

CMS(Concurrent Mark Sweep) GC

CMS(Concurrent Mark Sweep) GC는 Parallel GC와 마찬가지로 여러 개의 쓰레드를 이용한다. 하지만 기존의 Serial GC나 Parallel GC와는 다르게 Mark Sweep 알고리즘을 Concurrent하게 수행하게 된다.

 

 

이러한 CMS GC는 애플리케이션의 지연 시간을 최소화 하기 위해 고안되었으며, 애플리케이션이 구동중일 때 프로세서의 자원을 공유하여 이용가능해야 한다. CMS GC가 수행될 때에는 자원이 GC를 위해서도 사용되므로 응답이 느려질 순 있지만 응답이 멈추지는 않게 된다.

하지만 이러한 CMS GC는 다른 GC 방식보다 메모리와 CPU를 더 많이 필요로 하며, Compaction 단계를 수행하지 않는다는 단점이 있다. 이 때문에 시스템이 장기적으로 운영되다가 조각난 메모리들이 많아 Compaction 단계가 수행되면 오히려 Stop The World 시간이 길어지는 문제가 발생할 수 있다.

// deprecated in java9 and finally dropped in java14
java -XX:+UseConcMarkSweepGC -jar Application.java

만약 GC가 수행되면서 98% 이상의 시간이 CMS GC에 소요되고, 2% 이하의 시간이 Heap의 정리에 사영된다면 CMS GC에 의해 OutOfMemoryError가 던져질 것이다. 물론 이를 disable 하는 옵션이 있지만, CMS GC는 Java9 버젼부터 deprecated 되었고 결국 Java14에서는 사용이 중지되었기 때문에 굳이 알아볼 필요는 없을 것 같다.

G1(Garbage First) GC

G1(Garbage First) GC는 장기적으로 많은 문제를 일으킬 수 있는 CMS GC를 대체하기 위해 개발되었고, Java7부터 지원되기 시작하였다.

기존의 GC 알고리즘에서는 Heap 영역을 물리적으로 Young 영역(Eden 영역과 2개의 Survivor 영역)과 Old 영역으로 나누어 사용하였다. G1 GC는 Eden 영역에 할당하고, Survivor로 카피하는 등의 과정을 사용하지만 물리적으로 메모리 공간을 나누지 않는다. 대신 Region(지역)이라는 개념을 새로 도입하여 Heap을 균등하게 여러 개의 지역으로 나누고, 각 지역을 역할과 함께 논리적으로 구분하여(Eden 지역인지, Survivor 지역인지, Old 지역인지) 객체를 할당한다.

G1 GC에서는 Eden, Survivor, Old 역할에 더해 Humongous와 Availabe/Unused라는 2가지 역할을 추가하였다. Humonguous는 Region 크기의 50%를 초과하는 객체를 저장하는 Region을 의미하며, Availabe/Unused는 사용되지 않은 Region을 의미한다. 

G1 GC의 핵심은 Heap을 동일한 크기의 Region으로 나누고, 가비지가 많은 Region에 대해 우선적으로 GC를 수행하는 것이다. 그리고 G1 GC도 다른 가비지 컬렉션과 마찬가지로 2가지 GC(Minor GC, Major GC)로 나누어 수행되는데, 각각에 대해 살펴보도록 하자.

 

1. Minor GC

한 지역에 객체를 할당하다가 해당 지역이 꽉 차면 다른 지역에 객체를 할당하고, Minor GC가 실행된다. G1 GC는 각 지역을 추적하고 있기 때문에, 가비지가 가장 많은(Garbage First) 지역을 찾아서 Mark and Sweep를 수행한다.

Eden 지역에서 GC가 수행되면 살아남은 객체를 식별(Mark)하고, 메모리를 회수(Sweep)한다. 그리고 살아남은 객체를 다른 지역으로 이동시키게 된다. 복제되는 지역이 Available/Unused 지역이면 해당 지역은 이제 Survivor 영역이 되고, Eden 영역은 Available/Unused 지역이 된다.

 

2. Major GC(Full GC)

시스템이 계속 운영되다가 객체가 너무 많아 빠르게 메모리를 회수 할 수 없을 때 Major GC(Full GC)가 실행된다. 그리고 여기서 G1 GC와 다른 GC의 차이점이 두각을 보인다.

기존의 다른 GC 알고리즘은 모든 Heap의 영역에서 GC가 수행되었으며, 그에 따라 처리 시간이 상당히 오래 걸렸다. 하지만 G1 GC는 어느 영역에 가비지가 많은지를 알고 있기 때문에 GC를 수행할 지역을 조합하여 해당 지역에 대해서만 GC를 수행한다. 그리고 이러한 작업은 Concurrent하게 수행되기 때문에 애플리케이션의 지연도 최소화할 수 있는 것이다.

 

물론 G1 GC는 다른 GC 방식에 비해 잦게 호출될 것이다. 하지만 작은 규모의 메모리 정리 작업이고 Concurrent하게 수행되기 때문이 지연이 크지 않으며, 가비지가 많은 지역에 대해 정리를 하므로 훨씬 효율적이다.

java -XX:+UseG1GC -jar Application.java

이러한 구조의 G1 GC는 당연히 앞의 어떠한 GC 방식보다 처리 속도가 빠르며 큰 메모리 공간에서 멀티 프로레스 기반으로 운영되는 애플리케이션을 위해 고안되었다. 또한 G1 GC는 다른 GC 방식의 처리속도를 능가하기 때문에 Java9부터 기본 가비지 컬렉터(Default Garbage Collector)로 사용되게 되었다.


참고 자료

728x90