JVM의 메모리 관리법

CorinBeom·2025년 10월 15일
0

CS

목록 보기
21/22
post-thumbnail

이번 게시글에서는 JVM이 메모리를 어떻게 관리하는지 알아보자

우선 시작하기 전에, JVM의 메모리 구조를 이해해야 GC(Garbage Collection)
왜 필요한지 감이 잡힌다.

GC (Garbage Collection)

프로그래밍 언어에서 메모리 관리는 필수적인 개념이다.
우리가 객체를 생성하면 메모리가 할당되고, 더 이상 그 객체가 필요하지 않다면 그 메모리를 다시 회수해야 한다.
문제는 이 “언제, 어떻게 회수할 것인가”를 누가 담당하느냐이다.

수동 메모리 관리 언어 (C, C++)

C언어나 C++에서는 개발자가 직접 메모리 할당 해제를 해줘야한다

C언어의 메모리 할당 해제

#include <stdio.h>
#include <stdlib.h>

int main() {
    int *num = (int*)malloc(sizeof(int)); // 메모리 직접 할당
    *num = 10;

    printf("num = %d\n", *num);

    free(num); // 직접 해제해야 함
    return 0;
}

malloc() 은 Heap 영역에 메모리를 할당하고,
반드시 free() 로 해제해야 한다.

이 과정을 잊어버리면 메모리 누수(memory leak) 가 발생하고,
이미 해제한 메모리를 다시 접근하면 세그멘테이션 오류(segmentation fault) 가 발생한다.
C언어에 대한 메모리 관리 포스팅이 있다. 그 부분에서 더 세세하게 확인할 수 있다.

C++의 메모리 할당 해제

#include <iostream>
using namespace std;

int main() {
    int* num = new int(10); // 메모리 직접 할당
    cout << "num = " << *num << endl;

    delete num; // 반드시 해제해야 함
    return 0;
}

C++에서는 new / delete 문법이 생겼지만, 본질은 같다.
여전히 개발자가 직접 관리해야 하며,
복잡한 코드에서는 해제를 깜빡하거나 예외로 인해 건너뛰는 경우가 많다.


자동 메모리 관리 언어 (Java, Python, Kotlin 등)

이러한 위험을 줄이기 위해 등장한 개념이 바로
가비지 컬렉션(Garbage Collection, GC) 이다.

Java, Python, Go, C# 등은 GC를 사용하여
개발자가 메모리 해제에 신경 쓰지 않아도 된다.
런타임이 더 이상 사용되지 않는 객체(garbage) 를 자동으로 찾아내어 정리한다.

User user = new User("Alice");
user = null; // 이제 "Alice" 객체는 참조되지 않음

user가 가리키던 객체는 더 이상 어디에서도 접근할 수 없다.
JVM은 이 객체를 “가비지”로 인식하고, 이후 GC가 실행될 때 메모리를 회수한다.

GC는 단순히 오래된 객체를 제거하지 않는다.
“도달 가능한 객체(reachable object)” 만을 살려두고,
그 외의 객체는 제거 대상으로 본다.

  • 루트(root) 객체에서 시작

    • 스택 변수, static 변수, 실행 중인 스레드 등
  • 참조 그래프를 따라가며 “도달 가능한 객체”를 탐색

  • 더 이상 연결되지 않은 객체는 가비지로 간주

JVM GC

Java 프로그램이 실행될 때, JVM은 여러 메모리 영역을 만들어 관리한다.
이 중 GC가 관여하는 영역은 Heap 영역이다.
Heap에는 모든 객체(instance) 들이 저장된다.

JVM은 이 Heap을 효율적으로 관리하기 위해 세대별(Generational) 구조를 사용한다.
즉, 객체의 “나이”에 따라 관리 전략을 달리하는 것이다.

Heap
 ├── Young Generation
 │     ├── Eden
 │     ├── Survivor 0 (S0)
 │     └── Survivor 1 (S1)
 └── Old Generation

🔹 Young Generation

새로 생성된 객체가 위치하는 영역이다.
대부분의 객체는 생성된 후 얼마 지나지 않아 사용이 끝나므로,
이 영역에서 짧은 생명 주기의 객체를 빠르게 회수한다.

Young Generation 내부는 다시 세 구역으로 나뉜다.

Eden: 새 객체가 처음으로 생성되는 곳

Survivor 0 / 1 (S0, S1): GC 후 살아남은 객체들이 임시로 이동하는 곳

Young 영역이 가득 차면 Minor GC가 발생한다.
살아남은 객체는 Survivor로 이동하고,
일정 횟수 이상 살아남으면 Old Generation으로 승격된다(Promotion).

🔹 Old Generation

여러 번의 Minor GC를 견뎌 살아남은 객체들이 모이는 곳이다.
즉, 장수 객체를 보관한다.
Old Generation은 Young보다 크고, GC도 상대적으로 적게 일어나지만 더 느리다.

이 영역이 가득 차면 Major GC (또는 Full GC) 가 발생한다.
이때는 Heap 전체를 검사하기 때문에
애플리케이션이 일시 중단(Stop-The-World) 된다.

🔹 Metaspace (참고)

Java 8 이후부터는 클래스 메타데이터(Class Metadata)가
PermGen 대신 Metaspace 영역에 저장된다.
이 영역은 JVM 메모리가 아닌 네이티브 메모리(native memory) 를 사용한다.

Minor GC

Minor GC는 Young Generation 영역에서 일어난다.
즉, 새로 생성된 객체들이 사라질 때 발생하는 짧고 빠른 GC다.

🔸 동작 과정

  1. 객체 생성
  • 모든 새 객체는 처음에 Eden 영역에 생성된다.

  • 프로그램이 실행되며 객체가 계속 쌓이면, Eden 영역은 곧 가득 찬다.

  1. Eden이 가득 차면 GC 발생
  • GC가 실행되어, Eden의 객체 중 여전히 참조되고 있는 것만 Survivor 영역으로 이동한다.

  • 이때 도달 불가능한 객체는 메모리에서 제거된다.

  1. Survivor 교체(Swap)
  • Survivor 영역은 두 개(S0, S1)로 존재하며, 매 GC 때마다 역할이 바뀐다.

  • 예를 들어, S0 → S1로 복사하면서 살아있는 객체만 옮긴다.

  1. Promotion (승격)
  • 여러 번의 Minor GC 후에도 여전히 살아남은 객체는
    “이 객체는 오래 쓰이겠구나”라고 판단되어 Old Generation으로 이동한다.

💡 대부분의 GC는 Minor GC다.
즉, Java 애플리케이션의 대부분의 “메모리 청소”는 Young Generation에서 일어난다.

Major GC

Major GC 혹은 Full GC는 Old Generation에서 일어난다.
즉, 오래된 객체들 중에서도 더 이상 참조되지 않는 것들을 정리한다.

🔸 동작 과정

  1. Old Generation이 가득 차면 트리거됨
  • Minor GC를 여러 번 거쳐 Promotion된 객체들이 계속 쌓이면
    결국 Old Generation도 한계에 다다른다.
  1. 전체 Heap 검사
  • Major GC는 Old뿐 아니라 Heap 전체(Eden, Survivor, Old)를 모두 스캔하기도 한다.

  • 이때 Stop-The-World(모든 스레드 일시 중단) 이 발생한다.

  1. Mark → Sweep → Compact
  • Mark : 살아있는 객체 표시

  • Sweep : 도달 불가능한 객체 제거

  • Compact : 남은 객체들을 한쪽으로 모아 메모리 단편화 방지

  1. 메모리 재사용 가능 상태로 정리 완료

⚠️ Major GC는 자주 일어나면 성능 저하의 핵심 원인이 된다.
그래서 대부분의 GC 튜닝은 “Full GC를 줄이는 방향”으로 이루어진다.


위에서 설명한 Minor GC와 Major GC는 JVM 내부에서 일어나는
Young Generation / Old Generation 청소 과정을 단순화한 모델이다.

실제 현대 JVM에서는 G1 GC처럼 Region 기반으로 세분화된 GC가 동작한다.
이 G1 GC는 내부적으로 Young GC(Minor)와 Old GC(Major)를 모두 포함하며,
큰 객체를 처리할 때는 Humongous Allocation이라는 특수한 이벤트도 발생한다.

다음은 그 실제 로그를 분석한 사례다.

실제 GC 로그 해석 사례 — G1 GC와 Humongous Allocation

이번에는 직접 작성한 예제를 통해 GC 로그를 해석하는 방법을 알아보자.
다음 코드는 1MB 크기의 배열을 10만 번 반복해서 생성하는 간단한 프로그램이다.

public class GCDemo {
    public static void main(String[] args) {
        for (int i = 0; i < 100000; i++) {
            // 1MB 크기의 배열을 계속 생성
            byte[] data = new byte[1024 * 1024];
        }
    }
}

실행 명령어 :

java -Xms64m -Xmx64m -Xlog:gc* GCDemo

실행 결과는 다음과 같다 :

[11.599s][info][gc] GC(15286) Pause Young (Concurrent Start) (G1 Humongous Allocation) 27M->1M(64M) 0.624ms
[11.599s][info][gc] GC(15287) Concurrent Undo Cycle
[11.599s][info][gc] GC(15287) Concurrent Undo Cycle 0.099ms
[11.600s][info][gc] GC(15288) Pause Young (Concurrent Start) (G1 Humongous Allocation) 27M->1M(64M) 0.377ms
[11.600s][info][gc] GC(15289) Concurrent Undo Cycle
...(반복)

이 로그는 G1 GC가 수행한 Young GC(Minor GC) 계열의 이벤트이며,
큰 객체(Humongous Object)를 할당하기 위해 발생한 특수한 형태의 GC다.

로그 구조 해석 :

구간의미
[11.599s]JVM 실행 후 11.599초 시점에 GC 발생
GC(15286)15286번째 GC 수행 (아주 자주 발생 중)
Pause YoungYoung 영역에서의 GC 수행
(Concurrent Start)백그라운드 마킹 시작 단계
(G1 Humongous Allocation)큰 객체(1MB) 때문에 GC가 트리거됨
27M->1M(64M)GC 전 27MB → GC 후 1MB (전체 Heap 64MB 중)
0.624msGC 소요 시간 (0.6밀리초)

큰 객체(Humongous Object)를 생성하려다 공간이 부족해
Young GC가 반복적으로 발생한 상황이다.

💡 “Humongous Allocation”이란?

G1 GC는 Heap을 Region 단위(기본 1~32MB) 로 나눠서 관리한다.
그런데 객체 크기가 Region 크기의 50%를 넘으면
“일반 Young 영역에 둘 수 없다”며 별도의 Humongous Region 을 만들어 관리한다.

이 예제의 경우,

  • Heap 크기: 64MB

  • Region 크기: 약 1MB

객체 크기: 1MB → Region 크기와 동일

즉, JVM 입장에서는 매번 너무 큰 객체를 새로 만들어야 해서
매 객체 생성 시마다 GC가 돌게 된다.

⚙️ “Concurrent Undo Cycle”의 의미

[11.599s][info][gc] GC(15287) Concurrent Undo Cycle

이는 G1의 백그라운드 마킹 단계 중 일부로,
GC가 병렬(Concurrent)로 Old 영역을 탐색하며
마킹 정보를 되돌리거나 수정하는 과정이다.
0.05~0.1ms 정도의 짧은 수행으로, 정상적인 동작이다.

실제로 실행 명령어를 입력하면 엄청 많은 로그가 나온다.
자주 발생하는 이유들을 정리해보았다.

원인설명
🧠 Heap 크기가 너무 작다64MB Heap 안에서 1MB짜리 객체를 반복 생성
📦 객체 크기가 크다Region 크기(1MB)와 거의 동일 → Humongous Allocation 발생
⚙️ G1 정책상Humongous 객체는 별도 Region을 새로 만들어야 함
🔁 결과매번 GC가 실행되어 로그가 수천 번 출력됨

즉, GC가 폭주한 것이 아니라, 작은 Heap과 큰 객체의 조합으로 인해
G1이 지속적으로 공간을 확보하며 정상적으로 동작하고 있는 상황이다.


GC는 단순한 개념이 아니라 성능과 직결되는 시스템 레벨의 요소다.

작은 Heap과 큰 객체 조합이 얼마나 많은 GC를 유발하는지,
로그를 직접 보며 확인해보면 “튜닝이 왜 중요한지” 한눈에 이해된다.

결국 GC를 잘 이해하는 것은 단순한 이론이 아니라,
안정적인 서버 운영과 효율적인 리소스 관리의 출발점이다.

profile
Before Sunrise

0개의 댓글