자바

[Java] JVM, GC

seaniiio 2025. 11. 17. 06:26

부하테스트를 하다 보니 java의 메모리 구조, GC 등 학습의 필요성을 느꼈다.

 

프로메테우스에서 jvm에 대한 여러 메트릭을 수집해주는데, 이를 기반으로 해석 혹은 튜닝을 하기에 근거가 부족하다는 생각이 들었다. 적어도 대시보드를 이해할 수 있을 정도로 공부를 해야할 것 같아서, 이 참에 한 번 정리해보려 한다.

📍 JVM

JVM(Java Virtual Machine)은 자바 바이트코드를 실행하기 위한 가상 머신이다. 

💡 왜 필요할까?

세상에는 다양한 하드웨어/OS가 존재하는데, C/C++ 프로그램은 플랫폼마다 다시 컴파일해야 했다. (C/C++ 프로그램은 컴파일하면 기계어가 생성된다.) 따라서 운영체제, CPU 아키텍처 바뀌면 다시 컴파일해야 한다는 번거로움이 있다.

 

그런데 자바 코드는 한 번 작성하면 어느 환경에서도 동일하게 실행할 수 있다. JVM만 있으면 바이트코드가 어디서나 동일하게 동작하도록 하드웨어/OS 차이를 보정해준다.

 

💡 JVM 구조

JVM의 구조를 살펴보자.

✓ Class Loader

- 클래스 파일(.class)를 JVM 내부로 가져온다

✓  실행 엔진(Execution Engine)

- Interpreter
- JIT 컴파일러
- Garbage Collector

✓  Runtime Data Area

- JVM 메모리
heap
  - 동적인 객체들을 저장하는 곳
  - Young Generation: Eden, Survivor
  - Old Generation
stack
  - 스레드마다 1개씩
  - 메서드 호출 정보, 지역 변수
- method(metaspace)
  - 클래스 정보 저장
  - static 변수, contant pool, JIT된 코드 ...
pc register
  - 현재 실행중인 명령의 주소
native method stack
  - C/C++ 같은 네이티브 언어에 대한 스택

Person p = new Person();

 

여기서 p라는 변수는 stack에, new Person()으로 실제 생성된 객체는 heap에 저장

✓  JNI

- JVM <-> C/C++ 네이티브 코드 연결 장치

✓   Native Method Libraries

- OS별 구현들

📍 JVM 메모리 - Heap 영역

동적으로 할당된 객체는 heap 영역에 저장된다. heap 영역은 크게 Young, Old 두 영역으로 나눌 수 있다. 왜 이렇게 나눌까?


자바에서 사용되는 대부분의 객체는 금방 죽는다.(더이상 참조되지 않는 경우 죽었다고 표현) 예를 들어 HTTP 요청 처리 중 생성되는 DTO는 요청을 처리하고 나면 사용되지 않는다. 그런데 스프링 싱글톤 빈은 애플리케이션이 종료될때까지 살아있어야 한다. heap 메모리가 찰 때마다 뒤에서 설명할 내용인 GC를 통해 비워줘야 한다.

 

비워준다는 것은 죽은 객체를 할당 해제하는 것이고, 상황에 따라 JVM 전체 동작을 멈춰야 하는 경우도 존재한다.

 

그런데 대부분의 할당 해제가 필요한 객체들은 새로 생긴 객체들일 것이고, 오래 살아남은 객체들도 항상 죽었는지 확인해야 한다면 비효율적일 것이다. 따라서 Young, Old로 나눠서 GC가 효율적으로 동작하게 한다.

💡 Young Generation

Young 영역은 다시 Eden, Survivor로 나뉜다.

✓ Eden

- 객체가 처음 생성되는 곳
- 꽉 차면 Minor GC 발생

✓ Survivor(S0, S1)

- GC에서 살아남은 객체들이 Old Generation으로 가기 전에 임시로 머무는 공간
- 아직은 그렇게 오래 살진 않았지만, 한 번 죽을 위기를 넘긴 애들
- 새로 생긴 객체는 GC에서 살아남을때마다 Eden → S0 → S1 → S0 → … 이런식으로 왔다갔다 하면서 살아남다가, 일정 횟수 이상 살아남으면 Old로 승격된다.

✓ S0, S1 두 영역이 필요한 이유

처음 heap 영역 구조를 알게됐을 때, "Eden, Survivor만 사용하면 되지 않을까?" 라는 의문이 들었었다. 이에 대해 찾아본 결과, 두 영역으로 운영해야 하는 이유는 아래와 같다. (아래의 GC 관련 내용을 먼저 읽고 오면 이해하기 편할 것이다.)


만약 Eden과 Survivor만 있다면, 한 번의 GC에서 Eden에서 살아남은 객체와 Survivor에서 살아남은 객체를 Survivor에 재배치하고, Survivor에서 죽은 객체를 해제하는 과정이 필요할 것이다. 이 때 Survivor에는 생존한 객체들이 남아있을 것이기에, Eden에서 Survivor에 객체를 복사할 때 주소 충돌이 발생할 수 있다.(그냥 연속적으로 할당하면 안 된다) 그리고 할당 해제도 각각의 객체마다 해줘야 한다. 마지막으로 할당 해제된 객체들로 인해 메모리의 빈 공간이 연속되지 않고 단편화가 발생한다. (mark and sweep는 이 단편화를 없애기 위해 compact 과정이 존재하는데, compact는 객체의 주소를 바꾸는 과정이다 보니 stop-the-world 필요)

 

그런데 S0, S1으로 나뉜다면 어떻게 될까? S0에서 S1로 복사한다고 하면, S0에서 살아남은 객체들과 Eden에서 살아남은 객체들은 S1에 연속적으로 append하면 된다. (S1은 비어있기 때문) 그리고 S0에서 죽은 객체들을 일일이 할당해제할 필요 없이, S0 공간 전체를 할당 해제하면 된다.

💡 Old Generation

- 일정 횟수 Minor GC에서 살아남은 객체들이 위치하는 영역

- 꽉 차면 G1 기준 Mixed GC(Young + Old 일부) 발생
- 최악의 경우 Full GC -> 긴 Stop The World

📍 GC 알고리즘

이미 위에서도 언급했지만, GC는 Garbage Collector의 약자로, 더이상 사용하지 않는 객체들을 비워줌으로써 힙메모리 영역을 확보하는 과정이다.

💡Copying GC

살아있는 객체를 새로운 공간으로 복사한다. 기존 공간에는 죽은 객체만 남아있기 때문에 영역 자체를 통째로 비울 수 있다.

✓ 장점

죽은 객체가 섞인 기존 공간은 그냥 버리고, 새 공간에 연속적으로 객체를 할당하기 때문에 단편화가 발생하지 않는다.

✓ 단점

두 개의 공간이 필요하다.(from, to)

💡 Mark & Sweep

1. Mark 단계: GC Root(스레드 스택, static 변수, JNI 등)에서 시작해서 참조가 이어져 있는 객체들을 따라가며 “살아있는 객체”에 마킹을 한다.

2. Sweep 단계: 표시 없는 객체를 할당 해제한다.

✓ 장점

- 두 공간(from/to)이 필요 없다
- 오래 사는 객체가 많은 Old 영역에서 효율적

✓ 단점

- 죽은 객체를 각각 할당해제하기 때문에 빈 공간이 생긴다.(단편화)
- sweep 비용 존재 

💡 Mark & Compact

Mark & Sweep 방식에서 compact 단계를 추가해, 살아있는 객체를 앞쪽으로 모아 연속 공간을 만든다.

✓ 장점

- 단편화 해결

✓ 단점

- 객체 이동 비용 큼 → STW 길어짐

✓ Compact 과정이 왜 비용이 클까?

- 모든 객체의 주소를 재배치해야 한다 -> 레퍼런스가 가리키는 주소를 전체 JVM에서 싹 다 업데이트해야 한다.

- 실행 중인 스레드들이 계속 객체를 읽고 쓰고 있는데, 그 객체를 GC가 다른 주소로 옮겨버리면 참조가 전부 깨지기 때문에, 스레드를 잠시 멈춰서(world stop) "객체 이동 + 참조 재작성”을 원자적으로 처리해야 한다.

📍 G1GC

G1 GC는 힙메모리 영역을 작은 Region 단위로 쪼개고, 각 Region의 “가비지 비율”을 계산해서, 가장 효율적인 순서(=Garbage-First)로 Young/Old 영역을 GC하는 알고리즘이다.

 

각 region별로 eden, survivor, old 역할이 정해진다. (다른 GC 방식와 다르게 eden, survivor, old 영역이 물리적으로 연속되지 않는다)

 

G1GC는 위에서 알아본 GC 알고리즘을 다양하게 조합하여 사용한다.

 

💡 Minor GC

Young 영역에 대해 일어나는 GC로, 가볍고 자주 일어난다. Copying GC 알고리즘으로 동작한다.

💡 Mixed GC

Minor GC를 수행하고 Old Region 일부를 동시에 compact한다. mark를 하고, 각 region별로 GC를 하기 효율적인 region에 대해서만 압축한다.

✓ 단계 1: Initial Mark (STW)

- Young GC와 함께 수행(Young GC도 어차피 STW 있어서, pause 추가 비용을 줄이기 위해)
- GC Root(스택, 레지스터, class metadata 등)에서 reachable한 Old 객체들을 mark 시작점으로 등록
- 살아있는 Old 객체 그래프 탐색의 루트가 만들어짐
- Old 루트만 잡기 때문에 Old 전체를 탐색하는 것은 아님

 단계 2: Concurrent Marking

- 여기서 Concurrent는, 마킹하는동안 stop the world를 발생시키지 않고, 애플리케이션도 동시에 동작하는 것을 의미
- 전체 Old 영역을 거의 스캔함 → 시간이 오래 걸릴 수 있지만, 애플리케이션이 계속 실행됨.
- 각 Region별 살아있는 객체 비율 계산 → region live percentage

- 이걸 기반으로 “어떤 region을 GC하면 가장 효율적인가?” 판단
- SATB(Snapshot-At-The-Beginning) 기록
  - concurrent -> marking 중 참조가 사라지면 그 old reference를 SATB buffer에 기록, GC가 나중에 그 old reference를 기반으로 mark 동기화
  - 따라서 remark 단계에서 할 일이 매우 적어짐 → pause 극도로 짧아짐

 단계 3: Remark (STW)

Concurrent Mark가 돌아가는 동안 놓친 참조 변화(정확히는 SATB buffer)를 처리하는 단계
- marking 중 변경된 부분 finalization
- 사용자 스레드에 의해 객체의 참조 변화가 발생시 SATB 버퍼에 기록하고 버퍼에 기록된 내용을 기반으로 최종 확인

 단계 4: Cleanup

- 가비지가 거의 100%인 Region을 바로 free
- 약간 섞여 있는 Region은 Mixed GC 후보로 예약

💡 Full GC

Old 영역 전체에 대해 Mark-Compact 알고리즘으로 GC를 수행한다. 매우 무겁고 거의 발생하면 안 되는 이벤트로, 발생을 최소화시켜야 한다.

📍 GC 로그 확인하기

메트릭으로만 GC를 확인했었는데, jvm 설정으로 GC에 대한 정확한 시점과 상세한 로그를 확인할 수 있었다.

 

직접 확인해보기 위해 인텔리제이의 vm 변수 설정에 아래 설정을 추가해줬다.

-Xms512m -Xmx512m -Xlog:gc*:stdout

 

참고로 자바를 로컬 컴퓨터에서 실행하기 때문에 힙영역이 너무 크게 잡힐까봐, OS 메모리 2GiB 기준 jvm 힙영역 기본 설정인 512MB로 설정해줬다. 이렇게 설정하고 애플리케이션을 실행시킨 뒤, 부하테스트를 실행해봤다.

 

GC로 인한 Region의 변화 등 아주 상세한 로그를 확인할 수 있었다. 그런데 위의 설정으로는 Minor GC밖에 일어나지 않아서, 힙 영역을 128MB, 64MB로 낮춰서 테스트해봤다. 그 결과 아래와 같이 Full GC에 대한 로그도 확인할 수 있었다.

📍 마무리

부하테스트를 하면서 다양한 튜닝을 해볼 때, 힙메모리 영역도 늘려봤다. 그런데 힙 메모리를 늘리기 전 GC 메트릭을 다시 확인해보니 힙영역을 늘릴 필요가 없었다는 것을 알 수 있었다.

 

당시의 GC 메트릭인데, Minor GC만 일어나는 것을 확인할 수 있다. JVM과 GC를 찍먹해본 뒤 다시 보니, Minor GC만으로 충분히 힙영역을 관리할 정도가 된다면 힙메모리를 늘릴 필요는 없다고 본다. Full GC가 자주 발생하는 경우 힙영역을 늘려한다는 신호로 받아들일 것 같다.

 

그래서 Full GC 발생할 경우 로그 / 알림 남기도록 하는 설정을 추가하면 좋을 것 같다는 생각이 들었다. 프로메테우스로 수집한 GC 관련 메트릭을 기반으로 grafana alert rule을 설정해주면 좋을 것 같다. (미래의 나 화이팅)