Note

JVM에 대해

by 달쿨

JVM

JVM이란 Java Virtual Machine의 약어로 Java 프로그램이 실행되는 가상 머신을 말한다. 이는 Java가 운영체제에 종속 받지 않고 CPU가 Java를 인식하고 실행할 수 있도록 한다.

 

JVM의 등장

맨 처음 컴퓨터와 처음 의사소통을 할 시절에는 0과 1을 통해 2진법을 이용해 명령을 만들었다. 이후 기계어의 이해하기 어려운 문제점을 개선해, 인간이 이해할 수 있는 단어들로 조합된 어셈블리어라는 프로그래밍 언어가 등장했다. 개발자가 어셈블리어를 작성하면 중간의 어셈블러가 이를 기계어로 변환해 컴퓨터에게 전달하는 방식이다.

 

하지만 이는 컴퓨터에 따라 다른 어셈블리어가 존재해 호환성이 매우 좋지 않았고, 이를 보완하기 위해 개발자가 하나의 소스코드를 작성하면 기계의 컴파일러가 각 기계의 기계어로 변환해 줄 수 있는 C언어가 등장했다. 즉 한번만 작성하고 컴파일하면 어디서든 사용할 수 있다는 것인데, 이 특성을 WOCA(Write Once, Compile Anywhere)라고 부른다.

 

하지만 C언어는 운영체제에 종속적이라, 컴파일러가 다른 OS와 CPU 아키텍처에서는 제대로 적용되지 않아 개발자가 각 환경에 맞게 다시 컴파일 하거나, 코드를 새로 작성해야 하는 문제점이 있었다. 이러한 컴파일러가 운영체제에 종속적인 문제를 해결하고, 소스 코드를 운영체제로부터 독립적이게 해 WORA(Write Once, Run Anywhere)특성을 가능하게 하는 것이 JVM이다.

 

JVM은 Java 소스 코드가 기계어 대신 자바 컴파일러를 통해 바이트코드로 컴파일 된 코드를 읽어 운영체제에 맞춰 번역하고 실행하기 때문에, 개발자는 자바 코드를 한번 작성하면 운영체제에 맞춰 수정 할 필요가 없게 된 것이다.

 

간단한 실행 흐름

바이트 코드를 어떻게 실행하는지는 먼저 영역을 Class Loader(클래스 로더), Execution Engine(실행 엔진), Runtime Data Area(런타임 데이터 영역)로 간단하게 나누어 볼 수 있고, 다음과 같은 흐름으로 실행된다.

먼저 자바 소스 코드가 javac(*바이트코드로 변환하는 자바 컴파일러)에 의해 바이트코드(.class)로 컴파일된다. 그럼 클래스 로더가 동적 로딩을 통해 필요한 클래스를 로딩해 런타임 데이터 영역으로 올리고, 해당 영역에 로딩된 바이트 코드는 실행 엔진에 의해 인터프리터나 JIT 컴파일러를 통해 기계어로 번역되어 실행된다.

 

Class Loader

클래스 로더는 클래스 파일을 메모리에 동적으로 로드하고 실행 가능한 상태로 만드는 역할을 하며 크게 Loading(로딩), Linking(링크), Initialization(초기화)의 세 단계로 동작한다.

 

① 로딩 단계에서는 클래스 파일을 읽고 JVM 메모리에 로드하기 위한 작업을 한다. 클래스 파일을 읽으면, 내용에 따라 적절한 바이너리 데이터를 만들어 먼저 메서드 영역에 저장한다. 그리고 로딩이 끝나면 해당 클래스 타입의 클래스 객체를 생성해 힙 영역에 저장한다. (Class 객체가 클래스 로더에 의해 메모리에 로딩되는 시점에 힙 영역에 올라오게 된다.) *이 때 로딩은 동적으로 이루어져 모든 클래스 파일을 한번에 메모리에 올리지 않고, 필요한 시점에 동적으로 메모리에 적재한다.

 

② 링크 단계는 클래스 파일을 검증하는 과정으로, Verify(검증), Prepare(준비), Resolve(분석)의 세 단계로 나누어진다.

  • 검증 단계에서는 클래스 파일 형식(바이트 코드)이 유효한지 체크하며, JVM의 구동 조건대로 구현되지 않았을 경우에는 오류(java.lang.VerifyError)가 던져진다.
  • 준비 단계에선 클래스에 필요한 메모리를 할당하며, 데이터 구조를 준비한다. static 필드가 기본 값으로 초기화된다.
  • 분석 단계에서는 클래스의 상수 풀 내의 심볼릭 레퍼런스(기본 자료형을 제외한 클래스, 메서드, 필드 등을 직접적으로 식별하는 데 사용되는 이름이나 식별자)를 다이렉트 레퍼런스(실제 메모리 주소)로 교체한다.

 

③ 마지막으로 초기화 단계에서는 static 필드에 값을 할당하고 static 블록이 있다면 실행시키는 등의 클래스 변수들의 초기화 작업을 진행한다.

 

Runtime Data Area

런타임 데이터 영역은 JVM의 메모리 영역으로, 운영체제 위에서 실행되면서 할당 받는 메모리 영역이다. 이는 크게 Method Area, Heap, Stack, PC Register, Native Mathod Stack의 5가지 영역으로 나뉜다. (세분화하면 메서드 영역 안의 Runtime Constant Pool 영역도 있다.)

*이 때 Stack, PC Register, Native Mathod Stacks 영역은 각 스레드마다 생성되지만, Method Area, Heap 영역은 모든 스레드가 공유해 사용한다. (스레드가 3개일 경우 각각의 스레드는 앞의 3개의 영역을 갖지만, 메서드와 힙 영역은 그냥 모든 스레드가 공유하며 같이 사용한다.)

 

① Method Area

static 영역으로도 불리는 메서드 영역은 JVM이 시작할 때 생성되며, 위에서 정리한 클래스 로더가 처음 바이트 코드(클래스 파일)을 읽으면 그 데이터가 저장되는 곳이다. 클래스, 인터페이스, 필드, 메서드, 생성자 등의 바이트 코드 정보와 클래스(static) 변수, 상수 풀이 저장된다.

해당 영역의 데이터는 프로그램의 시작부터 종료까지 메모리에 남아있다. 때문에 static 데이터들은 프로그램이 종료될 때까지 어디서든 사용 가능하다. (그렇기에 static 데이터를 무분별하게 사용하면 메모리 부족 현상이 일어날 수 있다)

 

② Heap

힙 영역 또한 메서드 영역과 같이 모든 스레드가 공유하는 공간으로, 데이터를 런타임 시에 동적으로 할당하여 사용하는 영역이다. 참조형 데이터 타입을 갖는 객체, 배열등이 실제로 저장되는 공간으로 new 키워드로 생성자를 호출하면 해당 데이터가 힙 영역에 저장된다. 그리고 이 객체에 대한 참조(주소)가 Stack 메모리에 저장되는 것이다.

스택 메모리에 적재되는 참조값은 메서드 호출이 종료됨에 따라 메모리에서 삭제되지만, 힙 영역의 실제 데이터는 삭제되지 않고 남아있게 되는데, 해당 데이터들이 GC(가비지 컬렉터)의 대상이 되어 메모리에서 해제된다. (*이를 잘 수행하지 못하면 메모리 누수가 발생할 수 있다.)

즉, 힙 영역은 실행 엔진의 가비지 컬렉션의 대상이 되는데, 효율적인 GC를 위해 두 영역(Young Generation, Old Generation)으로 구분된다. 

 

③ Stack

스택 영역은 각 스레드마다 가지게 되는 영역이며, 메서드가 호출될 때마다 스택 영역에 스택 프레임이라는 구조체가 생성되어 그 안에 해당 메서드에 필요한 정보를 저장하고, 메서드 호출이 끝나면 프레임 별로 스택에서 삭제된다. 이는 LIFO 구조로 동작해 마지막에 들어온 스택 프레임이 먼저 실행되고 삭제된다.

스택 프레임 내부에는 메서드의 매개변수, 지역변수, 리턴 값 등이 쌓이게 된다. 이 때 데이터의 타입이 기본 타입인 경우엔 스택 영역에 직접 값을 가지지만, 참조 타입인 경우엔 실제 데이터가 아닌 객체의 주소를 가지게 된다.

 

* Heap vs Stack

힙과 스택의 차이를 간단히 정리하면 먼저 접근 범위를 들 수 있다. 힙 메모리는 모든 스레드가 공유하지만, 스택 메모리는 각각의 스레드가 가지기 때문에 다른 스레드와 영역을 공유할 수 없다. 또한 객체가 생성되면 힙 공간에 저장되고, 스택 영역은 그 참조를 갖거나 기본 타입 지역변수만 갖는다. 또한 스택은 고정된 크기의 메모리 영역이지만, 힙은 동적으로 크기를 조절할 수 있다. 생명주기 면에서도 스택 메모리는 짧지만 힙 메모리는 끝까지 남아있는 차이가 있다.

 

④ PC Register

PC 레지스터도 각 스레드마다 가지게 되는 영역으로, 현재 수행 중인 JVM 명령어의 주소를 저장한다. 스레드가 어떤 부분을 무슨 명령으로 실행하고 있었는지에 대한 기록을 가진다. 이는 CPU 위에서 스레드가 빠르게 번갈아 실행되기 때문에 CPU 점유를 넘겨주었다가 돌려받았을 때 실행하던 일을 기억하기 위함이다. 스레드가 시작할 때 생성되며, 스레드가 자바 메서드를 수행하고 있으면 해당 JVM 명령의 주소를 레지스터에 저장한다. (*자바가 아닌 다른 언어는 밑의 네이티브 메서드 스택 영역에서 처리하기 때문에, 이 경우엔 undefined 상태가 된다.)

 

⑤ Native Method Stack

네이티브 메서드 스택 또한 각 스레드마다 가지게 되는 영역으로, 자바 코드 외의 네이티브 코드(C, C++ 등)를 호출할 때 사용되는 스택이다. 실행 엔진의 JIT 컴파일러에 의해 컴파일된 네이티브 코드도 이 공간에서 실행된다. 일반적인 메서드를 실행하면 JVM 스택에 쌓이지만, 해당 메서드 내부에 네이티브 방식의 메서드가 있다면 이 네이티브 스택에 쌓이고, 수행이 끝나면 다시 JVM 스택으로 돌아온다. 그리고 이를 가능하게 해주는 것이 JNI(Java Native Interface)라고 할 수 있다. (*이 때 필요에 따라 JNI가 C, C++로 작성된 Native Method Library를 로딩해 실행한다.)

 

Execution Engine

실행 엔진은 클래스 로더를 통해 런타임 데이터 영역에 배치된 바이트 코드를 명령어 단위로 실행한다. 바이트 코드는 바로 수행 가능한 기계어라기보단 중간 레벨로 컴파일 된 코드이기 때문에 실행 엔진에서 이를 기계어로 컴파일해줘야 한다. 컴파일 방법으로 인터프리터(Interpreter)와 JIT 컴파일러(Just-In-Time Compiler)의 두 가지 방식이 있다.

 

인터프리터는 기본적인 동작 방식으로, 바이트 코드 명령어를 하나씩 읽어서 해석하고 실행한다. 이는 하나하나의 해석은 빠르지만, 같은 메서드 등 동일한 코드가 반복될 때에도 매번 해석이 이루어져야 하기 때문에 비효율적이므로 전체적인 속도가 떨어지는 단점을 가진다.

 

이 단점을 보완하기 위해 도입된 것이 JIT 컴파일러이다. 인터프리터처럼 하나씩 해석하는 것이 아니라, 바이트 코드 전체를 컴파일해 기계어인 네이티브 코드로 변경하고 캐시에 보관하는 방식으로, 동일한 코드(한 번 컴파일된 코드)가 호출되면 캐시에서 꺼내 별도의 추가 인터프리팅 없이 바로 사용할 수 있기 때문에 실행 속도가 빨라진다. 이는 C언어 같은 언어에 비해 속도가 느린 단점을 극복할 수 있게 한다.

 

*바이트 코드를 네이티브 코드로 변환하는 과정 자체는 하나씩 인터프리팅하는 것보다 오래걸리기 때문에 JVM이 내부적으로 해당 메서드가 얼마나 자주 호출되고 실행되는지 추적해 호출 횟수가 일정 기준을 넘겼을 때만 JIT 컴파일러를 통해 네이티브 코드를 생성한다. 이 때 컴파일되어 최적화된 코드는 코드 캐시라는 특수 힙에 저장된다. (Hotspot VM 기술 참고하기)

실행 엔진에는 중요한 GC(가비지 컬렉터)도 존재한다. 이는 다른 포스트에서 자세히!

 

 

[참고 링크]

'공부' 카테고리의 다른 글

스레드에 대해  (0) 2026.03.31
GC에 대해  (0) 2026.03.29
AOP에 대해  (0) 2026.03.18
역색인(Inverted Index) 구조  (0) 2025.04.24
RabbitMQ 개념  (3) 2025.01.14

블로그의 정보

note

달쿨

활동하기