본문 바로가기

Java/자바 성능 개선

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

💡 본 게시글은 이상민 저자의 '자바 성능 튜닝 이야기' 교재를 공부하고, 이에 대해 정리한 내용입니다.

들어가며

 자바 기반 시스템을 개발하면서 가비지 컬렉션(GC)이 어떻게 동작하는지 완벽히 이해하지 못하는 개발자들이 종종 있습니다. 이것이 반드시 자바 개발에 필요한 지식은 아니지만, GC의 동작 방식을 이해하는 것은 매우 중요합니다. 왜냐하면, 풀(Full) GC가 수행되는 동안에는 해당 JVM에서는 다른 처리를 하지 않습니다. 즉, GC가 자주 발생할수록 시스템의 응답 시간에 영향을 미칩니다. 이는 유닉스, 리눅스, 윈도우 서버 등 어떤 서버에서도 마찬가지입니다. 따라서, 자바 프로그램의 성능을 고려하는 개발자라면, GC가 어떻게 동작하는지에 대한 기본적인 이해를 가지고 있어야 합니다. 이는 자신이 개발한 프로그램의 성능 최적화에 도움이 될 것입니다.


1) GC(가비지 컬렉터)란?

 자바와 C언어에서 메모리 관리는 누가 담당할까요? C언어를 개발해본 경험이 있다면, C언어에서는 개발자가 직접 메모리를 관리하고 참조해야 한다는 것을 알 수 있습니다.

 

 반면에 자바에서는 개발자가 메모리 관리에 대해 고민할 필요가 거의 없습니다. 왜냐하면 자바에서는 메모리 관리를 GC(Garbage Collection)라는 알고리즘을 통해 처리하기 때문입니다. 따라서 개발자는 메모리 처리를 위한 로직을 만드는 대신, 프로그램의 로직에 집중할 수 있습니다.

 

그렇다면 Garbage Collection이란 무엇일까요? 이는 이름에서도 알 수 있듯이 '쓰레기를 정리하는 작업'을 의미합니다. 자바 프로그래밍에서 '쓰레기'는 더 이상 필요하지 않은 객체를 가리킵니다. 객체는 메모리를 점유하고 있으므로, 필요 없어지면 메모리에서 해제되어야 합니다.

 

String a = new String();

예를 들어, 코드를 실행하면 'a'라는 객체가 생성되어 메모리를 점유하게 됩니다.

 

다음의 코드를 살펴봅시다. 

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입니다.


2) 자바의 런타임 데이터 영역 (Runtime data area)는 이렇게 구성된다.

 자바의 GC에 대해서 알아보기 전에 먼저 자바에서 데이터를 처리하기 위한 런타임 데이터 영역에 대해 알아보겠습니다. 데이터 영역은 다음과 같이 구성됩니다:

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

 이 중에서 힙 영역에서만 가비지 컬렉션이 발생합니다. 그래서 나머지 영역들은 가비지 컬렉션의 대상이 아닙니다. 이 영역들을 그림으로 나타내면 아래 그림과 같습니다.

 

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

 

(1) Heap 메모리

  • 클래스 인스턴스와 배열이 저장되는 공간입니다. 이 메모리는 '공유 메모리'라고도 불리우며, 여러 스레드에서 공유하는 데이터가 저장됩니다.

 

(2)  Non-heap 메모리

  • 반면에 Non-heap 메모리는 자바의 내부 처리를 위한 영역입니다. 주로 메서드 영역이 이에 해당합니다.
    • 메서드 영역은 모든 JVM 스레드에서 공유하며, 런타임 상수 풀, 필드 정보 등이 저장됩니다.
      • 런타임 상수 풀 : 자바의 클래스 파일에는 constant_pool이라는 정보가 포함된다. 이 constant_pool에 대한 정보를 실행 시에 참조하기 위한 영역이다. 실제 상수 값도 여기에 포함될 수 있지만, 실행 시에 변하게 되는 필드 참조 정보도 포함된다.
      • 필드 정보에는 메서드 데이터, 메서드와 생성자 코드가 있다.
    • JVM 스택은 스레드가 시작될 때 생성되며, 메모리 호출 정보인 프레임, 지역 변수, 임시 결과, 메서드 수행과 리턴에 관련된 정보 등이 저장됩니다.
    • 네이티브 메서드 스택은 자바 코드가 아닌 다른 언어로 작성된 코드(대체로 C)가 실행될 때의 스택 정보를 관리합니다.
    • PC 레지스터는 각 스레드가 자신의 프로그램 카운터를 가지고 있으며, 네이티브 코드를 제외한 모든 자바 코드가 수행될 때 JVM의 명령 주소를 저장합니다. \
스택의 크기는 고정적이거나 가변적일 수 있습니다. 연산 중에 JVM 스택 크기를 초과하면 StackOverflowError가 발생하며, 가변적인 경우에는 스택 크기를 확장하려 할 때나 스레드를 생성할 때 메모리가 부족하면 OutOfMemoryError가 발생합니다.

 

여기서 Heap 영역과 메서드 영역은 JVM이 시작될 때 생성됩니다. 이 모든 내용을 그림으로 표현하면 다음과 같습니다.


3) GC의 원리 

 가비지 컬렉터(Garbage Collector)는 메모리 관리를 담당하는 중요한 역할을 합니다. 그 역할은 크게 다음과 같습니다:

  • 메모리 할당
  • 사용 중인 메모리 인식
  • 미사용 메모리 인식

 미사용 메모리를 인식하지 못하면, 메모리 할당 영역이 가득 차서 JVM이 멈추거나, 더 많은 메모리를 할당하려는 현상이 발생할 수 있습니다. 만약 JVM의 최대 메모리 크기를 지정하고 모두 사용한 후, 가비지 컬렉션을 실시해도 사용 가능한 메모리 영역이 없는 상태에서 계속 메모리를 할당하려고 하면, OutOfMemoryError가 발생하여 JVM이 다운될 수 있습니다.

 

 JVM의 메모리는 여러 영역으로 구분되며, 가비지 컬렉션과 관련된 부분은 힙 영역입니다. 그러므로 가비지 컬렉터가 인식하고 할당하는 자바의 힙 영역을 자세히 살펴보겠습니다.

 위 그림을 보면 힙 영역은 크게 Young, Old, Perm 세 영역으로 나뉩니다.

 

 그 중에서 Perm(Permanent) 영역은 거의 사용되지 않는 영역으로, 클래스와 메서드 정보 등이 저장되지만, 자바 언어 레벨에서 사용하는 영역은 아닙니다. 또한, JDK 8부터 이 영역은 사라집니다. 따라서 Young 영역과 Old 영역만을 고려하면 됩니다. Young 영역은 다시 Eden 영역 및 두 개의 Survivor 영역으로 나뉩니다. 그러므로 자바의 메모리 영역은 총 4개 영역으로 볼 수 있습니다.

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

 

Perm 영역에는 클래스와 메서드 정보 외에도 intern된 String 정보도 포함하고 있습니다. String 클래스에는 intern()이라는 메서드가 존재합니다. 이 메소드를 호출하면 해당 문자열의 값을 바탕으로 한 단순 비교가 가능합니다. 즉, 참조 자료형은 equals() 메서드로 비교를 해야 하지만, intern() 메서드가 호출된 문자열들은 == 비교가 가능해집니다. 따라서, 값 비교 성능은 빨라지지만, 문자열 정보들이 Perm 영역에 들어가기 때문에 Perm 영역의 GC가 발생하는 원인이 되기도 합니다. 물론 이 현상은 JDK 8부터는 발생하지 않습니다.

 

일단 객체가 처음 생성되면 Eden 영역에 할당됩니다.

Eden 영역이 꽉 차면, 여기에 있던 객체는 Survivor 영역으로 이동하거나 삭제됩니다.

두 Survivor 영역 중 하나는 항상 비어 있어야 하며, Eden 영역에서 가비지 컬렉션 후 살아남은 객체들이 이동하는 곳입니다.

그리고, Survivor 1과 2를 왔다 갔다 하던 객체들은 마침내 Old 영역으로 이동합니다.

또한, 객체의 크기가 매우 큰 경우에는 Survivor 영역을 거치지 않고 바로 Old 영역으로 이동할 수 있습니다. 예를 들어 Survivor 영역의 크기가 16MB인데 20MB를 점유하는 객체가 Eden 영역에서 생성되면 Survivor 영역으로 옮겨갈 수가 없습니다. 이런 객체들은 바로 Old 영역으로 이동하게 됩다.


4) GC의 종류

 가비지 컬렉션은 주로 두 가지 유형으로 분류됩니다: 마이너 GC와 메이저 GC입니다.

  • 마이너 GC : Young 영역에서 발생하는 가비지 컬렉션
  • 메이저 GC : Old 영역이나 Perm 영역에서 발생하는 가비지 컬렉션

 이 두 유형의 가비지 컬렉션은 서로 상호작용하며, 그 방식에 따라 가비지 컬렉션의 성능이 달라집니다. 가비지 컬렉션 발생 시나 객체가 한 영역에서 다른 영역으로 이동할 때, 애플리케이션의 성능에 병목 현상이 발생할 수 있습니다.

 

 이러한 문제를 해결하기 위해 핫 스팟(Hot Spot) JVM에서는 스레드 로컬 할당 버퍼(Thread-Local Allocation Buffers, TLABs)를 사용합니다. 이를 통해 각 스레드가 독립적인 메모리 버퍼를 사용하게 함으로써, 다른 스레드에 영향을 미치지 않는 메모리 할당 작업이 가능해집니다.


5) 5가지 GC 방식 

 다섯 가지의 GC 방식이 JDK 7부터 지원되며, 이는 WAS나 자바 애플리케이션 수행 시 옵션을 통해 선택할 수 있습니다.

 

1. 시리얼 콜렉터:

Young 영역과 Old 영역을 연속적으로 처리하며, 이 과정은 하나의 CPU에서 이루어집니다. 이 처리 과정이 진행될 때, 애플리케이션 수행이 일시적으로 중단되는데, 이를 'Stop-the-world'라고 부릅니다.

이 과정은 다음과 같이 이해할 수 있습니다:

  1. 먼저, 살아 있는 객체들은 Eden 영역에 위치합니다.
  2. Eden 영역이 가득 차게 되면, 살아 있는 객체들은 비어 있는 To Survivor 영역으로 이동합니다. 이 때, Survivor 영역에 들어가기에 너무 큰 객체들은 바로 Old 영역으로 이동하게 됩니다. 그리고 From Survivor 영역에 있는 살아 있는 객체들은 To Survivor 영역으로 옮겨집니다.
  3. To Survivor 영역이 가득 차면, Eden 영역이나 From Survivor 영역에 남아 있는 객체들은 Old 영역으로 이동하게 됩니다.

 이후에 Old 영역이나 Perm 영역에 있는 객체들은 Mark-sweep-compact 콜렉션 알고리즘을 따르게 됩니다. 이 알고리즘은 간단하게 설명하면, 사용되지 않는 객체를 표시하여 삭제하고, 살아 있는 객체들을 한 곳으로 모으는 방식입니다.

 

이 알고리즘은 다음과 같은 단계로 수행됩니다:

  1. Old 영역으로 이동된 객체들 중에서 살아 있는 객체들을 식별합니다(표시 단계).
  2. Old 영역의 객체들을 검사하여 쓰레기 객체들을 식별합니다(스윕 단계).
  3. 필요 없는 객체들을 삭제하고, 살아 있는 객체들을 한 곳으로 모읍니다(컴팩션 단계).

 시리얼 콜렉터는 주로 대기 시간이 많아도 크게 문제되지 않는 클라이언트 종류의 장비에서 사용됩니다. 시리얼 콜렉터를 사용하려면 자바 명령 옵션에 -XX:+UseSerialGC를 지정하면 됩니다.


2. 병렬 콜렉터

 병렬 콜렉터는 스루풋 콜렉터라고도 알려져 있으며, 이 방식의 주 목표는 다른 CPU가 대기 상태로 남는 것을 최소화하는 것입니다. 병렬 콜렉터는 시리얼 콜렉터와는 달리 Young 영역에서의 콜렉션을 병렬로 처리하며, 이로 인해 많은 CPU를 활용해 GC의 부하를 줄이고 애플리케이션의 처리량을 증가시킬 수 있습니다.

 

Old 영역에서의 GC는 시리얼 콜렉터와 같이 Mark-sweep-compact 콜렉션 알고리즘을 사용합니다. 병렬 콜렉터를 사용하려면 자바 명령 옵션에 -XX:+UseParallelGC를 추가하면 됩니다. 병렬 콤팩팅 콜렉터는 JDK 5.0 업데이트 6부터 사용 가능하며, 병렬 콜렉터와의 주된 차이점은 Old 영역에서 새로운 알고리즘을 사용한다는 것입니다.

 

따라서 Young 영역의 GC는 병렬 콜렉터와 같지만, Old 영역의 GC는 표시, 종합, 컴팩션의 세 단계를 거칩니다.

 이 방식은 여러 CPU를 사용하는 서버에 적합하며, GC를 사용하는 스레드의 개수는 -XX:ParallelGCThreads=n 옵션으로 조정할 수 있습니다. 병렬 콤팩팅 콜렉터를 사용하려면 -XX:UseParallelOldGC 옵션을 자바 명령 옵션에 추가하면 됩니다.

 

시리얼 콜렉터와 병렬 콜렉터의 Old 영역 처리 방식과 병렬 콤팩팅 콜렉터의 Old 영역 처리 방식의 차이점은 두 번째 단계에 있습니다. 즉, 스윕 단계와 종합 단계의 차이입니다.

  • 스윕 단계는 하나의 스레드가 Old 영역 전체를 검사합니다.
  • 종합 단계는 여러 스레드가 Old 영역을 분할하여 검사하며, 이전에 진행된 GC에서 컴팩션된 영역을 별도로 검사합니다.

3. CMS 콜렉터

 로우 레이턴시 콜렉터라고도 불리며, 큰 힙 메모리 영역에 적합합니다. Young 영역의 GC는 병렬 콜렉터와 동일하게 처리됩니다.

 

Old 영역의 GC는 다음과 같은 과정을 거칩니다:

1) 초기 표시 단계: 짧은 대기 시간을 통해 살아 있는 객체를 찾습니다.

2) 컨커런트 표시 단계: 서버 작업과 병행하여 살아 있는 객체를 표시합니다.

3) 재표시 단계: 컨커런트 표시 단계 동안 변한 객체를 다시 표시합니다.

4) 컨커런트 스윕 단계: 표시된 쓰레기를 청소합니다.

 

 CMS는 컴팩션 단계를 거치지 않아 메모리를 한 곳으로 몰지 않습니다. 따라서 GC 후에 빈 공간이 생기며, 이를 위해 XX:CMSInitiatingOccupancyFraction=n 옵션을 통해 Old 영역의 %를 n 값으로 지정합니다. 여기서 n 값의 기본값은 68입니다.

 CMS 콜렉터는 2개 이상의 프로세스를 사용하는 서버에 적합하며, 특히 웹 서버에 잘 맞습니다. 이 GC 방식을 사용하려면 -XX:+UseConcMarkSweepGC 옵션을 사용하면 됩니다.

 

 또한 CMS 콜렉터는 점진적 방식을 추가 옵션으로 지원합니다. 이는 Young 영역의 GC를 세분화하여 서버의 대기 시간을 줄일 수 있습니다. CPU가 많지 않고 시스템의 대기 시간이 짧아야 할 때 사용하면 좋습니다. 점진적 GC를 실행하려면 -XX:+CMSIncrementalMode 옵션을 지정하면 됩니다. JVM에 따라 -Xincgc 옵션을 사용해도 동일한 결과를 얻을 수 있습니다. 하지만 이 옵션을 사용할 경우 예측하지 못한 성능 저하가 발생할 수 있으므로, 충분한 테스트 후에 운영 서버에 적용하는 것이 좋습니다.


4. G1 콜렉터

 기존의 Garbage Collector들은 Young 영역(Eden과 Survivor 영역으로 나뉨)과 Old 영역으로 구성되어 있습니다. 그러나 Garbage First (G1) 콜렉터는 다른 영역 구성을 가지고 있습니다.

G1은 "구역"이라는 개념을 사용하며, 각 구역의 기본 크기는 1MB에서 최대 32MB까지 가능합니다. 각 구역은 동일한 크기를 가지며, 이는 Young 영역과 Old 영역이 물리적으로 분리되어 있지 않음을 의미합니다. 약 2000개의 구역이 있으며, 각 구역은 Eden, Survivor, Old 영역의 역할을 수행하거나, Humongous 영역으로도 사용됩니다.

 

G1이 Young GC를 수행하는 과정은 다음과 같습니다:

1) 일부 구역을 Young 영역으로 지정합니다.

2) 객체가 생성되면서 해당 구역에 데이터가 쌓입니다.

3) Young 영역으로 할당된 구역이 가득 차면, GC를 수행합니다.

4) GC를 수행하면서 살아있는 객체들만 Survivor 구역으로 이동합니다.

 

 이렇게 이동된 구역은 새로운 Survivor 영역이 됩니다. 다음 Young GC가 발생하면 Survivor 영역에 계속 쌓이게 되고, 객체가 여러 번의 GC를 거치며 Old 영역으로 승격됩니다.

 

 G1의 Old 영역 GC는 CMS GC와 비슷한 방식으로, 아래 여섯 단계로 진행됩니다. 이 때, STW(Stop the world)가 발생하는 단계도 있습니다.

 

1) 초기 표시 단계(Initial Mark, STW): Old 영역의 객체 중 Survivor 영역의 객체를 참조하는 객체들을 표시합니다.

2) 기본 구역 스캔(Root Region scanning) 단계: Survivor 영역을 훑어 Old 영역 참조를 확인합니다. 이 작업은 Young GC가 발생하기 전에 수행됩니다.

3) 컨커런트 표시 단계: 전체 힙 영역에서 살아있는 객체를 찾습니다. 만약 이 때 Young GC가 발생하면 잠시 멈춥니다.

4) 재표시(Remark, STW) 단계: 살아있는 객체들의 표시 작업을 완료합니다. 이 때 snapshot-at-the-begging (SATB) 알고리즘을 사용하며, 이는 CMS GC 방식보다 빠릅니다.

5) 청소(Cleaning, STW) 단계: 살아있는 객체와 비어 있는 구역을 식별하고, 필요 없는 객체들을 제거합니다. 그 후 비어 있는 구역을 초기화합니다.

6) 복사 단계(STW): 살아 있는 객체들을 비어 있는 구역으로 모읍니다.

 

G1은 CMS GC의 단점을 보완하고, GC 성능도 매우 빠르게 향상시키기 위해 만들어졌습니다. 그러나 안정화 기능이 필요하기 때문에 G1이 빠르다고 해서 무조건 이 콜렉터를 선택하는 것은 시스템 장애로 이어질 수 있습니다.


6) 강제로 GC 시키

 강제로 GC를 실행시키는 것도 가능합니다. 이를 위해 System.gc() 메서드나 Runtime.getRuntime().gc() 메서드를 사용하면 됩니다. 그러나 이 메서드들은 코드에 사용하는 것을 권장하지 않으며, 특히 웹 기반 시스템에서는 절대로 사용하지 말아야 합니다. 왜 강제 GC 실행이 좋지 않은지 알아봅시다.

 

다음의 코드를 보면, System.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>");
%>

 

 

이 코드를 실행해보면 어떤 결과가 나올까요?

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

 

 약 5,000배 이상의 성능 차이가 발생합니다. 이는 웹 화면의 응답 속도를 비교한 것입니다. GC 방식에 상관없이 GC를 수행하는 동안 다른 애플리케이션의 성능에 영향을 미칩니다. 따라서 이 코드가 실제 운영 중인 시스템에 존재한다면, 시스템의 응답 속도에 큰 영향을 미칠 것입니다.