져니의 개발 정원 가꾸기

자바 Heap 메모리 관리 - GC.Garbage Collection (feat. 서비스를 살려야한다!) 본문

개발노트/Spring | Java

자바 Heap 메모리 관리 - GC.Garbage Collection (feat. 서비스를 살려야한다!)

전전쪄니 2024. 2. 4. 23:51

목차

    배경

    최근에 업무 프로젝트를 정리하던 중 입사 초기에 신규 프로젝트에 투입되어 부하테스트 진행했던 부분이 생각났다. 1차 성능테스트 당시 부하가 증가함에에 따라 힙메모리가 지속적으로 증가해 OOM(Out Of Memoery) 문제가 발생했었고 이를 해결하기 위해 GC 설정과 캐시 설정을 바꾸었다. 실제 고객들이 사용중인 서비스에서 OOM이 나면 사용자는 아예 해당 애플리케이션이 제공하는 기능을 아예 사용할 수 없는 불편함을 겪게 된다. 더군다나 개발자가 이를 눈치 채지 못하고 화면과 기능이 꽤 오랜시간동안 제공되지 않은 상태로 머물러 있다면 결국 회사 매출과 사용자 이탈에도 악영향을 미칠 수 있다.

    이처럼 GC는 애플리케이션 성능과 서비스의 품질에 지대한 영향을 끼치는 요소로서 중요하기 때문에 GC에 대해 글을 쓰려고 한다.

    GC가 뭘까?

    GC(Garbage Collection)는 직역하면 쓰레기 수집이라는 말로, JVM 메모리 영역중 힙(Heap)영역(-xmx, -xms 옵션으로 할당되는 메모리 영역)에서 존재하지만 더이상 사용하지 않는 객체를 메모리에서 삭제(해제)하는 작업을 말한다. 여기서 더이상 사용하지 않는 객체는 죽은 객체를 말하는데, 이는 Stack 영역에서 참조가 끊겨(존재하지 않는) 객체를 말한다. Java는 개발자가 직접 코드로 명시하여 메모리를 해제하지 않고 가비지 콜렉터(Garbage Collector)가 이 사용하지 않는 쓰레기 객체를 찾아 메모리에서 지우는 작업(GC)을 한다.

    그림1 - 출처 : https://9gag.com/gag/aAerx5L
    그림 2 - JVM Heap 구조

    (참고로 java7 까지는 Heap영역에 Permanent 영역이 있었는데, JVM8부터 Heap에서 permanent 영역이 사라지고 native memory 영역에 metaspace영역이 생기는 변화가 있다. 참고https://jaemunbro.medium.com/java-metaspace%EC%97%90-%EB%8C%80%ED%95%B4-%EC%95%8C%EC%95%84%EB%B3%B4%EC%9E%90-ac363816d35e)

    GC(가비지 수집) 과정

    사용하지 않는 객체를 메모리에서 삭제한다고 하는데 가비지 콜렉터가 어떻게 사용하지 않는 오래된 객체를 구별하고 가비지 객체들을 처리하는지 알아보자.

    먼저 그림 2 - JVM Heap 구조 를 보면 Heap은 크게 Young generation, Old generation 두 가지 영역으로 나뉜다.

    • Young generation 영역
      새롭게 생성된 객체의 대부분이 이곳에 위치한다. 대부분의 객체들은 금방 unreachable 상태가 되기 때문에 대다수의 객체가 young 영역에 생성되었다가 사라진다. Young generation 영역에서 객체가 삭제되는 것이 Minor GC.
    • Old generation 영역
      reachable 상태로 young generation 에서 살아남은 객체가 복사되는 영역이다. 보통 young gen.보다 크게 할당되고, 크기가 큰 만큼 GC는 더 적게 발생한다. Old generation 영역에서 객체가 삭제되는 것이 Major GC.

    두 영역을 거쳐 전체 GC 과정은 다음과 같이 이루어진다.

    그림 3 - JVM Heap내 객체 이동

    1. 새로운 recatagle 객체가 생성되고 Eden 영역에 저장된다.
    2. Eden에 존재할 때 Minor GC 발생
      • 객체가 다른 곳에서 더이상 참조되지 않는다면 메모리에서 제거한다.
      • GC대상이 되지 않아 살아 남았다면 Eden에서 살아 남은 객체들이 존재하는 Survivor 영역(Survivor영역은 번갈아가면서 쓰기 때문에 한 쪽을 사용하면 한쪽은 비어있다.)으로 이동한다.
    3. 이 후 Survivor 영역에 존재할 때 Minor GC 발생
      그림 4 - Minor GC
      • 2번에서와 같은 이유로 제거되거나, 살아남아 Survivor1, Survivor2 영역을 오간다.
      • 두 survivor를 오간다는 의미 : survivor 영역이 가득 차면 GC에서 살아남은 객체들은과 다른 한 Survivor로 이동하고 가득 찼던 Survivor는 빈 상태가 된다.
      • 한 객체는 이 과정을 Old Generation에 들어갈 때까지 계속 반복한다.
    4. Survivor를 오가며 Minor GC에서 오래동안 살아남은 객체가 되면 최종적으로 old generation영역으로 옮겨진다(=promotion).
      • Old generation으로 옮겨지는 오래동안 살아남았다는 기준은 각 객체가 가지고 있는 age bit로 판단한다.
        age bit는 Minor GC에서 살아남은 횟루를 기록하는 횟수를 기록하는 bit로, Minor GC가 발생할 때바다 1씩 증가한다. age bit가 MaxTenuringThreshold라는 설정 값을 초과하게 되거나 초과하기 전에 Survivor 영역의 메모리가 부족할 경우 Old generation으로 객체가 이동한다.
        ( 참고로, java 11, java 17에서는 MaxTenuringThreshold의 기본값이 15로 설정되어 있다.
      • -> 확인 방법 :$ java -XX:+PrintFlagsFinal -version | grep 'TenuringThreshold')
    5. Old generation에서 미사용된다고 판단되는 객체들은 Major GC를 통해 메모리에서 제거된다.

    stop-the-world

    한편, GC을 실행할 때 JVM은 애플리케이션 실행을 잠시 멈춘다. 그리고 이러한 행위를 가르켜 'stop-the-world'라고 한다(줄여서 STW). STW가 발생하면 GC를 실행하는 쓰레드를 제외한 나머지 쓰레드는 모두 작업을 멈추고, GC작업이 끝난 후에 다시 중단한 작업을 시작한다. 알고리즘과 상관없이 GC가 발생하면 STW는 무조건 발생한다. GC튜닝이라고 하면 대개 이 STW를 줄이는 것이라고 보면된다.

    GC 알고리즘

      Young Generation Old Generation
    실행 시점 Eden 영역이 꽉 찬 경우 Old 영역이 꽉 찬 경우
    실행 주기 자주 비교적 덜 빈번
    실행 속도 빠름 느림

    Minor GC (Garbage Collection)

    Minor GC가 발생하면 객체를 특정 위치에 복사하는 행위가 자주 발생한다. 가령 Eden에서 Survivor1/2(둘 중 사용되는 곳)으로, Survivor1/2에서 Survivor2/1으로, Survivor1/2에서 Old generation으로 객체를 복사한다. 이처럼 반복적으로 객체를 복사하는
    방식을 Copy & Scavenge라고 한다. 속도가 빠르고 작은 크기의 메모리를 모으는데 효과적인 방식이라서, 빈번하게 발생하여 속도가 중요한 Minor GC에 적합하다.

    Major GC (Garbage Collection)

    Major GC에 기본적으로 사용되는 알고리즘은 Mark-Sweep-Compact 이다.

    • (Mark) Old 영역에서 살아있는 객체를 마킹(mark)하여 식별
    • (Sweep) Old영역 앞 부분부터 쓰레기 객체를 확인하며 살아있는 것만 남기고 남은 메모리를 해제(sweep)한다
    • (Compact) 살아있는 각 객체들이 연속되게 쌓이도록 힙의 가장 앞 부분부터 채워서 빈 공간 없이 압축(compact)한다.

    copmact작업은 메모리의 Fragmentation 상태를 해결하기 위한 작업이다. 메모리가 파편화되어 존재할 경우 총 메모리가 충분히 여유가 있음에도 불구하고 새로운 객체를 할당할 수 없는 상황이 생기거나, 새로운 객체를 할당하기 위해 빈 공간을 뒤지는 과정 자체가 성능에 악영향을 줄 수 있다. 사용하는 메모리와 사용하지 않은 메모리를 모으는 것으로 이를 해결한다.

    어떤 가비지 콜렉터를 쓰느냐에 따라 알고리즘이 약간씩 달라지지만 Old영역에서는 기본적으로 Mark-Sweep-Compact알고리즘을 쓰는 것을 기억하자!

    가비지 콜렉터 종류

    1. Serial GC (Serial Garbage Collector)

    단일 쓰레드를 사용한 가비지 콜렉터로, 메모리와 CPU 코어 개수가 적을 때 적합하다. 그러나 하나의 쓰레드만 사용하기에 Stop The World시 대기시간이 상당히 길기 때문에 전체적으로 애플리케이션 성능이 많이 떨어지게 된다. 그래서 실제 운영 환경에서는 쓰지 않는다.

    Serial GC를 사용하는 ex. VirtualBox VM

    사용하기

    java -XX:+UseSerialGC -jar Application.java

    2. Parallel GC  (Parallel Garbage Collector)

    Parallel GC는 Java 5~8 JVM의 기본값으로 사용되는 GC이다. Throughput Collector라고도 불리운다. 기본적으로 사용하는 알고리즘은 Serial GC와 같지만 여러 개의 쓰레드를 사용하여 비교적 빠르게 GC할 수 있다는 차이점이 있다. 메모리가 많고 코어의 개수가 많을 때 적합하다.

    사용하기

    java -XX:+UseParallelGC -jar Application.java

    옵션으로 GC로 사용할 쓰레드의 수나 중단 시간 등 멀티 스레딩과 관련한 여러 옵션을 사용할 수 있다.

    ex. -XX:ParallelGCThreads=[N], -XX:MaxGCPauseMillis=[N]

    3. G1 GC (Garbage First Garbage Collector)

    G1 GC는 큰 메모리 공간(4GB이상)& 멀티 프로세서에서 돌아가는 애플리케이션을 위해 만들어진 GC이다. JDK 7 이후 버전부터 사용가능하며, Java 9 이상의 JVM에서 기본값으로 사용하는 GC이다.

    G1 GC는 기존 가비지 콜렉터들과는 다르게 heap 메모리를 일정한 크기로 나눈다. 이렇게 나눈 메모리 영역을 Region이라고 하고, 아래 그림5 처럼 각 공간이 같은 크기가 되도록 바둑판 형태로 나뉜다. JVM heap은 2048개의 Region으로 나뉠 수 있고, 각 Regison의 크기는 1MB ~ 32MB 값을 가진다. (크기는 -XX:G1HeapRegionSize 옵션으로 조정할 수 있다.)

    • 기존 컬렉터 : 기존에는 고정된 위치에서 young, old 영역을 나눔
    • G1 GC 컬렉터 : Region의 상태에 따라서 Eden, Survivor, old 등의 역할이 동적으로 부여됨.

    그림 5 - G1 GC 출처 : https://www.dhaval-shah.com/g1-gc-primer/

    물리적으로 메모리는 변신을 했지만, JVM heap에서 구분한 young, old 영역의 개념은 그대로 가져간다. G1GC에서 Young generation과 Old generation은 다음과 같다. 참고로 Free Region은 Available Region이라고도 하며 아직 사용되지 않은 Region을 말한다.

    Young generation

    • eden region
    • survivor region

    Old generation

    • Old region
    • Humonguos region (Region 크기의 50%를 초과하는 큰 객체를 저장하기 위한 공간. GC동작이 최적으로 동작하지 않는다.)

    그렇다면 GC는 어떻게 이루어질까? 사실 G1GC는 관점을 어떻 두냐에 따라서 단계를 다른 관점에서 볼 수 있는데, 여기서는 두 가지 단계로 나누어 어떻게 GC가 볼 것이다. 참고로 G1 GC는 Old generation의 객체들을 수집하기 위해 일시중지를 최소화하도록 설계된 컬렉터이다. 일시중지 시간을 줄이기 위해 각 스레드가 자신만의 region을 잡고 작업하는 방식으로 병렬적으로 GC를 진행한다.

    두 단계는 young-only단계space-reclamation 단계로, gc 종료 조건을 달성할 때까지 두 단계를 반복하며 아래 cycle구조로 동작한다. 간략하게 말하자면 young-only에서는 현재 사용할 수 있는 메모리를 old 영역의 객체로 점차 채우는 과정(promotion)을 포함하고, space-reclamation단계에서는 old 영역에서 공간을 되찾고(reclaim) young 영역의 GC를 같이 수행한다. 그리고나서 다시 young-only 단계부터 시작되며 과정들이 반복된다.

    그림 6 - g1gc cycle https://c-guntur.github.io/java-gc/#/6/4
    그림7 - g1gc cycle https://c-guntur.github.io/java-gc/#/6/5

    Young-Only Phase
    young-only 단계에서는 old 영역으로 객체를 옮기는(promotion)하는 young GC(; young generation GC, young collection)으로 시작한다. Young GC는 각 Region 중 GC대상 객체가 가장 많은 Region(Eden 또는 Survivor 역할) 에서 수행 되며, 이 Region 에서 살아남은 객체를 다른 Region(Survivor 역할) 으로 옮긴 후, 비워진 Region을 사용가능한 Region으로 돌리는 형태로 동작한다

    • Initial Mark 단계 (Concurrent Start)
      young GC(young collection)가 진행되는 중에 Marking 단계를 시작한다. young GC는 Concurrent Marking가 진행될 때도 같이 동작하기 때문에 Marking은 인터럽트될 수 있다. Concurrent Marking는 space-reclamation단계에서 old영역의 삭제하지 않고 살려 둘 현재 살아있는(reachable)객체들을 결정한다. 마킹이 다 끝나지 않았어도 young GC가 일어날 수 있고, 실제 마킹은 STW가 발생하는 Remark 와 CleanUp단계에서 끝난다. (STW는 발생하지 않는다.)
    • Remark 단계
      애플리케이션을 멈춰(STW) 마킹을 마무리하는 단계이다. Refrence 처리나 Class Unloading을 수행한다. Reamark와 Cleanup단계 중에 현재 영역들의 liveness 퍼센트 정보를 업데이트 하고, 이는 Cleanup단계에서 마무리 되며 내부 데이터 구조를 정리하는데 사용된다.
    • Cleanup 단계
      애플리케이션을 멈춰(STW) 빈공간들을 회수하고 공간을 재확보(space reclamation)단계를 할지 말지 결정한다. 공간 재확보 단계가 오게 될 경우 young-only는 1회의 Mixed-GC만 진행하고 완료된다. (STW 발생)

    Space-reclamation(공간 재확보) Phase

    • Young Region 뿐만 아니라 Old Region의 Live 객체도 옮기는 여려번의 Mixed-GC로 구성. 이 과정은 G1이 더 이상 Old Region을 효율적으로 줄일 수 없겠다고 판단될 때까지 진행된다.

    space-reclamation후에는 다시 cycle이 시작되어 새로운 young-only 단계가 시작된다.

    Old region GC

    바로 앞에서는 주로 young gc에 대해서 다루었다. old 영역의 gc에 대해서 좀 더 자세히 알아보자. 동일한 단계들이 있지만 old gc에 맞춰서 필요한 내용들을 추가하여 약간 다르게 설명했다.

    그림 8 - G1GC https://huisam.tistory.com/entry/jvmgc

    • Initial Mark 단계
      Old Region에 존재하는 객체들이 참조하는 Survivor Region을 찾는다. (STW 발생)
    • Root Region Scanning 단계
      Initial Mark 단계에서 찾은 Survivor Region에 대해 GC 대상 객체 스캔 작업을 진행한다. STW는 발생하지 않는다.
    • Concurrent Marking 단계
      Heap 전체를 대상으로 Region 스캔 작업을 진행하며 살아남을 객체들을 찾는다. GC 대상 객체가 발견되지 않은 Region들에 대해 이후 단계를 진행하지 않도록 한다. 이 과정은 앞서 young-only 단계에서 보았듯이 young GC 에 의해 인터럽트될 수 있고, STW는 발생하지 않는다.
    • Remark 단계
      애플리케이션을 멈추고(STW) heap에서 살아있는 객체들의 마킹을 마무리한다. 최종적으로 GC하지 않을 객체(살아남을 객체)를 식별해낸다.
    • Cleanup 단계
      애플리케이션을 멈추고(STW) 살아있는 객체가 가장 적은 Region에 대한 미사용 객체 제거(GC)를 수행한다. STW를 끝내고, 앞선 GC 과정에서 완전히 비워진 Region을 concurrnet하게 Freelist에 추가하여 재사용할 수 있도록 한다.
    • Copying 단계
      GC 대상 Region 이었지만 Cleanup 과정에서 완전히 비워지지 않은 Region의 살아남은 객체들을 Free region(Available Region)에 복사하여 Compaction 작업을 수행한다. (STW 발생)
      young generation에서만 이루어질 경우 - GC Pause(young) 라고 기록
      young / old 두 곳에서 모두 이루어지는 경우 - GC Puause(mixed) 라고 기록

    G1 GC의 GC과정을 보면 마킹 과정을 많이 한다. G1 GC는 마킹 알고리즘으로 SATB(snapshot-at-the-beginning) 을 사용한다. 일시중지가 일어난 시점 직후의 살아남은 객체(스냅샷)에만 마킹하는 방법으로, 마킹 도중에 죽은 객체도 라이브 객체로 간주되는 특징이 있고, 다른 GC보다 응답시간이 더 빠르다고 한다.

    사용하기

    java -XX:+UseG1GC -jar Application.java

    4. Z GC ( Z Garbage Collector)

    Z GC는 확장성있는 로우레이턴시(응답성이 높은) 가비지 콜렉터이다. 리눅스에서 실험 옵션으로 Java11버전에서 등장했고, 윈도우와 맥 운영체제에서는 Jdk 14 버전부터 등장했다. 정식 프로덕션 상태를 얻게 된 것은 Java 15에서 부터이다.

    G1 GC와 메모리 구조가 유사하고, G1 GC의 Region과 비슷한 영역의 개념으로 Z GC는 ZPages를 사용한다. 더 큰 메모리(8MB ~ 16TB)에서 효율적으로 GC 하기 위한 알고리즘이다. Z GC는 기존 GC방법들과는 다르게 Colored Pointers와 Load barriers라는 두 가지 주요 알고리즘을 사용하며, STW 시간을 줄이기 위해 Marking 시간에만 STW를 갖는 특징이 있다.

    그림 9 - Z GC heap 메모리 구조 https://huisam.tistory.com/entry/jvmgc

    사용하기

    java -XX:+UseZGC Application.java

    5. 그 외... CMS GC, Epsilon GC 등

    앞에서 설명한 GC 말고도 더 많은 GC들이 있다.

    CMS GC는 G1 GC 이전에 나온 가비지 컬렉터로 STW를 아주 짧게 하려고 설계된 Old generation 전용 가비지 컬렉터이다. GC작업을 응용 프로그램 스레드와 동시에 수행하여 GC로 인한 일시 중지를 최소화하려고 한다. STW가 짧다는 장점이 있지만 다른 GC보다 메모리와 CPU를 더 많이 사용하고, Compaction 단계가 기본적으로 제공되지 않기 때문에 사용시 신중히 검토해야한다고 한다.

    사용하기

    -XX:+UseConcMarkSweepGC

    Epsilon GC는 아무것도 안하는 가비지 컬렉터로, 힙이 다 차면 Out Of Memoery가 발생한다. 제한된 메모리를 사용하는 임베디드 환경에서 유용할 수 있다고 한다.

    GC 튜닝?

    저~ 앞에서 GC 튜닝이라고 하면 대개 STW 시간을 줄이는 것이라고 이야기한 바있다. 애플리케이션 성능 개선 외에도 실제 서비스의 부하가 올라갈 경우 Out Of Memeory나 Timeout Connection가 발생하는 것도 GC 설정과 관련 있을 수 있다. 애플리케이션이 안정적으로 제공되기 위해서, 성능을 높이기 위해서 GC 튜닝이 필요할 수 있다.

    그러나 GC를 튜닝하기 전에 먼저 애플리케이션 코드에서 메모리가 세고 있는 부분이 없는지 먼저 점검해봐야한다. 개선할 수 있는 모든 것을 다 해봤는데도 개선이 안 될 때 GC튜닝을 해도 늦지 않다. GC 튜닝 자체가 신경써야할 요소와 모니터링을 통해 최적의 옵션을 찾는 것이기 때문에 자칫하다가 득보다 실이 많을 수 있기 때문이다.

    그래서 GC 튜닝을 하게 된다면 두 가지를 먼저 명심하고 하도록 진행하도록 하자.

    1. GC 튜닝 옵션은 서비스의 특징마다 적정 값이 다르다.
    2. GC 튜닝은 가장 마지막에 해야한다.

    GC튜닝은 대개 모니터링 후 진행여부를 결정하고 JVM옵션이나 GC 옵션 값을 조절하며 결과를 분석하여 최적의 옵션을 적용하는 절차로 반영될 수 있다. GC 모니터링은 애플리케이션 구동시 -verbosegc JVM 옵션을 추가하거나, jstat, visualVM + visual GC, HPJMeter 툴 등을 사용하여 모니터링 할 수 있다. 모니터링 및 옵션 값에 대해서는 아래 블로그들에서 자세하게 설명하고 있으니 관심있으면 참고하자.

    끝으로

    이번 글에서는 GC에 대해서 알아봤다. 글을 쓰기 위해 찾아보는 과정중에 GC에 관련된 내용은 정말 끝이 없었다. 더 적고 싶은 내용들이 많았지만 굵직한 소재들은 충분히 다룬 것 같다.

    나중에 GC 튜닝이 필요한 중요한 순간에 내 애플리케이션에 맞는 최적의 선택을 하기를...!

    참고

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

    https://d2.naver.com/helloworld/329631
    https://www.baeldung.com/jvm-garbage-collectors

    https://devocean.sk.com/blog/techBoardDetail.do?ID=165630
    https://blog.bespinglobal.com/post/garbage-collection-1%EB%B6%80/

    https://mirinae312.github.io/develop/2018/06/04/jvm_gc.html

    https://juns-lee.tistory.com/entry/Java-Garbage-Collection

    https://www.dhaval-shah.com/g1-gc-primer/

    https://siahn95.tistory.com/108

    https://thinkground.studio/2020/11/07/%EC%9D%BC%EB%B0%98%EC%A0%81%EC%9D%B8-gc-%EB%82%B4%EC%9A%A9%EA%B3%BC-g1gc-garbage-first-garbage-collector-%EB%82%B4%EC%9A%A9/

    https://blog.leaphop.co.kr/blogs/42

    https://docs.oracle.com/javase/9/gctuning/garbage-first-garbage-collector.htm

    https://c-guntur.github.io/java-gc/#/

    https://incheol-jung.gitbook.io/docs/q-and-a/java/g1-gc-vs-z-gc

    https://inpa.tistory.com/entry/JAVA-%E2%98%95-%EA%B0%80%EB%B9%84%EC%A7%80-%EC%BB%AC%EB%A0%89%EC%85%98-GC-%ED%8A%9C%EB%8B%9D-%EB%A7%9B%EB%B3%B4%EA%B8%B0