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

de_sj_awa·2021년 9월 10일
3

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

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

1. 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라고 한다.

2. 자바의 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이 시작될 때 생성된다. 지금까지 설명한 내용들을 그림으로 나타내면 다음과 같다.

3. 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() 메서드로 비교를 해야 하지만, intern() 메서드가 호출된 문자열들은 == 비교가 가능해진다. 따라서, 값 비교 성능은 빨라지지만, 문자열 정보들이 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 영역으로 이동하게 된다.

4. GC의 종류

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

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

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

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

5. 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라고 표현한다. 다시 말하면 콜렉션이 수행될 때 애플리케이션 수행이 정지된다.

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

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 영역 전체를 훑는다.
  • 종합 단계는 여러 스레드가 Old 영역을 분리하여 훑는다. 게다가, 앞서 진행된 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이 빠르다고 무조건 이 콜렉터를 선택하는 것은 시스템의 장애로 연결될 수 있다.

6. 강제로 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를 수행하는 동안 다른 애플리케이션의 성능에 영향을 미친다는 점은 변함이 없으므로 만약 실제 운영 중인 시스템에 이 코드가 있으면 실제 시스템의 응답 속도에 미치는 영향이 엄청나게 커질 것이다.

참고

  • 자바 성능 튜닝 이야기
profile
이것저것 관심많은 개발자.

0개의 댓글