본문 바로가기
Language/Java

GC(Garbage Collection)에 대해 알아봅시다!

by 태하팍 2025. 10. 31.
반응형

https://docs.oracle.com/javase/specs/jvms/se25/html/jvms-2.html#jvms-2.5

Run-Time Data Areas

  • The pc Register
  • JVM Stack
  • Heap
  • Method Area
  • Run-Time Constant(상수) Pool
  • Native Method Stacks

이 영역들 중에 GC(Garbage Collection)가 일어나는 영역은 Heap 영역 입니다.
역으로 말하면 나머지 영역은 GC 대상이 아니라는 이야기!

단순하게는 Heap메모리 영역과 Non-Heap메모리 영역으로 나뉜다.

    • 클래스 로더 서브 시스템
      • 클래스나 인터페이스를 JVM으로 로딩하는 기능을 수행
      • 1) Bootstrap ClassLoader : JVM 자체 제공, jdk핵심 클래스 로드 (<JAVA_HOME>/lib)
      • 2) Extentions ClassLoader : 확장 라이브러리 로드 (<JAVA_HOME>/lib/ext)
      • 3) Application ClassLoader : 사용자가 지정한 -cp(classpath)경로의 클래스 로드(3rd-party jar)
        • 1) -> 2) -> 3) 순으로 계층적으로 동작 함.
        • -Xbootclasspath/a: 옵션을 프로젝트에서 conf를 주려고 사용할 때가 있었는데 Bootstrap ClassLoader의 클래스 로드 경로(bootstrap classpath)에 추가(append) 역할을 합니다. 
        • ex) 아래처럼 추가 됨.
          • Bootstrap ClassLoader  
            • <JAVA_HOME>/lib/*.jar  
            • + -Xbootclasspath/a:/*.jar
  • 실행 엔진
    • 로딩된 클래스의 메서드들의 바이트코드를 실행하는 영역
      • Interpreter 방식 : 바이트 코드를 한줄 씩 해석해서 실행(속도 느림)
      • JIT(Just-In-Time) Compiler : 자주 실행되는 코드를 기계어로 컴파일해 캐싱(속도 빠름)
      • 2가지 방식을 모두 사용(default 설정)
        • 즉, 처음에는 Interpreter방식으로 시작하다가 코드가 반복되면 캐싱해버림(JIT)!
        • JVM옵션을 조정으로 설정 변경도 가능
          • -Xint : Interpreter만 사용
          • -Xcomp : JIT complier만 사용(모든 코드를 컴파일 -> 초기 느림)
          • -Xmixed : Interpreter+JIT혼합 사용(default)
          • 디폴트로 그냥 사용하면 될것 같음! 
클래스로더가 컴파일 된 바이트코드를 메모리에 올리고 실행엔진이 그 바이트코드를 실제로 돌리는 구조 입니다.

 

우리는 이제 GC를 알아보기 전 아래처럼 메모리를 나눠서 생각할 수 있습니다.

Heap 메모리 & Non-Heap 메모리

    •  Heap 메모리
      • 클래스 인스턴스, 배열이 이 메모리에 쌓입니다. (ex. new 연산으로 만든 친구들)
      • 이 메모리는 "공유 메모리"라고도 불리며 여러 쓰레드에서 공유하는 데이터들이 저장되는 메모리 입니다.
      • GC가 관리!
        • GC에서 관리가 필요하다는건 다른 말로 수시로 생성/삭제가 된다는 말과 같다.
    • Non-Heap 메모리
      • 자바의 내부 처리를 위해서 필요한 영역들 입니다.
      • JVM 내부 시스템이 관리!
        • 거의 불변(클래스 메타정보 등)이라 GC 부하를 줄이기 위해 분리한거라 볼수 있습니다. CQRS느낌? ㅎ
      • 여기서 주된 영역은 메서드 영역 입니다.
        • 메서드 영역(Method Area)
          • JVM이 클래스를 로드할 때 해당 클래스의 메타데이터를 저장하는 공간 입니다. 
          • 모든 JVM 쓰레드에서 공유!
          • 이 영역에 저장되는 데이터들은?
            • 런타임 상수 풀: 자바의 클래스 파일에는 contant_pool이라는 정보가 포함되어있습니다.
              • 이 contant_pool에 대한 정보를 실행 시(런타임)에 참조하기 위한 영역 입니다.
              • 실제 상수값도 포함 될수 있지만 실행 시에 변하게 되는 필드 참조 정보도 포함 입니다.
              • 요약하면 클래스의 상수값 및 참조정보
                • 참조정보란? Helloworld.sayHello() <- 메서드/필드 참조 정보!
          • 필드 정보에는 메서드 데이터, 메서드와 생성자 코드가 있습니다.
            • Class명, Method명, 필드(멤버변수)명
            • 메서드 코드(바이트코드)
            • 생성자 코드
            • static 변수(전역변수)

예를 들면 아래의 코드에서는 
Class명(Helloworld), static변수인 count 변수, sayHello()의 바이트코드, name 멤버변수 정보등이 메서드 영역에 저장 됩니다.

class HelloWorld {
   static int count = 0;
   String name;
   void sayHello() { System.out.println("태하팍! Hi~"); }
}
  •  
    • JVM 스택(쓰레드의 일시 작업 메모리)
      • 쓰레드가 시작할 때 JVM 스택이 생성!
      • 각 쓰레드가 메서드를 호출하면 JVM 스택에 그 메서드용 공간(frame)이 하나 생기고 리턴하면 사라집니다.
        • 지역변수
        • 매개변수
        • 리턴에 관련된 정보들
    • 네이티브 메서드 스택(Native Method Stack)
      • Java 코드가 아닌 다른언어로 된(c, c++) 코드들이 실행하게 될 때의 스택 정보를 관리 합니다.
      • JNI(Java Native Interface) 호출 시 사용
        • System.arraycopy()같은 메소드는 내부적으로 C코드로 작성되어있어서 해당 영역을 사용!
        • 참고 : 링크
    • PC 레지스터(Program Counter Register)
      • 현재 쓰레드가 JVM 바이트코드 중 어느 명령을(JVM Instruction) 실행 중인지 저장하는 저장소
        • 자바의 쓰레드들은 각자의 pc(Program Counter)레지스터를 갖습니다.
        • 네이티브한 코드를 제외한 모든 자바 코드들이 수행될 때 JVM의 Instruction(명령) 주소를 pc 레지스터에 보관!
        • JVM이 인터프리터 모드로 바이트코드를 해석할 때 이 주소를 사용
          • 왜 사용하냐면 어디까지 수행했는지 기억하면서 읽어들임(pc 레지스터에 저장)
          • 마치 책 읽으면서 어디까지 읽었는데 책갈피 해놓는 거랑 같음!
          • JVM Instruction가 바이트코드라고 하면 해당 주소로 어디까지 읽어들였는지 알수 있다는 소리! 

참고! ) 스택의 크기는 고정 또는 가변적일 수 있으며 고정일 경우 연산을 하다 JVM의 스택 크기가 최대치를 넘어서면 StackOverflowError가 발생 합니다.
그리고 가변적일 경우 스택의 크기를 늘이려고 할 때 메모리가 부족하거나 쓰레드를 생성 할 때 메모리가 부족한 경우에는 OutOfMemoryError가 발생 합니다.

Heap영역과 메서드 영역은 JVM이 시작 될 때 생성 됩니다.

참고 : book - 자바성능튜닝이야기

 

Run-Time Data Areas에서 이제 GC관련 내용을 알아봅시다!

GC의 원리

GC 작업을 하는 가비지 컬렉터(Garbage Collector)가 하는 일은?

  • 메모리 할당
  • 사용 중인 메모리 인식
  • 사용하지 않는(불필요한 메모리) 메모리 인식 -> 메모리 해제(free)

사용하지 않는 메모리를 인식하는 작업!
즉, 죽은(?) 객체들을 인식하지 못한채 내비두면 Heap은 가득차게 됩니다.
어떻게 될까요? 
JVM이 더이상 메모리 할당을 못하고 서버에 Hang이 걸리고 혹은 OOM이 발생!

포인트!

GC는 성능의 핵심이며 단순히 청소부가 아니고 겁나 중요한 JVM의 생명유지 장치 입니다.
그만큼 중요하다는 말이며 하지만 보통은 GC 기본설정이 매우 안정적이라 일반적인 서비스에서는 튜닝할 일이 없습니다.
하지만 어떻게 동작하는지 그리고 어떤 영향이 있는지는 이해하고 있어야 합니다.


다시 돌아와서 앞서 설명한 JVM 메모리 영억중에 GC와 연관된 부분은 Heap 입니다.
Heap은 크게보면 Young, Old, Perm 세 영역으로 나뉘지만 이 중에서 Perm은 JDK8부터 해당 영역이 사라짐
그러므로 아래의 그림처럼 Young과 Old로 구성 됩니다.

Java 메모리 영역

  • Young
    • Eden : 새로 생긴 객체가 저장되는 영역(ex. new User())
    • Survivor0 : 살아남은 객체 임시 보관 영역
    • Survivor1 : 살아남은 객체 임시 보관 영역
  • Old : 여러 GC를 통과해서 계속 참조중인 객체를 저장하는 영역 입니다.

GC의 종류

GC는 크게 2가지 타입이 있습니다.

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

이 2가지 GC가 어떻게 상호작용하느냐에 따라 GC방식에 차이가 나고 성능에도 영향을 줍니다.

GC가 발생하거나 객체가 각 영역에서 다른 영역으로 이동할 때 즉, 모든 쓰레드가 Eden영역을 같이 쓴다면?
동시에 여러 쓰레드가 같은 메모리 주소를 덮어쓸 위험이 있습니다.
그래서 보통 lock을 걸어서 순서대로 처리를 하는데..lock은 느리죠!
결론적으로 여러 쓰레드가 new를 동시에 호출하면 병목(bottleneck)이 생깁니다.

그래서 HotSpot JVM(=Oracle에서 만든 자바 실행 엔진)에서 TLAB(Thread Local Allocation Buffer),
쓰레드 전용 임시 메모리 버퍼(작은 Eden 조각)을 사용합니다.
JVM이 한 덩어리의 Eden을 사용하지 않고 각 쓰레드에게 TLAB를 할당 합니다.

Tread-1 -> TLAB1(Eden의 일부)만 사용
Tread-2 -> TLAB2(Eden의 일부)만 사용
Tread-3 -> TLAB3(Eden의 일부)만 사용
각자 할당되기 때문에 lock을 걸필요가 없습니다.(thread local) -> 속도 UP!


내부동작

  1. JVM이 Heap(Eden)에서 쓰레드마다 TLAB(작은버퍼)를 미리 생성
  2. 각 쓰레드는 new 호출 시 자기 TLAB 안에서만 공간을 차지 함
  3. TLAB가 꽉차면
    1. 새로운 TLAB를 Eden에서 할당 받음
    2. Eden이 꽉 찼다면?
      1. Eden청소(Minor GC 수행) -> Eden 전체(=TLAB을 포함한 Young 영역 전체)를 검사
        1. 참조 중인 객체들은 Survivor0으로 이동
        2. 비참조 인 객체들은 GC로 제거(메모리 회수) 후 새로운 TLAB들을 다시 할당
    3. 다시 한번 GC가 일어나면?
      1. GC의 대상은 Eden+S0(Survivor0)
        1. 참조 중인 객체들은 Survivor1로 이동!
        2. Survivor0은 메모리 비움
        3. 비참조는 역시나 GC로 제거(메모리 회수)
      2.  S0, S1이 반복하면서 메모리 단편화를 방지 합니다.
    4. 여러번의 Minor GC를 했는데 살아남은 객체들은 Old영역으로 promotion(이동, 승진) 합니다. 
      1. MaxTenuringThreshold JVM 옵션(=default 15번) 초과 시 이동
    5. 또는 Survivor의 공간이 부족할 때! 즉, 살아남은 객체가 너무 많아서 못담을때
      1. JVM은 일부를 Old로 promotion 합니다.
    6. 또는 객체가 너무 커서 Eden에 못들어갈때
      1. JVM이 판단해서 바로 Old영역으로 보내버립니다.

ex) new User(); // 아직 참조 중~
      new Order(); // 참조 끊김!

  • Minor GC를 수행
    • User객체는 아직 참조! -> Survivor로 복사
    • Order객체는 참조가 끊김! -> GC로 제거(메모리 회수)
  • Major GC(=Old GC)를 수행
    • 동작 : 스캔(Mark) -> 살아있는 객체만 남기고 죽은 객체는 메모리 해제(Sweep) -> 공간정리(Compact) 
    • Old영역에 있는 객체들은 대부분 계속 참조 되고 있음 -> GC를 자주 돌면 손해 -> 필요할때만 GC 수행
    • 언제가 필요할때인가??
      • Old영역이 가득 찼을 때 
      • 개발자가 강제로 호출(=System.gc()) - 비권장!
      • Full GC가 필요한 상황

Promotion이 너무 자주 일어나면?
Old 영역이 빨리 차서 Major GC가 자주 발생하게 됩니다.
STW(=Stop The World) 시간이 길어지고
애플리케이션에 지연이 발생합니다.

STW(=Stop The World)가 뭐지??
GC가 실행 될 때 JVM 모든 애플리케이션 쓰레드의 실행을 멈추는 상태! -> 안전하게 메모리를 정리 하기 위함!
ex) 클라이언트의 요청을 처리하던 쓰레드, 로그를 쓰던 쓰레드, DB쿼리를 날리던 쓰레드 등 모두 스탑!!
Only GC 쓰레드만 돌아가서 메모리를 정리함!
위에서 언급한 STW가 길어진다는 건 서버가 그 시간동안 아무일도 못한다는 뜻!
그렇다고 서버가 멈춘건 아니고 요청 큐에 쌓이고 있다가 GC가 끝나면 다시 처리 합니다.
즉, 응답지연!!(latency)

tip. Major GC와 Full GC는 같은 용어인가?
답은 nope! Full GC가 조금 더 광범위한 범위를 가지고 있습니다.

  • Major GC
    • Old 영역의 GC(=Olod 영역이 가득 찼을 때 등 수행)
    • STW 발생
  • Full GC
    • Heap 전체(Young+Old)+Non-Heap(Metaspace 등)까지 싹다 정리!(=Heap 전체 메모리 부족 or 시스템 강제호출 등)
    • STW 발생(더 길게 발생)
    • Young+Old영역 모두 가득찼을 때
      • Minor GC로 해결 X, Promotion할 공간 X -> 전체 Heap이 꽉 찬 상태면 Full GC 수행
    • System.gc() 호출
      • 개발자가 코드에서 직접 호출하는 경우 수행
    • Major GC 중 Promotion 실패 시 
      • Old가 꽉 차서 Major GC 수행을 했는데 공간이 확보 X -> Full GC 수행
    • Metaspace(클래스 메타 정보 영역, 메소드 영역) 부족
      • Metaspace = 자바 8 이후 메서드 영역(Method Area)의 새로운 구현체 
      • 클래스 로딩이 잦거나 리플렉션 같은 동적 로딩이 많으면 메타공간(Metaspace)가 꽉 찰수 있음 -> Full GC 수행 
    • JNI(네이티브) 영역이나 DirectBuffer 메모리가 부족
      • JVM 외부(OS 네이티브 메모리)를 뜻 합니다. 
        • Netty나 Kafka 같은 OS 네이티브 메모리를 사용하는 경우
          • Heap 밖의 메모리도 부족하면 Full GC 수행
            • Heap은 여유가 있는데 시스템 메모리(OS)가 점점 줄어든다면 네이티브 메모리를 직접 해제하지 않지만
              해당 메모리를 참조하는 자바 객체를 제거해서 간접적으로 OS 메모리를 회수 합니다.
            • 지표는 네이티브 메모리 확인은 top, htop, free -m, vmstat
              • RSS(Resident Set Size) -> 실제 RAM 사용량
              • RSS - Heap = 네이티브 메모리 사용량
                • 지표의 경우 Actuator + Prometheus 조합을 많이 사용 함.
                  RSS : process_resident_memory_bytes 

                                                          jvm관련은 아래~

SpringBoot Actuator info

 

여기까지가  JVM 메모리 구조부터 GC의 원리, GC의 종류, GC 내부동작등을 익혔습니다.
GC의 여러가지 방식 중 Java8에서는 Parllel GC 방식 등이 있습니다.

그러나 Java버전이 올라가면서 Java9 부터 G1 GC를 널리 사용하다가 
Java18+부터 G1 GC가 디폴트 이지만 ZGC와 Shenandoah도 선택적으로 사용이 가능합니다.
STW가 거의 없다고 하는데 뭐 때문에 그러한지 궁금하긴하네요!

GC는 여기까쥐! 하고 다음에 Golang GC + ZGC 등을 정리해보겠습니다:)

주저리 : Golang GC가 옮겨다니지 않고 마크만하고 필요시에 처리를 한다고 동료랑 이야기하다가 들었는데
Multiplexing I/O 방식이 살짝 떠올랐습니다ㅋㅋ

2025.10.28 - [OpenSource/Redis] - Redis Single Thread, 싱글인데 왜 빠르지??

 

 

 

 

 

 

반응형

'Language > Java' 카테고리의 다른 글

코딩 테스트 - 프래그래머스  (0) 2023.07.18
Producer-Consumer 패턴  (0) 2021.03.18
Runnable과 Callable의 차이는?  (0) 2021.03.18
AtomicInteger&LongAdder&Thread-Safe  (0) 2020.12.01
Java thread에서의 Lock의 종류?  (0) 2020.11.24