[Java] JVM 구조

j-yong98·2026년 1월 3일

Java

목록 보기
1/7

Java를 사용하면서 JVM을 정확히 이해하고자 JVM 관련 내용을 정리 해보자.

JVM이란?


JVM은 컴파일된 클래스 파일(바이트 코드)을 읽고 실행하는 가상 실행 환경(Virtual Machine)이다.

자바로 작성된 코드는 컴파일러에 의해 바이트 코드로 변환되고, 이 바이트 코드는 OS에 직접 전달되지 않는다. 대신, JVM이 중간 계층으로 존재해, 실행 시 바이트 코드를 해석하거나 JIT(Just-In-Time) 방식으로 기계어로 변환해 실제 하드웨어에서 동작하도록 한다.

이러한 구조 덕분에, 서로 다른 OS에서도 동일한 클래스 파일을 그대로 실행할 수 있다.

여기까지 JVM이 무엇인지 개념적으로만 살펴봤다. 이제 JVM 내부가 어떤 구조로 구성되어 있고, 어떤 과정으로 처리하며 실행하는지 구체적으로 살펴보자.

JVM 내부 구조

ClassLoader


클래스 로더는 클래스 파일을 동적으로 로드하고 런타임 데이터 구조로 변환한다.

클래스 로딩은 데이터를 클래스 파일로부터 메모리로 읽고, 이를 검증, 변환, 초기화 후 바로 사용할 수 있는 자바 타입을 생성한다. 컴파일 시 링킹까지 하는 다른 언어와 달리 자바는 클래스 로딩, 링킹, 초기화가 모두 ‘프로그램 실행 중에’ 이루어진다.

로딩

  1. 해당 클래스를 정의하는 바이너리 바이트 스트림을 가져온다.
  2. 바이트 스트림으로 표현된 정적인 저장 구조를 메서드 영역에서 사용하는 ‘런타임 데이터 구조로 변환’한다.
  3. 로딩 대상 클래스를 표현하는 java.lang.Class객체를 힙 메모리에 생성한다. 이 객체를 통해 메서드 영역에 저장된 타입 데이터를 활용할 수 있게 된다.

로딩이 완료되면 바이너리 바이트 스트림은 자바 가상 머신이 정의한 형식에 맞게 메서드 영역에 저장된다.

배열 클래스는 클래스 로더가 생성하지 않고 자바 가상 머신이 직접 메모리에 동적으로 생성한다. 그렇지만 배열 클래스의 원소 타입은 클래스 로더를 통해 로드된다.

검증

클래스 파일의 바이트 스트림에 담긴 정보가 제약을 만족과 자바 가상 머신 자체의 보안을 위협하지 않는지를 확인하는 단계이다. 다음과 같은 항목을 검증한다.

  1. 파일 형식 검증

    클래스 파일 형식에 부합하고 현재 버전의 가상 머신에서 처리될 수 있는지 확인한다. 이 단계의 검증을 통과하면 바이트 스트림이 메서드 영역에 저장된다.

  2. 메타데이터 검증

    바이트 코드로 설명된 정보를 검증한다. 상위 클래스의 유무나 상속 허용 여부 등을 검증한다. 즉, 클래스의 의미론적 검증을 수행하여 일치하는가를 검증한다.

  3. 바이트코드 검증

    가장 복잡한 단계로 데이터 흐름과 제어 흐름이 적법하고 논리적인지 확인한다. 클래스 메서드 본문을 분석하는 단계이다. 타입, 점프, 형변환 등을 검사하게 된다.

  4. 심벌 참조 검증

    심벌 참조를 직접 참조로 변환할 때 수행된다. 이 변환은 링킹의 세 번째 단계인 해석 단계에서 일어나며 해당 클래스 자체를 제외한 모든 정보를 확인한다. 즉, 외부 클래스, 메서드, 필드, 그 외 자원에 접근할 권한이 있는지 확인한다.

준비

클래스 변수(정적 변수)를 메모리에 할당하고 초깃값을 설정하는 단계이다. 준비 단계에서는 인스턴스 변수가 아닌 클래스 변수만 할당된다. 인스턴스 변수는 객체가 인스턴스화될 때 객체와 함께 힙에 할당된다.

또한, 준비 단계에서 클래스 변수에 할당하는 초깃값은 해당 데이터 타입의 제로 값이다. 하지만 final 을 사용한다면 컴파일 단계에서 설정된 값을 할당할 것이다.

해석

상수 풀의 심벌 참조를 직접 참조로 대체하는 과정이다. Class 파일 내부에는 메서드, 필드, 타입이 심벌로 표기되어 있다. 이를 실제 메모리 상 클래스, 필드, 메서드 주소로 변환한다.

초기화

초기화는 클래스 로딩의 마지막 단계로 사용자 클래스에 작성된 자바 프로그램 코드를 실행하기 시작한다. 앞선 준비 단계에서 모든 변수에 시스템 정의 값 0을 할당했다. 하지만 초기화 단계에서는 개발자가 기술한 프로그램 코드대로 초기화한다.

이 과정에서 실행하는 초기화는 생성자와 다르다. 생성자는 인스턴스를 만드는 생성자이다.

Execution Engine

실행 엔진은 컴파일 된 바이트코드를 해석해 실행한다.

실행 엔진이 바이트코드를 실행하는 방법은 해석 실행(인터프리터)와 컴파일 실행(JIT 컴파일러)이 있다. 실행 엔진 하나에서 둘 다 포함할 수도 있고, 수준이 다른 여러 JIT 컴파일러를 혼용할 수도 있다.

인터프리터

인터프리터는 바이트코드를 한 줄씩 읽어서 실행하는 방식이다. 바이트 코드를 네이티브 코드로 변환하지 않고 명령어 단위로 해석해서 실행을 반복한다.

이러한 방식은 컴파일 방식이 없어서 초기 실행 비용이 낮은 장점이 있지만, 같은 코드를 실행할 때마다 반복해서 해석하기 때문에 성능상 단점이 있다.

JIT 컴파일러

JIT 컴파일러는 자주 실행되는 바이트 코드를 네이티브 코드로 변환하여 성능을 향상 시킨다. 실행 중에 컴파일을 수행하며 컴파일된 네이티브 코드는 코드 캐시에 저장해놓 실행한다.

JIT 컴파일러는 반복되는 코드를 네이티브 코드로 변환해 놓고 실행하기 때문에 성능상 이점이 있다. 또한 런타임 정보를 기반으로 하기 때문에 최적화가 가능하다. 하지만, 컴파일을 하는데 비용이 소요된다는 단점이 있다.

GC

JVM은 GC를 이용해 Heap 영역에 생성된 객체 중 더 이상 참조되지 않는 객체를 자동으로 회수하는 역할을 한다.

이를 통해 개발자는 명시적인 메모리 해제 없이 안정적으로 프로그래밍을 할 수 있으며, GC는 애플리케이션 실행 중 주기적으로 혹은 필요 시 동작하여 메모리 누수를 예방한다.

Runtime Date Area

JVM은 자바 프로그램을 실행하는 동안 필요한 메모리를 몇 개의 데이터 영역으로 나눠 관리한다. 이 영역들은 각각 목적과 생성/삭제 시점이 있다.

PC Register

현재 실행 중인 스레드의 바이트 코드 번호를 표시한다. 이 카운터의 값을 바꿔 다음 실행할 바이트 코드의 명렁어를 선택하는 방식으로 동작한다.

멀티스레딩은 여러 스레드를 교대로 사용하기 때문에 특정 시점에 각 코어는 한 스레드의 명령어만 실행한다. 스레드 젼환 시 이전에 실행하다 멈춘 지점을 복원하기 위해서는 스레드마다 고유한 프로그램 카운터가 필요하다. 따라서 각 스레드의 카운터는 서로 영향을 주지 않는 독립된 영역에 저장된다.

JVM Stack

JVM Stack도 스레드마다 독립적이고 스레드와 생성/삭제를 같이한다. JVM은 스택 프레임을 만들어 지역 변수 테이블, 피연산자 스택, 동적 링크, 메서드 반환값 등의 정보를 저장한다. 이후 스택 프레임을 JVM Stack에 push하고, 메서드가 끝나면 pop 하는 일을 반복한다.

지역 변수 테이블에는 컴파일 시점에 알 수 있는 다양한 기본 데이터 타입, 객체 참조, 반환 주소 타입을 저장한다. 지역 변수 테이블을 구성하는 필요한 데이터 공간은 컴파일 과정에서 할당 되고 메서드 실행 중에는 절대 변하지 않는다.

Native Method Stack

Native Method Stack은 JVM Stack과 비슷한 역할을 한다. 차이점은 JVM Stack은 바이트 코드를 실행할 때 사용하고, Native Method Stack은 Native Method 실행 시 사용한다.

Heap

Heap은 모든 스레드가 공유하며 가상 머신이 구동될 때 만들어진다. 이 메모리 영역은 객체 인스턴스를 저장하는 곳이다. Heap은 GC가 관리하는 메모리 영역이다.

Method Area

Method Area도 모든 스레드가 공유하는 메모리 영역이다. 가상 머신이 읽어 들인 타입 정보, 상수, 정적 변수 그리고 JIT 컴파일러가 컴파일한 코드 캐시 등을 저장하는데 이용된다.

런타임 상수 풀은 Method Area영역의 일부이다. 상수 풀 테이블에는 클래스 버전, 필드, 메서드, 인터페이스 등 클래스 파일에 포함된 설명 정보에 더해 컴파일 시점에 생성된 다양한 리터럴과 심벌 참조가 저장된다. 가상 머신이 클래스를 로드할 때 이러한 정보를 메서드 영역의 런타임 상수 풀에 저장한다.

런타임 상수 풀은 동적이라는 특징이 있다. 자바에서는 상수가 꼭 컴파일 시점에 생성되어야 한다는 규칙은 없다. 런타임에도 메서드 영역의 런타임 상수 풀에 새로운 상수를 추가될 수 있다. String 클래스의 intern() 메서드에 이 특성이 반영되어 있다.


참고

0개의 댓글