Developing Myself Everyday
article thumbnail
Published 2023. 6. 10. 15:15
JVM의 내부 구조와 작동 Android/Java

자바의 프로세스는 JVM에서 실행되는 독립적인 실행 프로그램이다. 자바 프로세스는 운영 체제에서 실행되기 위해 자원을 할당받고, 메모리 공간을 사용하며, 시스템 자원을 활용하여 작업을 수행한다. 자바 프로세스는 JVM을 통해 자바 언어로 작성된 소스 코드를 바이트 코드로 변화하고, 해당 바이트 코드를 실행해 프로그램을 동작시킨다. 이제부터 JVM에 대해 자세히 알아보겠다. 만약 JVM이 뭔지를 모른다면 아래의 게시글을 읽고 돌아오길 바란다.

 

 

JVM & JRE & JDK

자바를 공부해본 사람이라면 자바는 플랫폼에 독립적이고, WORA("Write Once Run Anywhere" - 한 번 작성하면 모든 곳에서 돌릴 수 있다)는 말을 들어봤을 것이다. public class Main { public static void main(String[]

everyday-develop-myself.tistory.com


 

 

🖥 자바 코드의 실행 과정

자바는 아래의 그림과 같이 실행된다.

 

 

 

 1. 자바로 개발된 프로그램을 실행하면 JVM은 OS로부터 메모리를 할당한다.

 2. 자바 컴파일러(javac)가 자바 소스코드(.java)를 자바 바이트 코드(.class)로 컴파일한다.

 3. Class Loader를 통해 JVM Runtime Data Area로 로딩한다.

 4. Runtime Data Area에 로딩된 .class들은 Execution Engine을 통해 해석한다.

 5. 해석된 바이트 코드는 Runtime Data Area의 각 영역에 배치되어 수행하며 이 과정에서 Execution Engine에 의해 GC의 작동과 스레드 동기화가 이루어 진다.

 

이제부터 빨간색으로 표시된 내용들을 하나씩 알아보며 JVM이 어떻게 동작되는지 알아보겠다.


 

 

 

JVM의 구조


 

 

 

Class Loader (클래스 로더)

자바 프로그램을 실행하고 자바 소스코드(.java)가 자바 바이트 코드(.class)로 컴파일되면 그 파일은 파일 시스템의 특정 위치에 저장된다. 클래스 로더는 이 자바 바이트 코드 파일을 묶어서 Runtime Data Area로 적재한다.

 

클래스 로더는 다음과 같은 과정으로 진행된다.

클래스 로드 과정

 

 1. Loading: 클래스 파일(.class)를 가져와서 JVM의 메모리에 로드한다.

 2. Verifying: 클래스가 자바 언어 명세 및 JVM 명세에 명시된 대로 구성되어 있는지 검사한다.

 3. Preparing: 클래스의 메모리를 할당한다.

 4. Resolving: 클래스의 상수 풀 내 모든 심볼릭 레퍼런스를 다이렉트 레퍼런스로 변환한다.

 5. Initializing: 클래스 변수를 적절한 값으로 초기화한다.

 

🤔 심볼릭 레퍼런스를 다이렉트 레퍼런스로 변환

심볼릭 레퍼런스는 클래스 로더가 클래스를 로드할 때 사용하는 가상의 참조이다. 클래스 로더는 클래스를 찾거나 로드할 때 심볼릭 레퍼런스를 사용해 해당 클래스의 위치를 식별한다. 그러나 클래스가 실제로 사용될 때, 실제 클래스의 인스턴스를 참조하는 레퍼런스가 필요하게 된다. 이게 바로 다이렉트 레퍼런스이다.

 

그래서 클래스 로더는 클래스를 실제로 필요로 하는 시점에 심볼릭 레퍼런스를 다이렉트 레퍼런스로 변환해 클래스를 로드하고 사용할 수 있게 하는 것이다.


 

 

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

클래스 로더가 묶은 .class 파일은 런타임 데이터 영역의 Method area에 배치된다. 런타임 데이터 영역은 JVM의 메모리 영역으로 자바 프로그램을 실행할 때 사용되는 데이터들을 적재하는 영역이다.

 

런타임 데이터 영역을 나타낸 그림

 

런타임 데이터 영역은 위의 그림과 같이 서로 공유하는 영역과 공유하지 않는 영역이 나눠져 있다.

 

모든 스레드가 공유하는 영역

 - Method Area 

 - Heap Area

 

각 스레드가 독립적으로 사용하는 영역

 - Stack Area

 - PC Register

 - Native Medthod Stack

 

각 영역에 대해 더 자세히 알아보겠다.

 

1️⃣ Method Area (메서드 영역)

JVM이 동작하고 클래스 로더가 클래스에 대한 작업을 마치면 적재되는 공간이다. 이 데이터들은 프로그램이 종료될 때까지 저장된다. 클래스 멤버 변수의 이름, 데이터 타입, 접근 제어자 정보와 같은 필드 정보들과 메서드 정보, 데이터 타입 정보, Constant Pool, Static 변수, final class 등이 적재되는 영역이다. 

 

 

2️⃣ Heap Area (힙 영역)

힙 영역은 JVM이 관리하는 프로그램 상에서 데이터를 저장하기 위해 런타임 시 동적으로 할당하여 사용하는 영역이다. new 키워드로 생성된 객체와 배열이 생성된다. 

 

클래스 로더는 심볼릭 레퍼런스를 처리하기 위해 Runtime Constant Pool(런타임 상수 풀)에 심볼릭 레퍼런스를 저장한다. 이때, 심볼릭 레퍼런스는 다이렉트 레퍼런스로의 변환을 기다리는 상태가 된다.

클래스가 메서드가 실제로 사용되는 시점에 해당 클래스를 로드하고, 심볼릭 레퍼런스를 다이렉트 레퍼런스로 변환해 사용한다. 이 변환된 다이렉트 레퍼런스는 힙 영역에 저장된다. 변환된 다이렉트 레퍼런스는 객체의 인스턴스를 참조하는 레퍼런스로 사용된다.

 

여기서 주의해야할 점이 있다. 힙 영역에 저장된 다이렉트 레퍼런스는 JVM 스택 영역의 변수나 다른 객체의 필드를 참조하고 있다. 그런데 만약 참조하는 변수나 필드가 없다면 의미 없는 객체가 즉 쓰레기가 되어버린다. 그래서 힙 영역은 Garbage Collection(가비지 컬렉션)의 대상이 된다.

 

🤔 Garbage Collector (가비지 컬렉터)

JVM은 가비지 컬렉터를 사용해 힙 영역에서 더는 사용하지 않는 메모리를 자동으로 회수해 준다. 가바지 컬렉터는 나중에 설명할 Execution Engin(실행 엔진)에 포함되어 있다.

 

 

 

힙 영역은 효율적인 가비지 컬렉션을 위해 힙을 3개의 영역으로 나눈다.

 

 1. Young Generation: 자바가 생성되자마자 저장되고, 생긴지 얼마 안되는 객체가 저장되는 공간이다. 힙 영역에 객체가 생성되면 이 Eden 영역에 할당되고 만약 Eden 영역이 어느정도 쌓이게 되면 Suvivor의 빈 공간으로 이동되거나 회수된다.

 2. Tenured Generation: Edn과 Survior 영역이 차게되면 Old 영역으로 이동되거나 회수된다. 

 3. Permanent Generation: Java8부터 이 영역은 사라지게 되었다. 

 

 

대신 Java8 부터는 Metaspace라는 영역이 생겼다. 이 영역은 클래스의 메타데이터를 저장하는 역할을 담당한다. 메타데이터는 네이티브 메모리 영역에 동적으로 크기를 조정할 수 있는 형태로 클래스 메타데이터를 저장한다. 메타데이터는 Reflection(리플렉션)과 관계가 되어있는 영역이다. 리플렉션과 관련된 자세한 내용은 아래의 게시글에서 확인할 수 있다.

 

 

Reflection (리플렉션)

 

everyday-develop-myself.tistory.com

 

 

 

3️⃣ Stack Area (스택 영역)

지역변수, 파라미터, 리턴값, 연산에 사용되는 임시 값 등이 저장된다.

 

 

4️⃣ PC Register (PC 레지스터)

운영체제의 PC 레지스터와 비슷한 역할을 한다. 현재 수행 중인 명령의 주소를 가지며 스레드가 시작될 때 생성되며 각 스레드마다 하나씩 존재한다.

 

 

5️⃣ Native Method Stack (네이티브 메서드 스택)

JAVA가 아닌 언어로 작성된 네이티브 코드를 위한 스택이다. JNI(JAVA Native Interface)를 통해 호출하는 코드를 수행하기 위한 스택이다.

 

 

🤔 JNI (JAVA Native Interface)

자바가 다른 언어로 만들어진 어플리케이션과 상호 작용할 수 있는 인터페이스를 제공하는 프로그램이다.


 

 

Execution Engin (실행 엔진)

실행 엔진은 클래스 로더가 런타임 데이터 영역에 배치한 바이트 코드를 명령어 단위로 읽어서 실행한다.

이 수행 과정에서 실행 엔진은 바이트 코드를 기계가 실행할 수 있는 형태로 변경하는데 다음 두 가지 방식으로 변경하게 된다.

 

1️⃣ Interpreter (인터프리터)

JVM에서 바이트 코드는 기본적으로 인터프리터 방식으로 실행된다. 인터프리터 방식은 고전적인 방식으로 바이트 코드를 한 줄 씩 해석한다. 이런 방식의 속도가 느리다는 단점이 있다. 다음에 설명할 JIT가 인터프리터의 이런 단점을 개선한 방식이다.

 

 

2️⃣ JIT (Just In Time)

JIT의 작동 방식

 

JIT 컴파일러는 프로그램을 실행하면서 바이트코드를 분석하고, 해당 코드 블록을 기계어로 컴파일하여 최적화된 네이티브 코드를 생성한다. JIT 컴파일러는 같은 코드를 매번 해석하지 않고, 실행할 때 컴파일을 하면서 해당 코드를 캐싱한다. 이후에는 바뀐 부분만 컴파일하고, 나머지는 캐싱한 코드를 재사용한다. 이런 방식을 사용하기 때문에 인터프리터 방식보다 훨신 더 빠른 성능을 자랑하게 된다.

 

JVM은 인터프리터 방식을 사용하다가 일정 기준이 넘어가면 JIT 컴파일 방식으로 명령어를 실행한다.

 

 

 

🤼‍♂️ 인터프리터 방식 vs JIT 방식

❓ JIT 방식의 성능이 더 우수하다면 굳이 인터프리터 방식을 사용하지 않고 JIT 방식만을 사용해도 될 것 같다는 생각이 든다. 하지만 그렇지 않다. JIT만을 사용한다면 프로그램의 실행 속도와 시작 속도를 균형있게 고려하지 못하게 된다. 2가지 경우를 들어서 설명해보겠다.

 

i) 처음 실행되는 코드의 경우

인터프리터는 바이트 코드를 즉시 해석해 실행할 수 있으므로 프로그램의 시작 속도가 빠르다. 반면, JIT 컴파일러는 코드를 컴파일하는 시간이 필요하다. 그래서 프로그램을 처음 실행할 때에는 인터프리터를 먼저 사용하는 것이다.

 

ii) 반복 실행되는 코드의 경우

JIT는 반복되는 코드 블록을 컴파일해 최적화된 네이티브 코드를 생성한다. 이렇게 생성된 네이티브 코드는 인터프리터보다 빠른 실행 속도를 제공한다.

 

초기에는 인터프리터를 사용해 바로 실행하고, 프로그램의 실행 특성을 분석한 후에 JIT 컴파일러가 해당 코드를 최적한다. 이렇게 인터프리터와 JIT 함께 사용하면 실행 속도와 시작 속도를 균형있게 고려할 수 있다.


 

 

 

이렇게 JVM의 내부 구조에 대해 알아보고 우리가 실행하는 자바 프로그램이 어떻게 실행되는지 알게 되었다.

각 내부 구조들은 서로 상호작용하면서 자바 코드를 실행하고 있다. 내가 작성하는 코드가 어떻게 실행되는지 이해를 잘 하면 더 좋은 개발자가 된다고 생각한다.

 

 

 

Reference

 

[JAVA] JVM 동작원리 및 기본개념

JAVA라는 언어를 통해 코딩을 하고 있는 사람으로서 JAVA의 간단한 탄생배경 그리고 JAVA의 시작과 끝이라고 할 수 있는 JVM을 한 번 짚고넘어가려고 해요 우선 JAVA의 탄생배경을 좀 알고가면 이해하

steady-snail.tistory.com

 

[Java] 자바 JVM 내부 구조와 메모리 구조에 대하여

저번 포스팅에서는 JVM에 대해서 간략하게 알아보는 시간을 가졌다면 이번 포스팅에서는 JVM의 내부 구조에 대해 좀 더 자세하게 알아보도록 하겠습니다. 혹시 JVM의 정의와 왜 필요한지 궁금하시

coding-factory.tistory.com

 

'Android > Java' 카테고리의 다른 글

JVM과 커널의 동작  (0) 2023.06.26
Reflection (리플렉션)  (0) 2023.06.11
JVM & JRE & JDK  (0) 2023.05.29
profile

Developing Myself Everyday

@배준형

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!