JVM 아키텍처와 Java 실행 환경의 메모리·컴파일 구조
JVM의 클래스 로딩, 런타임 메모리 영역, 가비지 컬렉션, JIT 컴파일과 주요 구현체의 역할을 실무 관점에서 정리한다.
2026-08-14 · 최초 발행 2026-01-12
Java 실행 환경을 분리하는 JVM의 역할
Java 애플리케이션은 소스 코드를 컴파일해 바이트코드로 만들고, JVM이 이를 실행한다. 운영체제와 하드웨어별로 JVM 구현체가 바이트코드를 처리하므로, 애플리케이션 바이너리를 플랫폼마다 다시 만드는 문제를 줄일 수 있다.
JVM은 단순한 바이트코드 실행기만은 아니다. 클래스 파일을 읽고 검증하는 일, 실행 중 필요한 메모리를 나누어 관리하는 일, 더 이상 참조되지 않는 객체를 회수하는 일, 반복 실행되는 코드를 네이티브 코드로 바꾸는 일까지 맡는다. 이 과정이 Java의 플랫폼 독립성, 자동 메모리 관리, 보안 샌드박스의 기반이 된다.
C/C++ 프로그램은 플랫폼별 컴파일과 바이너리 호환성 문제를 고려해야 한다. Java는 중간 언어인 바이트코드와 플랫폼별 JVM 구현을 사이에 두어 하드웨어와 운영체제를 추상화한다.
JVM은 다음 구성으로 실행 환경을 만든다.
- 클래스 로더는 클래스 파일을 로딩하고 검증하며 초기화한다.
- 런타임 데이터 영역은 실행에 필요한 메모리를 제공한다.
- 실행 엔진은 인터프리터, JIT 컴파일러, 가비지 컬렉터로 구성된다.
- Native Method Interface는 네이티브 메소드 호출을 연결한다.
클래스는 로딩·링킹·초기화를 거쳐 실행된다
클래스 로딩은 클래스 파일을 찾고 바이트코드를 읽어 메모리에 적재한 뒤 Class 객체를 만드는 과정이다. 바이트코드는 파일 시스템의 .class 파일, JAR/WAR 아카이브, 네트워크 URL, 동적으로 생성된 코드에서 올 수 있다.
링킹 단계는 검증, 준비, 해결로 나뉜다. 검증에서는 바이트코드 형식, 타입 안전성, 접근 제어를 확인해 악의적인 코드를 차단한다. 준비에서는 정적 변수에 필요한 메모리를 할당하고 기본값인 0, null로 초기화해 메소드 영역에 저장한다. 해결은 클래스·인터페이스·필드·메소드의 심볼릭 레퍼런스를 실제 레퍼런스로 연결하는 일이며, 필요할 때 수행하는 Lazy Resolution이 가능하다.
초기화에서는 정적 변수에 초기값을 할당하고 정적 초기화 블록을 실행한다. 이 과정은 클래스 로딩 시 한 번만 수행되며 부모 클래스가 먼저 초기화된다. new로 인스턴스를 만들거나, 정적 메소드를 호출하거나, 정적 필드에 접근하거나, 리플렉션을 사용할 때 초기화가 일어난다.
클래스 로더는 계층 구조와 위임 모델을 통해 클래스 중복 로딩을 막고 보안 및 일관성을 유지한다.
- Bootstrap Class Loader는 JVM 자체 구현(C/C++)으로 코어 Java 클래스인
java.lang.*,java.util.*와rt.jar,charsets.jar를 다룬다. - Extension Class Loader는 Java로 구현되며
$JAVA_HOME/lib/ext의 확장 디렉토리와 추가 라이브러리를 담당한다. - Application Class Loader는 Classpath의 클래스와 사용자 애플리케이션을 로딩한다.
- Custom Class Loader는 사용자가 정의한 로더다.
위임 모델에서는 먼저 부모 클래스 로더에 로딩을 요청하고, 부모가 실패했을 때 현재 로더가 클래스를 로딩한다.
런타임 데이터 영역은 저장 대상과 공유 범위가 다르다
힙은 객체 인스턴스가 저장되는 영역으로, 모든 스레드가 공유하고 가비지 컬렉션 대상이 된다. 동적으로 크기를 조정할 수 있으며 세대별로 Young Generation과 Old Generation(Tenured)으로 나뉜다. Young Generation에는 Eden Space와 Survivor Space(S0, S1)가 있고, Java 7 이하의 Permanent Generation은 메타데이터를 다뤘다. Java 8부터는 Metaspace가 이를 대신한다.
힙의 초기 및 최대 크기는 다음처럼 설정할 수 있다.
java -Xms512m -Xmx2g MyApp
# -Xms: 초기 힙 크기
# -Xmx: 최대 힙 크기
스택은 메소드 호출 프레임과 지역 변수, 파라미터를 보관한다. 스레드마다 독립적으로 존재하며 LIFO 구조를 가진다. 프레임에는 지역 변수 배열, 피연산자 스택, 메소드 정보와 리턴 주소 등의 프레임 데이터가 포함된다. 재귀 호출 깊이가 지나치게 깊거나 스택 크기가 부족하면 StackOverflowError가 발생할 수 있다.
java -Xss1m MyApp
# -Xss: 스레드 스택 크기
메소드 영역에는 클래스 이름·부모 클래스·인터페이스 같은 클래스 메타데이터, 메소드와 필드 정보, 상수 풀의 리터럴 및 심볼릭 레퍼런스, 정적 변수가 저장된다. Java 8+의 Metaspace는 네이티브 메모리를 사용하고 동적으로 크기를 조정하며, 부족하면 OutOfMemoryError: Metaspace가 발생한다.
PC 레지스터는 현재 실행 중인 JVM 명령어 주소를 보관하는 스레드별 영역이다. Java 메소드에서는 바이트코드 주소를 가리키고, 네이티브 메소드 실행 중에는 undefined다. 네이티브 메소드 스택은 JNI를 거쳐 C/C++ 네이티브 코드를 실행할 때 사용하며, 스레드마다 분리되어 있고 StackOverflowError가 발생할 수 있다.
도달할 수 없는 객체를 회수하는 GC
가비지 컬렉션은 더 이상 사용되지 않는 객체를 자동으로 회수한다. 개발자의 메모리 관리 부담과 메모리 누수 위험을 낮추지만 런타임 오버헤드가 따른다. GC 대상은 GC Roots에서 접근할 수 없는, 즉 참조되지 않는 객체다.
GC Roots에는 스택의 지역 변수, 정적 변수, JNI 레퍼런스, 활성 스레드가 포함된다.
Mark-Sweep은 살아 있는 객체를 표시한 뒤 표시되지 않은 객체를 회수한다. 단, 메모리 단편화가 남을 수 있다. Mark-Sweep-Compact는 가비지를 제거한 뒤 객체를 이동해 연속된 메모리를 만들므로 단편화를 해결한다. Copying 방식은 메모리를 두 영역으로 나누고 살아 있는 객체를 다른 영역으로 복사한 후 원래 영역을 전체 회수한다. 빠르고 단편화가 없지만 메모리 절반을 사용한다.
세대별 GC는 대부분의 객체가 짧게 살고 일부만 오래 산다는 Weak Generational Hypothesis를 바탕으로 Young과 Old 영역을 분리한다. Eden Space가 차면 Minor GC가 살아 있는 객체를 Survivor Space로 옮기고 Survivor Space 사이에서 이동시킨다. Old Generation이 차면 Major GC가 전체 힙을 스캔하며 느리게 실행되고 Stop-the-World가 발생한다. Full GC는 Young과 Old Generation 전체를 대상으로 하므로 가장 느리고 애플리케이션을 정지시킨다.
GC 선택과 관찰에는 다음 옵션을 사용할 수 있다.
# Serial GC (단일 스레드)
java -XX:+UseSerialGC
# Parallel GC (다중 스레드)
java -XX:+UseParallelGC
# CMS (Concurrent Mark Sweep)
java -XX:+UseConcMarkSweepGC
# G1 GC (Garbage First, Java 9+ 기본)
java -XX:+UseG1GC
# ZGC (저지연, Java 11+)
java -XX:+UseZGC
# Shenandoah GC (저지연, OpenJDK)
java -XX:+UseShenandoahGC
튜닝에서는 힙 크기, GC 알고리즘, 세대 비율을 조정하고 GC 로그 분석과 성능 측정을 함께 수행한다.
인터프리터와 JIT가 실행 속도를 조율하는 방식
JIT는 Just-In-Time Compilation으로, 실행 중 바이트코드를 네이티브 코드로 컴파일한다. 초기에는 인터프리터가 바이트코드를 실행하고, 자주 실행되는 코드인 핫스팟을 감지하면 JIT 컴파일러가 네이티브 코드를 만든다. 이후에는 컴파일된 코드를 재사용한다.
이 방식은 인터프리터의 느린 실행을 보완하면서 플랫폼 특화 최적화와 런타임 정보 활용을 가능하게 한다. JIT는 메소드 호출을 줄이는 인라인화, 실행되지 않는 코드를 제거하는 데드 코드 제거, 루프 언롤링·루프 퓨전·루프 불변 코드 이동 같은 루프 최적화, 객체 스코프를 분석해 힙 할당과 동기화를 줄이는 탈출 분석을 수행한다.
HotSpot JVM의 C1 Compiler(Client Compiler)는 빠른 컴파일과 기본 최적화로 빠른 시작을 지향한다. C2 Compiler(Server Compiler)는 컴파일은 느리지만 고급 최적화와 높은 성능을 목표로 한다. Java 7부터 기본인 Tiered Compilation은 C1에서 C2로 단계적으로 진화해 이 둘의 균형을 맞춘다.
실행 환경에 따라 달라지는 JVM 구현체
HotSpot JVM은 Oracle/OpenJDK 공식 JVM으로 가장 널리 사용된다. C1·C2 컴파일러와 다양한 GC 알고리즘을 제공하며, 성숙한 최적화, 안정성, 풍부한 도구와 커뮤니티 지원이 특징이다.
OpenJ9는 IBM/Eclipse가 개발한 JVM이다. 낮은 메모리 사용량, 빠른 시작, 클라우드 최적화를 내세우며 메모리 효율, 컨테이너 친화성, 빠른 워밍업, AOT 컴파일을 장점으로 한다.
GraalVM은 Oracle Labs가 개발했으며 Java, JavaScript, Python, Ruby, R을 지원한다. 고성능 JIT 컴파일러와 네이티브 이미지 생성을 제공하고, Polyglot 프로그래밍과 서버리스 최적화, 네이티브 이미지의 빠른 시작을 특징으로 한다.
그 밖에도 Azul Zing은 C4 GC(Continuously Concurrent Compacting Collector)와 매우 낮은 지연시간, 엔터프라이즈 지원을 제공하는 상용 JVM이다. Android Runtime(ART)은 Dalvik을 대체한 Android 전용 런타임으로 AOT 컴파일과 모바일 최적화를 사용한다. GraalVM 일부인 Substrate VM은 AOT 컴파일된 코드를 실행하는 네이티브 이미지 런타임으로, 작은 메모리 풋프린트와 빠른 시작을 목표로 한다.