Java

JVM 구조

나맘임 2025. 2. 5. 16:58

들어가며

Write once, run anywhere
한 번 쓰면, 어디서든 실행된다.

 

자바는 위 슬로건을 전면에 내세워 세상에 널리 알려졌습니다.

당시 다른 프로그래밍 언어와 다르게 하드웨어의 종류와 상관없이 사용할 수 있다는 것이 큰 장점이었습니다.

현재도 여전히 크로스 플랫폼이라는 장점은 무시하지 못합니다.

 

자바는 어떻게 하드웨어 의존성을 무시할 수 있게 됐을까요??

그 근간엔 JVM(Java Virtual Machine)이 존재합니다.

 

이 글에선 JVM에 대해 공부해본 것을 정리해 보았습니다.

 

자바 전체 구조

그림 1. 전체 자바 구조

그림 1은 JVM에 대해 검색하면 자주 나오는 개념도입니다.

이 그림을 이용해서 차근차근 자바가 어떻게 작동하는지 설명해 보겠습니다.

 

1. Byte Code 변환

 

그림 2. 자바 코드 예시

우리가 작성한 자바 소스 코드는 컴퓨터가 이해할 수 없습니다.

컴퓨터는 0,1만 이해할 수 있기 때문에 변환해야 합니다.

 

이 과정을 간략하게 말하면

자바 소스 코드 -> 자바 바이트 코드 -> 기계어 -> CPU가 0과 1을 처리하고 실행

 

위 과정을 진행합니다.

그림 3. 자바 소스 코드를 바이트 코드로 변환하는 단계

 

그중 자바 소스 코드에서 자바 바이트 코드로 변환해 주는 것이 자바 컴파일러입니다.

 

그렇다면 왜 자바 소스 코드에서 바로 기계어로 변환하지 않고 자바 바이트 코드를 거쳐가는 걸까요??

이유는 글의 서두에서 나왔습니다. 바로 플랫폼에 독립적으로 실행되어야 하기 때문입니다.

즉, 윈도우, 맥, 리눅스 상관없이 JVM에서 이해할 수 있는 언어가 필요했기 때문입니다.

그 언어가 자바 바이트 코드입니다.

 

만약, 자바 바이트 코드의 변환 과정이 없이 바로 기계어로 바꿔야 한다면 어떻게 될까요??

그림 4. JVM 구조

 

플랫폼마다 JVM 전체를 새로 다 만들어야 할 것입니다.

거기다가 컴퓨터 CPU도 다 다르니까 거기에 맞는 JVM을 또 만들어야겠죠.

단순하게만 봐도 플랫폼 개수 x CPU 종류 개수만큼 JVM을 개발해야 하는 대참사가 벌어지는 겁니다.

그래서 자바 바이트 코드로 변환하는 과정을 넣어 자바 컴파일러의 종류만 늘리면 되도록 설계한 것입니다.

(JVM 전체를 설계하는 것보다 간단하므로)

 

2. Class Loader

그림 5. Class Loader 역할

 

만들어진 자바 바이트 코드를 이제 JVM에 로딩을 해야겠죠.

이를 클래스 로더(Class Loader) 단계라고 합니다.

쉽게 말해, 여러분이 작성한 변수, 메서드 등을 메모리에 할당하고, 외부 클래스 라이브러리를 연결해 주는 단계입니다.

 

(1) Loading

클래스의 바이트 코드를 읽어서 JVM의 Method Area에 저장하는 과정입니다.

Method Area란?
JVM의 메모리 영역 중 하나로, 클래스 정보 등을 저장하는 곳
MyClass obj = new MyClass();

 

만약 위와 같은 코드가 바이트 코드에 있다면, JVM은 MyClass가 Method Area에 있는지 확인하고, 없다면 Loading을 시작합니다.

 

클래소 로더 종류 역할
Bootstrap ClassLoader java.lang.*, java.util.* 등 핵심 클래스 로드
Extension ClassLoader lib/ext/ 폴더의 확장 클래스 로드
Application ClassLoader classpath에서 사용자 클래스 로드
Custom ClassLoader 사용자가 직접 정의한 클래스 로드

 

여러 가지 ClassLoader들이 계층형으로 존재합니다.

해당 ClassLoader에게 Loading을 요청하고 그 ClassLoader은 요청받은 .class를 찾아옵니다.

찾으면 .class 파일의 바이트코드를 읽고 Method Area에 저장합니다.

 

이때, 저장되는 정보는 다음과 같습니다.

  • 클래스 이름
  • 필드 정보(멤버 변수)
  • 메서드 정보
  • 접근 제어자(public, private 등)
  • 정적 변수(static field)
  • 부모 클래스 정보(상속 관계)

저장이 되면 JVM이 해당 클래스를 인식할 수 있게 되는 겁니다.

 

(2) Linking 단계

클래스를 실행하기 전, 검증 및 메모리 준비 작업을 수행하는 단계입니다.

검증 -> 준비 -> 해석 순으로 진행됩니다.

 

검증(Verification)

JVM이 클래스의 바이트코드가 올바른지 검사하는 과정

 

준비(Preparation)

클래스의 static 변수들을 위한 메모리를 할당하고 기본값 설정

 

해석(Resolution)

클래스 내부의 심볼릭 참조를 실제 메모리로 변환하는 과정

 

String s = new String("Hello");

 

위 코드가 있을 때, 이는 이전 단계에서 java/lang/String으로 심볼릭 참조(간단하게 생각하면 주소)로 변환됩니다.

이를 메모리에 탑재하여 실제 메모리 주소로 변환합니다.

java/lang/String -> 0x12345678 이런 식으로 바뀝니다.

 

(3) Initialization 단계

정적 변수의 실제 값을 할당하고, static 블록 등을 실행하는 과정입니다.

이전 Linking의 준비 단계에서 static 기본값을 할당했는데 이게 왜 있는 걸까요?

사실 Linking의 준비 단계에선 개발자가 부여한 기본값이 아니라 변수 또는 객체의 기본 값이 들어갑니다.

class MyClass {
    static int count = 10;
    static String name = "Hello";
}

 

위 클래스는 준비 단계에선 count = 0, name = null로 실행됩니다.

그리고 Initialization 단계 와서야 count = 10, name ="Hello"가 할당됩니다.

 

이 단계가 되어야 클래스는 완전히 초기화되고, 사용할 준비가 완료됐다고 할 수 있습니다.

3. Execution

그림 6. 실행 구성

 

Class Loader 에서 모든 준비는 끝났습니다.

이제 이걸 실행해야겠죠.

public class Main {
    public static void main(String[] args) {
        System.out.println("Hello, JVM!");  // (1) 한 줄씩 실행
        Calculator calc = new Calculator(); // (2) Calculator 클래스 로드
        int result = calc.add(5, 3); 
        result = calc.add(3, 3);   
        result = calc.add(2, 3);   
        result = calc.add(1, 3);   
        System.out.println(result);
    }
}

class Calculator {
    public int add(int a, int b) {
        return a + b;
    }
}

 

 

main 메서드를 호출합니다.

Interpreter가 한 줄씩 읽으면서 실행합니다.

이때, 필요한 클래스가 있으면 Class Loader가 저장해 둔 것을 꺼냅니다. 또는 Method Area에 없다면 Loading, Linking, Initialization 과정을 거쳐 불러옵니다.

 

그렇게 진행하다가 자주 실행되는 중복 코드를 발견하게 되면, 이를 JIT Compiler가 감지하고 캐싱 등을 이용해서 최적화합니다.

위 add(a, b)이 변환된 기계어를 캐싱할 수 있죠.

 

쓸모없는 변수, 메서드가 메모리 영역을 잡아먹는 것을 방지하기 위해 Garbage Collector가 메모리 영역의 객체들을 관리해 줍니다.(필요시 동작함)

위 예시에선 더 이상 calc 객체가 필요 없으므로 Garbage Collector가 메모리 영역에서 삭제합니다.

 

 

 

'Java' 카테고리의 다른 글

자바(Java) 비동기 처리에 대하여  (0) 2025.01.20