티스토리 뷰

Java

Java Virtual Machine(JVM)

jvnlee 2022. 3. 11. 16:34

JVM이란?

JVM은 Java Virtual Machine의 약자로, 쉽게 이야기하면 Java 코드의 실행(런타임) 환경이다. Java로 작성한 코드는 모두 JVM이라는 가상 환경 위에서 돌아간다. 흔히 Java를 설치한다고 하면 원하는 버전의 JDK(Java Development Kit)를 설치하게 되는데, 이 안에는 JVM과 여러 Java API들이 포함되어있다. 즉, Java와 JVM은 뗄 수 없는 존재라는 뜻이다.

 

 

JVM이 주는 이점

1. 한 번 작성하고, 어디서든 실행해라 (Write Once, Run Everywhere)

컴파일된 Java 코드는 실행 시 OS로 바로 전달되지 않고 JVM을 거쳐야 하기 때문에 Java는 OS에 종속적이지 않다는 특징을 갖는다. 반면, JVM은 OS마다 상이하게 명령을 전달할 준비가 되어있어야 하므로, OS에 종속적이다. Java 언어를 만든 사람들은 이처럼 Java가 OS에 종속적이지 않게 하기 위해 JVM이라는 가상환경을 사용하는 방식을 고안했다.

 

2. 자동 메모리 관리

1995년, Java가 처음 공개될 당시에만 해도 모든 컴퓨터 소프트웨어는 프로그래머가 직접 메모리를 관리하는 방식으로 작성되었다. 그러나 Java 진영의 JVM은 프로그래머를 대신해서 OS로부터 직접 메모리를 할당 받아 스스로 관리하는 능력이 있다. JVM 덕분에 프로그래머는 직접 메모리 관리를 하지 않고도 소프트웨어를 작성할 수 있게 되었다. (물론 JVM이 가진 메모리 한도 내에서 메모리 누수 등이 발생하지 않도록 효율적인 코드를 작성하는 것은 여전히 필요하다). 이 글에서 다루고자 하는 부분이 바로 JVM이 어떻게 메모리를 관리하는지와 관련이 있다.

 

 

JVM의 구조와 역할

프로그래머가 작성한 Java 소스 코드(.java)는 javac라는 컴파일러의 손을 거쳐 바이트 코드인 클래스 파일(.class)로 컴파일된다. 그리고 그 바이트 코드를 받아와 실행하기까지의 과정을 JVM이 맡고 있다.

 

Class Loader

바이트 코드를 받아와 Runtime Data Area에 배치하는 역할을 한다.


Execution Engine

Class Loader가 불러온 바이트 코드를 실제로 실행하는 역할을 한다.

 

1. Interpreter

런타임에 바이트 코드를 한줄씩 읽어들여 기계어로 변환한 뒤 CPU에 명령어로 내보낸다.

 

2. JIT Compilier (Just-In-Time Compiler)

초기 버전의 JVM은 인터프리터 방식만 사용했기 때문에 속도가 느리다는 단점이 있었다. 이를 보완하기 위해 JIT Compiler로 자주 실행되는 코드를 파악하고 별도의 저장소에 기계어 상태로 저장해두는 방식이 도입되었다. 이미 기계어로 변환된 이력이 있는 코드는 인터프리터가 굳이 또 읽어들여 기계어로 내보내는 과정을 거칠 필요 없이 저장해둔 것을 내보내게끔 하면 되므로 훨씬 효율적이다.

따라서 프로그램을 오래 실행할수록 프로그램의 실행 속도를 높여주는 장점이 있다.

 

단, JIT Compiler 방식도 변환과 보관에 드는 비용이 있기 때문에 처음부터 모든 코드를 JIT Compiler를 통해 실행시키지는 않고, Interpreter를 사용하다가 일정 기준을 넘어서면 JIT Compiler 방식으로 실행한다.

여기서 일정 기준이란 메서드가 호출된 횟수인데, JVM은 메서드 호출 시, 호출 횟수를 내부적으로 카운트한다. 일정 횟수 이상 호출되기 전까지는 Interpreter를 사용해서 실행하다가, 기준 횟수를 넘어서면 JIT Compiler를 통해 기계어로 컴파일한 후 보관하여 다음 번 호출 때 더 빠르게 실행시킬 수 있도록 돕는다.

컴파일해서 보관된 메서드는 다시 호출 횟수가 0으로 초기화 되는데, 만약 이후에도 계속 호출되어 기준 횟수를 넘어서면 JIT Compiler가 다시 한번 컴파일을 하게 된다. 이 때에는 그 전보다 한 단계 더 최적화를 적용한 컴파일 과정을 거친다. 이 과정이 반복되다 보면 맥시멈 최적화 단계까지 도달하게 되고, 주로 가장 자주 쓰이는 메서드들이 이 단계까지 도달하게 된다.

 

3. Garbage Collector

더 이상 쓰이지 않는 참조 데이터를 메모리(Heap Area)에서 지워주는 청소부 역할을 한다.

GC의 동작 원리


Runtime Data Area

JVM의 메모리 영역은 Runtime Data Area라고 불리고, 5가지 주요 영역으로 구분되어 있다. (Java 8 이상 기준)

 

그 중에서 아래의 두 영역은 모든 쓰레드가 공유하는 메모리 영역이다.

JVM이 시작될 때 생성되므로 JVM과 생명 주기를 함께한다.

 

1. Method Area

- Java 8 이전에는 Non-Heap Area 중 Permanent Generation의 일부였고, 각 클래스(또는 인터페이스) 마다의 Class-Level 정보들을 가지고 있었다 (Runtime Constant Pool, static 필드와 메서드, 생성자 등)

Runtime Constant Pool은 클래스/인터페이스 상수, 메서드, 필드에 대한 모든 레퍼런스(메모리 주소)를 저장하고 있다. JVM은 이곳에 필요한 메서드나 필드의 실제 메모리 주소를 저장해서 다른 클래스에서 이러한 정보들을 필요로 할 때 링크로서 사용한다.

 

- Java 8 이후로는 Permanent Generation이 삭제되면서 Metaspace라는 새로운 개념이 등장했다. 사실상 Method Area의 역할 대부분을 Metaspace가 한다고 봐도 무방하다.

Metaspace는 JVM 메모리가 아닌 OS가 제공하는 네이티브 메모리에 존재하며, 이렇게 만든 이유는 기존의 Permanent Generation이 JVM 내의 고정 메모리로 할당되어 툭하면 OutOfMemoryError를 뱉어댔기 때문이다. 네이티브 메모리로 넘겨버리면 필요에 따라 유동적으로 메모리를 늘려나갈 수 있다. 공간이 부족해지면 단순히 커지기만 하는게 아니라, GC의 관리도 받는다.
Metaspace는 다음과 같은 데이터를 저장한다.

항목 설명
Type Information - Type(class or interface)의 전체 이름
- Type의 직계 하위 클래스 전체 이름
- Type의 class/interface 여부
- Type의 modifier (public / abstract / final)
- 연관된 interface 이름 리스트
Field Information - Field Type
- Field Modifier
(public / private / protected / static / final / volatile / transient)
Method Information - Constructor를 포함한 모든 Method의 메타 데이터
Runtime Constant Pool - Type, Field, Method로의 모든 레퍼런스를 저장
- JVM은 이 영역을 통해 실제 메모리 상의 주소를 찾아 참조
Class Variable - static 키워드로 선언된 변수를 저장
- 기본형이 아닌 static 변수는 레퍼런스 변수만 저장되고 
실제 인스턴스는 Heap에 저장됨
- 클래스를 사용하기 이전에 이 변수들은 미리 메모리를 할당받음

 

2. Heap Area

- new 연산자로 생성된 객체(+ 인스턴스 변수), 그리고 배열처럼 동적으로 생성된 데이터가 저장된다.

- String Constant Pool이 리터럴로 생성된 문자열 상수들을 관리한다.

같은 내용의 문자열이더라도 new String("...")으로 생성하면 Heap에 각각 독립적인 객체로서 저장되지만, 문자열 리터럴 "..."로 생성하면 String Constant Pool에 하나만 저장된다. String Constant Pool에서는 리터럴로 생성한 문자열에 한해서는 내용이 같으면 하나의 메모리를 공유한다고 보면 된다.

 

- 효율적 메모리 관리를 위해 더 이상 참조되지 않는 데이터는 GC가 제거해준다.

 

그리고 아래의 세 영역은 개별 쓰레드에게 각각 할당되는 메모리 영역이다.

쓰레드가 생성될 때 함께 생성되므로, 각 쓰레드와 생명 주기를 함께한다.

 

3. Stack Area

- 메서드 호출 스택 

- 메서드 단위로 Stack Frame이 구성되는데, 크게 3가지 구성 요소로 이루어져있다.

1. 지역 변수 배열
메서드의 파라미터와 메서드 블럭 내부에 선언된 지역 변수들이 배열의 형태로 저장되어있다. 이 때, Primitive type 변수는 여기에 직접 값이 저장되고, Reference type 변수는 Heap Area에 저장되어 있기 때문에 주소값만 가져와 저장한다.

2. 피연산자 스택(Operand Stack)
프레임이 갓 생성되어있을 때는 비어있다. JVM은 작업을 instruction이라는 명령어 단위로 처리하는데, 이 중에서 피연산자를 피연산자 스택에 넣으라는 instruction이 실행되면 필요한 값들을 실제로 불러와 피연산자 스택에 push 한다. 또 다른 instruction은 스택에 들어있는 피연산자들을 하나씩 pop해서 실제 연산 작업 등을 수행하고 결과값을 다시 피연산자 스택에 push 한다. 이외에도 메서드 호출 시에 넘길 파라미터들을 담는 용도로도 쓰인다. 결국 메서드 실행에 필요한 재료와 중간 결과들을 담는 통이라고 보면 된다.

3. 소속 클래스에 대한 참조
Runtime Constant Pool(Metaspace의 일부)에 저장된 클래스 데이터에 대한 참조를 말한다.

 

- Stack이므로 LIFO(Last-In-First-Out)로 동작하며, JVM은 스택 프레임을 push/pop 하는 연산을 수행한다.

- printStackTrace()로 호출 스택에 쌓인 스택 프레임들을 출력할 수 있다.

 

4. PC Register

- 현재 실행 중인 JVM 명령어의 주소를 저장한다.

 

5. Native Method Stack

- JNI(Java Native Interface)를 통해 호출되는 C나 C++로 된 메서드 실행에 사용되는 공간

 

 

참고 자료

https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-2.html

https://www.ibm.com/docs/en/sdk-java-technology/7?topic=jc-jit-compiler-overview-2 

 

'Java' 카테고리의 다른 글

volatile 키워드  (0) 2022.03.27
Garbage Collector(GC)  (0) 2022.03.13
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday