포스트

JVM과 메모리 관리

JVM과 메모리 관리

자바의 특징

자바는 단순히 문법이 모여있는 도구가 아닙니다. 1995년 탄생 당시의 시대적 요구(인터넷의 보급과 이기종 환경)를 해결하기 위해 고안된 설계 철학의 결정체입니다.

플랫폼 독립성과 JVM(Write Once, Run Anywhere)

자바의 가장 위대한 업적이자 정체성입니다. C나 C++처럼 특정 운영체제(OS)나 하드웨어에 종속적인 기계어로 바로 컴파일하지 않고, 바이트코드(Bytecode, .class 파일) 라는 중간 형태의 코드로 컴파일합니다. 이 바이트코드는 JVM(Java Virtual Machine, 자바 가상 머신) 이 설치된 어떤 환경에서든 동일하게 실행됩니다.

개발자는 윈도우에서 코드를 짜고, 리눅스 서버에 배포하며, 맥 OS에서 테스트할 수 있습니다. 운영체제 파편화에 따른 고통을 해소했습니다. 하지만, 하드웨어와 직접 소통하지 않고 JVM이라는 ‘통역사’를 거쳐야 하므로, C/C++ 같은 네이티브 컴파일 언어에 비해 초기 실행 속도가 느리고 메모리 사용량이 많다는 태생적인 단점이 있습니다.

철저한 객체지향 언어(Object-Oriented)

자바는 태생부터 객체지향 패러다임을 강제하도록 설계되었습니다. 모든 함수와 변수는 클래스(Class) 내부에 존재해야 하며, 글로벌 함수나 글로벌 변수를 허용하지 않습니다. (캡슐화, 상속, 다형성을 언어 차원에서 강력하게 지원합니다.)

단순한 “Hello World”를 출력하기 위해서도 클래스와 메서드를 정의해야 하는 장황함(Verbosity) 이 있습니다. 또한, 성능 최적화를 위해 완전한 객체가 아닌 원시 타입(Primitive Type: int, double 등) 을 예외적으로 남겨두었는데, 이는 ‘모든 것이 객체’라는 순수 객체지향의 관점에서는 설계상 타협을 한 부분입니다.

자동 메모리 관리와 가비지 컬렉터(Garbage Collection, GC)

C/C++에서는 개발자가 직접 malloc, free 등을 사용해 메모리를 할당하고 해제해야 했지만, 자바는 이 역할을 가비지 컬렉터(GC) 라는 백그라운드 프로세스에 위임했습니다. 더 이상 참조되지 않는(사용하지 않는) 객체를 주기적으로 찾아내어 메모리에서 자동으로 정리합니다.

메모리 누수(Memory Leak)나 잘못된 메모리 참조로 인한 치명적인 버그(Segfault 등)를 줄여, 개발자가 비즈니스 로직에만 집중할 수 있게 해 주었습니다.

GC가 언제 실행될지 개발자가 정확히 제어할 수 없습니다. 메모리를 정리하는 동안 애플리케이션이 잠시 멈추는 ‘Stop-the-World’ 현상이 발생하며, 이는 실시간성(Real-time)이 극도로 중요한 시스템(금융 거래, 항공 제어)에서는 치명적인 약점이 될 수 있습니다.

정적 타입 시스템 (Static Typing)과 강타입 (Strong Typing)

변수를 선언할 때 반드시 그 데이터 타입(int, String 등)을 명시해야 하며, 컴파일 시점에 타입의 정합성을 엄격하게 검사합니다.

개발자의 단순 오타나 타입 불일치 오류를 코드를 실행하기도 전(Compile-time)에 잡아냅니다. 대규모 협업 환경에서 시스템의 안정성을 보장하는 든든한 안전망입니다. 하지만, 파이썬(Python)이나 자바스크립트(JavaScript) 같은 동적 타입 언어에 비해 유연성이 떨어지고 코드를 작성하는 속도가 상대적으로 느릴 수 있습니다.

자바 가상 머신(Java Virtual Machine, JVM)

개발자가 작성한 자바 소스 코드(.java)는 자바 컴파일러(javac)에 의해 바이트코드(.class)로 변환됩니다. JVM은 이 바이트코드를 운영체제가 이해할 수 있는 기계어로 번역하고 실행하는 역할을 합니다.

JVM의 내부는 크게 클래스 로더, 런타임 데이터 영역, 실행 엔진이라는 세 가지 핵심 컴포넌트로 나뉩니다.

클래스 로더(Class Loader)

컴파일된 바이트코드(.class 파일)들을 모아서 OS로부터 할당받은 메모리 영역(Runtime Data Area)으로 적재(Load)하는 역할을 합니다.

자바는 애플리케이션이 실행될 때 모든 클래스를 한 번에 메모리에 올리지 않고, 클래스가 처음 참조되는 순간 동적으로 메모리에 로드(Dynamic Loading) 합니다.

애플리케이션 구동 시간(Cold Start)을 단축시키고 메모리를 효율적으로 사용하게 해 줍니다. 하지만 처음 클래스를 로딩하는 시점에는 약간의 지연이 발생할 수 있다는 트레이드오프가 있습니다.

클래스 로더의 로딩 과정(Class Loading Process)

컴파일을 통해 .class 파일들이 준비되었다면, 이제 애플리케이션을 실행할 차례입니다. JVM은 모든 클래스를 한 번에 올리지 않고 필요한 순간에 동적으로 메모리에 적재합니다.

이 작업을 수행하는 클래스로더 시스템은 크게 로딩(Loading) → 링크(Linking) → 초기화(Initialization) 의 3단계를 거칩니다.

1단계 : 로딩(Loading)

하드 디스크 등에 저장된 .class 파일을 읽어 바이트코드를 JVM의 메서드 영역(Method Area) 에 적재하는 단계입니다. 이 과정은 클래스로더 위임 모델(Delegation Model) 이라는 독특한 계층 구조를 통해 이루어집니다.

  • 부트스트랩 클래스로더(Bootstrap ClassLoader) : JVM 구동을 위한 가장 핵심적인 표준 API 클래스(java.lang.Object, String)를 로드합니다. (최상위 부모)
  • 플랫폼(확장) 클래스로더(Platform ClassLoader) : 자바 기본 API 외의 확장 클래스들을 로드합니다. (Java 9 이전에는 Extension ClassLoader로 불렸습니다.)
  • 애플리케이션(시스템) 클래스로더(Application ClassLoader) : 개발자가 직접 작성한 클래스 파일들이나 외부 라이브러리(ClassPath에 있는 파일들)를 로드합니다.

위임 원칙(Parent-Delegation) : 어떤 클래스를 로드해야 할 때, 하위 클래스로더는 본인이 직접 찾지 않고 무조건 부모 클래스로더에게 로딩 요청을 위임합니다. 부모가 찾지 못하면 그때서야 자식이 찾습니다.

2단계 : 링크(Linking)

로드된 바이트코드가 유효한지 검증하고, 실행에 필요한 준비를 하는 단계입니다.

  • 검증(Verification) : 바이트코드가 자바 언어 명세 및 JVM 규격을 잘 따르고 있는지 철저하게 검사합니다. (악의적인 컴파일러로 조작된 바이트코드인지 확인)
  • 준비(Preparation) : 클래스가 필요로 하는 정적(static) 변수들을 위한 메모리를 할당하고, 이들을 기본값(디폴트 값) 으로 초기화합니다. (주의: 코드에 작성된 명시적 값이 아니라 int는 0, boolean은 false, 참조형은 null 등으로 뼈대만 잡습니다.)
  • 분석(Resolution) : 클래스의 런타임 상수 풀(Constant Pool) 내의 심볼릭 레퍼런스(이름만으로 참조)를 실제 메모리 주소를 가리키는 다이렉트 레퍼런스로 교체합니다.
3단계 : 초기화(Initialization)

클래스 로딩의 마지막 단계로, 개발자가 코드에 명시한 값으로 정적(static) 변수들을 실제 초기화하고, static 블록을 실행합니다. 이 시점부터 클래스는 JVM 상에서 완벽하게 사용할 준비가 끝납니다.

런타임 데이터 영역(Runtime Data Area)

JVM이 운영체제로부터 할당받은 ‘자신의 메모리 공간’입니다. 실무에서 성능 이슈나 메모리 에러(OOM)가 발생했을 때 가장 먼저 들여다봐야 하는 곳입니다. 크게 5가지 영역으로 나뉩니다.

  • 메서드 영역(Method Area / Metaspace) : 클래스 정보, 상수(Constant), 정적(static) 변수, 메서드의 바이트코드 등이 저장되는 영역입니다. 모든 스레드가 공유합니다.
  • 힙 영역(Heap Area) : new 키워드로 생성된 객체(인스턴스)와 배열이 동적으로 생성되는 공간입니다. 가비지 컬렉터(GC)의 주요 관리 대상이 되며, 모든 스레드가 공유합니다.
  • 스택 영역(Stack Area) : 메서드가 호출될 때마다 생성되는 프레임(Frame)이 저장됩니다. 이 안에는 지역 변수(Local Variables), 매개변수, 리턴 값 등이 담깁니다. 각 스레드마다 개별적으로 생성되며, 메서드 실행이 끝나면 즉시 삭제되므로 매우 빠르고 효율적입니다.
  • PC 레지스터(PC Register) : 현재 스레드가 실행 중인 JVM 명령어의 주소를 가리킵니다. (CPU의 PC 레지스터와 유사한 역할)
  • 네이티브 메서드 스택(Native Method Stack) : 자바가 아닌 다른 언어(C/C++ 등)로 작성된 코드를 실행하기 위한 공간입니다.

실행 엔진(Execution Engine)

클래스 로더를 통해 메모리(런타임 데이터 영역)에 적재된 바이트코드를 실제로 실행하는 장치입니다.

  • 인터프리터(Interpreter) : 바이트코드를 명령어 단위로 한 줄씩 읽어서 기계어로 번역하고 실행합니다. 바이트코드를 읽자마자 바로 실행을 시작할 수 있어 초기 구동 시간(Cold Start) 이 빠르지만, 반복되는 코드도 매번 해석해야 하므로 전체적인 속도는 느립니다.
  • JIT 컴파일러(Just-In-Time Compiler) : 인터프리터의 단점을 보완하기 위해 등장했습니다. 반복적으로 실행되는 ‘핫 스팟(Hot Spot)’ 코드를 발견하면, 런타임에 바이트코드 전체를 네이티브 기계어로 직접 컴파일해 버립니다. 그리고 이 기계어를 코드 캐시(Code Cache) 영역에 저장해 둡니다. 이후에는 해석 없이 컴파일된 기계어를 바로 실행하므로 성능이 비약적으로 향상됩니다.
  • 가비지 컬렉터(Garbage Collector, GC) : 더 이상 참조되지 않는 힙 영역의 객체들을 찾아내어 자동으로 메모리를 회수합니다.

Escape Analysis

최신 JVM의 JIT 컴파일러는 놀라운 최적화를 수행합니다. 메서드 안에서 생성된 객체가 메서드 밖으로 빠져나가지(Escape) 않는다고 판단되면, 힙(Heap) 영역이 아닌 스택(Stack) 영역에 객체를 직접 할당해버립니다. 이를 통해 GC의 부담을 혁신적으로 줄여줍니다.

가비지 컬렉터(Garbage Collector, GC)

가비지 컬렉터는 JVM의 힙 영역(Heap Area) 에서 동적으로 할당되었던 메모리 중, 애플리케이션이 더 이상 사용하지 않는 객체(Garbage)를 찾아내어 메모리를 자동으로 회수하는 데몬 스레드입니다.

핵심 동작 알고리즘 : Mark and Sweep(그리고 Compact)

GC가 쓰레기를 찾아내고 치우는 과정은 기본적으로 ‘Mark and Sweep’ 이라는 2단계(혹은 3단계) 알고리즘으로 동작합니다.

  • Mark(식별) : 메모리 정리가 시작되면, GC는 GC Root라는 최상위 노드들(스택의 지역 변수, 메서드 영역의 static 변수 등)에서 시작하여 참조를 따라가며 연결된 모든 객체를 탐색합니다. 이때 연결이 닿은 객체들은 ‘사용 중’이라고 표시(Mark)합니다.
  • Sweep(청소) : 전체 힙 메모리를 쭉 훑으면서, Mark 단계에서 표시되지 않은(즉, GC Root와 연결이 끊어진) 객체들을 메모리에서 완전히 해제(Sweep)합니다.
  • Compact(압축 - 선택적) : 메모리를 군데군데 해제하다 보면 빈 공간이 파편화(Fragmentation)됩니다. 이를 해결하기 위해 살아남은 객체들을 힙 메모리의 한쪽으로 촘촘하게 몰아넣어 연속된 여유 공간을 확보합니다.

Stop-The-World (STW)

GC가 이 작업을 수행하는 동안에는 메모리의 참조 상태가 변경되면 안 되기 때문에, GC를 실행하는 스레드를 제외한 애플리케이션의 모든 스레드가 작업을 멈춥니다. 이를 Stop-The-World (STW) 현상이라고 부릅니다. 이 시간이 길어지면 사용자는 서비스가 버벅거리거나 응답이 없다고 느끼게 되며, 트래픽이 많은 실무 환경에서는 이 STW 시간을 0.1초라도 줄이는 것이 아키텍처의 핵심 목표가 됩니다.

GC Root의 정확한 의미

“무엇이 참조의 시작점인가?”를 아는 것은 메모리 누수를 추적하는 핵심입니다. 자바에서 GC Root가 될 수 있는 것은 주로 (1) 스택 영역에 있는 지역 변수/매개 변수, (2) 메서드 영역에 있는 정적(static) 변수, (3) JNI(Java Native Interface)에 의해 생성된 네이티브 참조 등입니다. static 컬렉션에 무심코 데이터를 계속 쌓으면 Full GC도 이를 치울 수 없어 결국 OutOfMemoryError가 터지게 됩니다.

약한 세대 가설(Weak Generational Hypothesis)과 힙 구조

JVM 설계자들은 통계적으로 애플리케이션을 분석하다가 두 가지 중요한 사실을 발견했습니다. 이를 약한 세대 가설이라고 합니다.

  1. 대부분의 객체는 생성된 지 얼마 되지 않아 쓰레기(Unreachable)가 된다. (반복문 안에서 생성된 지역 변수)
  2. 오래된 객체가 젊은 객체를 참조하는 일은 아주 드물다.

이 가설을 바탕으로, 힙 영역을 객체의 ‘나이(생존 시간)’에 따라 효율적으로 나누어 관리하게 되었습니다.

Young Generation

새롭게 new 키워드로 생성된 모든 객체가 최초로 할당되는 공간입니다. 이 공간은 가비지 컬렉션(Minor GC)이 매우 빈번하게, 그리고 아주 빠르게 일어납니다. 효율적인 관리를 위해 내부적으로 다시 1개의 Eden 영역과 2개의 Survivor 영역으로 쪼개집니다.

  • Eden 영역 : 객체가 ‘처음 태어나는’ 공간입니다. 메모리가 순차적으로 연속해서 할당되므로, C언어의 포인터 연산만큼이나 메모리 할당 속도가 빠릅니다. Eden 영역이 꽉 차면 비로소 Minor GC가 트리거됩니다.
  • Survivor 0 / Survivor 1 영역 : Minor GC에서 살아남은 객체들이 임시로 머무는 대기소입니다. 두 개의 Survivor 영역 중 하나는 반드시 완전히 비어 있어야(Empty) 합니다.
Minor GC의 세부 동작 흐름(Copying Algorithm)
  1. Eden 영역이 꽉 차면 Minor GC가 발생하여 ‘사용 중인 객체(Live Object)’를 식별합니다.
  2. 살아남은 객체들은 비어있지 않은 Survivor 영역(예: S0)으로 복사(Copy) 되고, 나이(Age Bit)가 1 증가합니다.
  3. Eden 영역은 한 번에 싹 비워집니다(Sweep).
  4. 다음 번 Minor GC가 발생하면, 이번에는 Eden의 생존자들과 S0의 생존자들이 모두 비어있던 S1 영역으로 이동합니다. 그리고 Eden과 S0는 모두 비워집니다.
  5. 이처럼 두 Survivor 영역을 핑퐁(Ping-Pong) 치듯이 왔다 갔다 하며 메모리 파편화(Fragmentation)를 완벽하게 방지합니다.

Old Generation

Young Generation에서 치열한 Minor GC를 여러 번 겪고도 살아남은 객체들이 이동하는 넓은 공간입니다.

  • 승격(Promotion) : 객체가 Survivor 영역을 왔다 갔다 하며 나이(Age)를 먹다가, 특정 임계치(기본값 15, MaxTenuringThreshold)에 도달하면 드디어 Old Generation으로 이동합니다. 이를 승격이라고 합니다. 예외적으로, 객체의 크기가 너무 커서(예: 대용량 배열) Eden 영역에 들어갈 수 없는 경우, 나이와 상관없이 곧바로 Old Generation에 할당되기도 합니다.
  • Major GC(또는 Full GC) : Old Generation의 메모리가 가득 차면 발생합니다. Old Generation은 Young Generation보다 공간이 훨씬 크고, 객체들의 수명도 깁니다. 따라서 쓰레기를 식별하고 메모리 파편화를 모아서 압축(Compaction)하는 데 매우 오랜 시간이 걸립니다. 이때 치명적인 Stop-The-World (STW, 애플리케이션 일시 정지) 현상이 길게 발생하므로, 실무 성능 튜닝의 주된 목표는 “어떻게 하면 Full GC가 덜 발생하게 (혹은 짧게) 만들 것인가” 에 맞춰집니다.

다양한 GC 알고리즘

Serial GC

CPU 코어가 1개이던 시절에 설계된 가장 오래된 단순한 방식입니다. Young 영역과 Old 영역의 쓰레기를 치우는 일을 단 하나의 스레드가 전담합니다.

알고리즘이 단순하여 단일 코어 환경에서는 CPU 오버헤드가 적습니다. 하지만, 메모리가 크거나 멀티 코어 환경에서는 하나의 스레드가 모든 메모리를 청소해야 하므로 STW 시간이 기하급수적으로 길어지는 치명적인 단점이 있습니다.

Parallel GC(병렬 GC / Throughput GC)

듀얼 코어, 쿼드 코어 등 멀티 코어 프로세서가 대중화되면서 등장했습니다. Java 8의 디폴트 GC입니다. Serial GC와 기본 알고리즘(Young: Copying, Old: Mark-Compact)은 같지만, 가비지 컬렉션을 수행하는 스레드를 여러 개(멀티 스레드) 로 늘렸습니다.

병렬 처리 덕분에 STW 시간이 Serial GC에 비해 획기적으로 줄었고, 애플리케이션의 전체적인 처리량(Throughput) 을 극대화하는 데 유리합니다. 그러나 여전히 Full GC가 발생하면 모든 애플리케이션 스레드가 멈추는 것은 동일하며, 힙 사이즈가 수십 GB 단위로 커지면 STW가 수 초 이상 지속되는 한계가 있었습니다.

CMS GC(Concurrent Mark Sweep GC)

“Full GC 때 애플리케이션이 수 초간 멈추는 것은 용납할 수 없다”는 웹 서비스들의 요구(저지연, Low Latency)에 부응하기 위해 탄생했습니다. 애플리케이션 스레드를 멈추지 않고(Concurrent), GC 스레드와 애플리케이션 스레드가 동시에 실행되며 쓰레기를 식별(Mark)합니다. 아주 짧은 STW만 발생시킵니다.

STW 시간은 혁신적으로 줄었으나, 동시에 실행되다 보니 CPU 사용량이 매우 높습니다. 더 큰 문제는 Concurrent하게 청소(Sweep)만 하고, 빈 공간을 모으는 압축(Compaction) 작업을 기본적으로 하지 않습니다. 이로 인해 메모리 파편화(Fragmentation)가 심해져, 결국 나중에는 병렬 GC보다 훨씬 더 긴 STW를 유발하는 최악의 Full GC를 맞이하게 됩니다.

튜닝이 너무 복잡하고 파편화 문제가 해결되지 않아, Java 9부터 Deprecated(사용 권장 안 함) 되었고, Java 14에서는 아예 삭제(Removed)되었습니다.

G1 GC(Garbage-First GC)

CMS GC의 파편화 문제를 해결하고, 대용량 메모리(수 GB ~ 수십 GB)에서 짧고 ‘예측 가능한’ STW 시간을 달성하기 위해 설계되었습니다. Java 9부터 현재까지의 디폴트 GC입니다.

메모리를 물리적인 Young/Old로 완전히 나누는 전통적인 방식을 과감히 버렸습니다. 대신 전체 힙을 바둑판처럼 일정한 크기의 Region(리전) 단위로 쪼갭니다. 논리적으로만 특정 리전을 Eden, Survivor, Old로 지정해 사용합니다.

  • Garbage First : 전체 힙을 뒤지는 것이 아니라, 쓰레기(Garbage)가 가장 많이 쌓여 있는 리전을 먼저(First) 찾아서 집중적으로 청소합니다. 청소하면서 다른 리전으로 살아있는 객체를 복사(Copy)하므로 CMS의 고질병이었던 파편화 문제도 자연스럽게 해결(압축 효과) 됩니다.

사용자가 “STW 목표 시간(예: 200ms)”을 설정하면, G1 GC가 이 시간에 맞춰 청소할 리전의 개수를 스스로 조절합니다. 완벽한 저지연은 아니지만 밸런스가 매우 뛰어나며, 소규모 힙에서는 병렬 GC보다 CPU 오버헤드가 약간 더 높을 수 있습니다.

ZGC(Z Garbage Collector)

대규모 데이터 처리, AI, 클라우드 환경에서 수 TB(테라바이트) 단위의 힙 메모리를 다루면서도, STW 시간을 1ms 이하(극초저지연) 로 억제하기 위해 등장한 혁신적인 차세대 GC입니다. Java 11에서 실험적으로 도입되었고, Java 21부터는 세대별(Generational) ZGC가 기본 사양으로 자리 잡고 있습니다.

G1 GC처럼 리전(ZPage) 개념을 사용하지만, 컬러 포인터(Colored Pointers)로드 배리어(Load Barriers) 라는 OS 레벨에 가까운 기술을 사용합니다. 애플리케이션 스레드가 동작하는 그 순간에도, GC가 객체의 메모리 주소를 동적으로 변경(Compaction)합니다. 애플리케이션 스레드가 해당 객체를 참조하려고 하면 로드 배리어가 개입해 ‘변경된 새 주소’로 즉시 안내해 줍니다.

테라바이트급 메모리를 청소해도 STW가 1ms 미만이라는 기적 같은 성능을 보여주지만, 포인터를 검사하고 우회하는 로드 배리어 기술로 인해 애플리케이션의 순수 처리량(Throughput)은 G1 GC나 Parallel GC에 비해 약 10~15% 정도 감소할 수 있습니다. “극단적인 반응성”을 위해 “최대 연산 성능”을 일부 양보한 것입니다.

Throughput(처리량) vs Latency(지연 시간)

모든 것을 다 갖춘 마법의 GC는 없습니다.

  • 배치(Batch) 서버, 데이터 분석 시스템 : 한 번에 많은 데이터를 최대한 빨리 처리하는 것이 중요하므로 STW가 길더라도 Parallel GC가 유리합니다.
  • 실시간 웹 서버, 금융 트레이딩 시스템 : 응답 시간이 조금이라도 튀면 안 되므로, 전체 처리량이 조금 떨어지더라도 G1 GC나 ZGC를 선택해야 합니다.
이 기사는 저작권자의 CC BY-NC 4.0 라이센스를 따릅니다.