Language/JAVA&Spring

[JAVA] 가비지 컬렉션 GC

지과쌤 2022. 10. 11.
반응형

목차

    시작하며

    가비지 컬렉션은 자바의 메모리 관리 방법 중 하나로, JVM의 Heap 영역에서 동적으로 할당되었던 리소스 중 필요 없게 된(어떤 변수도 가리키지 않게 된 영역) 리소스를 주기적으로 삭제하는 프로세스이다.

     

    간단하게 말해, 프로그램이 동적으로 할당했던 메모리 영역 중 필요 없게 된 영역을 알아서 해제하는 기법이다.

     

    GC의 장 단점

    장점

    - 메모리 누수 방지

    - 해제된 메모리에 접근 방지

    - 해제한 메모리 또 해제하는 프로세스 방지

    단점

    - GC 작업은 순수한 오버헤드 작업임

    - 개발자는 언제 GC가 메모리를 해제하는지 명확하게 알기 힘들다

     

    이때문에, 실시간성 프로그램의 경우, GC에게 메모리 관리를 맡기는 것이 알맞지 않을 수 있다.

     

    GC 알고리즘

    Reference Counting

    Root Space : 스택 변수, 전역 변수 등 heap 영역 참조를 담은 변수

     

    reference counting은 heap 영역에 선언된 객체들이 각각 reference count라는 별도의 숫자를 갖고 있다고 생각하자.

    여기서 reference count는, 몇가지 방법으로 해당 객체에 접근할 수 있는지를 나타내주는데, 해당 객체에 접근할 수 있는 방법이 하나도 없다면( 0 ) GC의 대상이 된다.

     

    하지만, reference counting에는 순환 참조 문제 라는 한계점이 있다.

    위 그림에서, Root Space에서의 Heap Space 접근을 모두 끊는다고 가정해보자.

    노란색 고리 안의 객체들은 서로가 서로를 참조하고있기 때문에, reference count가 1로 계속 유지될 것이다.

    그럼 결국 사용하지 않는 메모리 영역이 해제되지 못하고 Memory Leak이 발생하게 된다.

     

    Mark And Sweep

    mark and sweep은 reference counting의 순환 참조 문제를 해결할 수 있다.

    mark and sweep은 루트에서부터 해당 객체에 접근 가능한지를 해제의 기준으로 삼는다.

    루트부터 그래프 순회를 통해 연결된 객체를 찾아내고, 연결이 끊어진 객체는 지운다.

    Mark and Sweep

    루트로부터 연결된 객체는 Reachable, 연결되지 않은 객체는 Unreachable 이라고 부른다.

    위 그림에선 Sweep 이후, 분산되어있던 메모리가 정리된 것을 볼 수 있는데, 이를 메모리 파편화를 막는 Compaction 이라고 한다.

    다만, Mark And Sweep 방식에서 Compaction 은 필수 과정은 아니다.

    아무튼, 해당 방식을 사용하면, 루트로부터 연결이 끊긴 순환 참조되는 객체들도 모두 지울 수 있다.

    Mark And Sweep 특징

    - 의도적으로 GC를 실행시켜야 한다.

    - 어플리케이션 실행과 GC 실행이 병행된다.

     

    객체의 reference counte가 0이 되면 지워버리는 reference counting 방식과는 달리 Mark And Sweep은 의도적으로 GC를 실행시켜야 한다.

    즉, 어느 순간에는 실행중인 어플리케이션이 GC에게 리소스를 내주어야 한다는 점이다.(STW : Stop The World) 따라서 어플리케이션의 사용성을 유지하면서 효율적이게 GC를 실행하는 것이 꽤 복잡한 작업이 된다.

     

    JVM 에서의 GC

    JVM 구조

    Class Loader : 바이트코드를 읽고, 클래스 코드를 메모리의 Heap / Method 영역에 저장.

     

    JVM Memory : 실행중인 프로그램의 정보가 올라가 있음.

     

    Execution Engine : 바이트 코드를 네이티브 코드로 변환시켜주고, GC를 실행

     

    JVM 메모리 구조

    JVM은 메모리를 OS로부터 할당받은 후, 해당 메모리를 용도에 따라 여러 영역으로 나누어 관리한다.

    모든 스레드가 공유하는 영역으로 method area와 heap 영역이 있다.

    각 스레드마다 고유하게 생성하며 스레드 종료시 소멸되는 Stack, PC Register, Native Method Stack 영역이 있다.

     

    JVM Root Space

    - Method Area : 프로그램의 클래스 구조를 메타데이터처럼 가지며, 메서드의 코드들을 저장해둔다.

    - Heap : 어플리케이션 실행 중에 생성되는 객체 인스턴스를 저장하는 영역이다. (Garbage Collector에 의해 관리되는 영역임.)

    - Stack : 메서드 호출을 스택 프레임이라는 블록으로 쌓으며, 로컬변수, 중간 연산 결과들이 저장되는 영역

    - PC Register : 스레드가 현재 실행할 스택 프레임의 주소를 저장하고 있다.

    - Native Method Stack : C/C++ 등의 Low Level 코드를 실행하는 영역

     

    JVM의 GC는 Mark and Sweep 방식으로 돌아간다.

    위에서 말했던 내용처럼, 루트에서부터 해당 객체에 접근이 가능한지가 해제의 기준인데, 루트는 위 그림과 같다.

    JVM Heap 영역

    JVM의 Heap 영역은 크게 두 영역으로 나뉜다.

    Young Generation 에서 발생하는 GC는 Minor GC, Old Generation에서 발생하는 GC는 Major GC라고 부른다.

    Young Generation은 위와 같이 Eden, Survival 0, Survival 1 세 영역으로 나뉘게 된다.

     

    - Eden : 새롭게 생성된 객체들이 할당되는 영역

    - Survival : Minor GC로부터 살아남은 객체들이 존재하는 영역

     

    survival 영역 0 또는 1 둘중 하나는 꼭 비어있어야 한다는 규칙이 있으므로 명심하자.

     

    Eden 영역에 위와 같이 객체가 생성되기 시작하다가.... 

    위와 같이 객체가 꽉 차게 되면 Minor GC가 일어나게 된다. -> Mark And Sweep이 발생하게 된다.

    루트로부터 Reachable 이라고 판단된 객체는 아래 그림과 같이 Survival 0 영역으로 옮겨지게 된다.

    Survival 0 영역으로 옮겨진 객체들의 숫자가 0에서 1로 증가하였는데, 이는 age bit를 뜻한다.

    Minor GC에서 살아남을 때 마다, 1씩 증가하게 된다.

    시간이 흘러 Eden 영역이 또 가득차게된다.

    위 그림과 같이, Reachable 이라고 판단된 객체들과 아닌 객체들이 나뉘게 되고, Reachable이라고 판단된 객체들이 Survival 1 로 이동하게 된다.

    시간이 흘러 또 Eden 영역이 가득 차게 되고...

    Eden 영역이 가득차면 또 Minor GC가 일어나게 된다.

    이번에 살아남은 객체들은 Survival 0 으로 이동하게 될 것이다.

    이렇게 이동하다 Survival 0 에 있는 한 객체의 Age-Bit이 3이 된것을 볼 수 있다.

     

    JVM GC에서는 일정 수준의 age-bit을 넘어가면 오래 참조될 객체라고 판단하여 해당 객체를 Old Generation에 넘겨주게 된다.

    Promotion : 일정 수준의 age-bit를 넘어가면 오래 참조될 객체라고 판단하여 해당 객체를 Old Generation에 넘겨주게 된다.

     

    JAVA 8 Parallel GC 방식 사용 기준, Age-Bit가 15가 되면 Promotion이 진행된다.

    시간이 많이 지나 Old Generation이 가득 차게되면, 이때 Major GC가 발생하면서....

    Mark and Sweep 방식을 통해 필요 없는 메모리를 비워주게 된다.

    단, Major GC는 Minor GC보다 더 오랜 시간 걸리게 된다.

    Heap 영역을 Young Generation과 Old Generation으로 나눈 이유는 다음과 같다.

     

    위 표 처럼, 대부분의 객체가 수명이 짧다.

    GC도 결국 리소스를 사용하는것이고, 비용적인 부분을 생각해야하는데, 메모리의 특정 부분만 탐색하며 해제하면 더 효율적일것이다.

    따라서 Young Generation 안에서 최대한 처리하도록 하는 것이다.

     

    Stop The World

    Mark And Sweep 의 특징 중, 어플리케이션 실행과 GC 실행이 병행된다는 부분을 확인하기 전, STW 라는 개념을 짚고 가야 한다.

     

    - Stop The World란, GC를 실행하기 위해, JVM이 어플리케이션 실행을 멈추는 것.

     

    따라서, Stop The World 시간을 최소화 하는 것이, 어플리케이션을 최적화 할 수 있는 방법이다.

     

    GC 방식 (Serial GC)

    - 하나의 스레드로 GC를 실행

    - Stop The World 시간이 길다

    - 싱글 스레드 환경 및 Heap이 매우 작을 때 사용

     

    GC 방식 (Parallel GC)

    - 여러 개의 스레드로 GC를 실행

    - 멀티코어 환경에서 사용

    - JAVA 8의 default GC 방식

     

    Parallel GC 는 여러 개의 스레드로 가비지 컬렉션을 수행한다.

    바로 위 Serial GC 보다 Stop The World 시간이 짧아진것을 알 수 있다.

    멀티코어 환경에서 어플리케이션 속도를 향상시키기 위해 사용된다.

     

    GC 방식 (CMS GC)

    Concurrent Mark-Sweep GC 방식

    - Stop The World 최소화를 위해 고안

    - GC 작업을 어플리케이션과 동시에 실행

    - 메모리와 CPU 자원을 많이 사용하고, Mark and Sweep 과정 이후 메모리 파편화를 해결하는 Compaction이 기본적으로 제공되지 않는다는 단점 존재

    - G1 GC 등장에 따라 해당 방식은 더이상 쓰이지 않음

     

    GC 방식 (G1 GC)

    - Garbage First (G1)

    - Heap을 Region으로 나누어 사용

    - Java 9 부터 default GC 방식

     

    Heap 영역을 위의 방식들과는 달리 다르게 사용한다.

    Heap을 일정 영역으로 잘게 나누어, 어떤 영역은 Young Generation, 어떤 영역은 Old Generation으로 활용한다.

    런타임의 G1 GC가 동적으로 영역별 Region 개수를 튜닝한다. 따라서 Stop The World 시간을 최소화할 수 있게 되었다.

     

    JVM GC 튜닝 맛보기

    - Old Generation으로 넘어가는 객체 최소화하기

    - Major GC 시간을 짧게 유지하기

     

    위의 두 목표 모두, Minor GC보다 Stop The World 시간이 긴 Major GC와 관련되어 있다.

    Major GC를 적게 발생시키거나, Major GC를 빠른 시간 내에 끝내는 것이 목표인 것이다.

    따라서 한정된 Heap 영역 내에 Young Generation과 Old Generation을 각각 얼마만큼 할당할 것인지 잘 판단해야 한다.

     

    메모리가 크다면, GC는 가끔 일어나겠지만 오래 걸려 Stop The World 시간이 길 것이고,

    메모리가 작다면, GC가 자주 일어나지만 Stop The World 시간이 짧을 것이다.

     

    이런 판단은 어플리케이션의 구조 및 특성에 따라 판단해야한다.

    따라서 GC 튜닝을 진행하기 위해서는 다음과 같은 내용을 확인해야한다.

     

    - GC 상태 모니터링

    - 알맞은 GC 방식과 메모리 크기 설정

    - 적용

     

    큰 관계는 없지만, 현재 내 맥의 설정을 확인해봤다.

    InitialHeapSize : 약 256MB

    MaxHeapSize : 약 4GB

    GC 방식 : G1 GC

     

    그리고, 여러가지 도구들을 이용하여 JVM 메모리를 모니터링 할 수 있는데, 그중 하나로 JDK 설치시 기본으로 제공되는 jstat이라는 툴을 통해 JVM을 모니터링 할 수 있다.

     

    jstat gcutil 명령어를 사용하여 현재 실행중인 45132 프로세스 (내 프로젝트)에 대해 1초에 한번씩 총 10번 GC와 관련된 정보를 출력하도록 해보자

    - S0 : Survival 0 영역의 사용률

    - S1 : Survival 0 영역의 사용률

    - E : Eden 영역의 사용률

    - O : Old 영역의 사용률

    - YGC : Young Generation 영역의 GC 이벤트 수

    - YGCT : Young Generation 영역의 총 가비지 컬렉션 시간

    - FGC : Full GC 이벤트 수 (Major GC)

    - FGCT : Full GC 시간

    - GCT : 모든 가비지 컬렉션 총 시간

     

    위 사진을 다시 자세히 보자.

     

    정보를 출력하는 10초 사이에, 어플리케이션에 특정 페이지를 출력하는 요청을 보냈다.

    이에 따라 새로운 객체가 할당되면서 Eden 영역의 사용률이 늘어난것을 볼 수 있다. 

     

    Young Generation 영역에서 총 5번의 GC가 0.018초 동안 실행되었는데 이를 단술하게 나누면, Minor GC 한번에 0.0036초가 소요되었다고 볼 수 있다.

     

    Major GC는 일어나지 않은것을 확인할 수 있다. 

     

    만약 Major GC가 발생하고, 1초 이상 소요되었다면 어떻게 될까?

     

    DB Connection이 1초가 넘어가면 타임아웃 되는 상황이라면 GC가 장애의 원인이 될 수 있을것이다.

     

    위 사진처럼 jstat gccapacity 명령을 통해 프로세스가 heap 영역을 얼마나 사용중인지 확인해볼 수 있다.

     

    NG로 시작하는 지표들은 New Generation, 즉 Young Generation 영역을 뜻하고, OG로 시작하는 지표는 Old Generation을 뜻한다.

     

    CMN : 영역의 최소 할당 크기

    CMX : 영역의 최대 허용 크기

    C : 영역의 현재 크기

     

    를 각각 KB로 나타내고 있다.

     

    이 수치를 보면 위에서 이야기했던, 기본 설정과 일치하는 부분이 있음을 확인할 수 있다.

    반응형

    댓글

    💲 추천 글