JVM(Java Virtual Machine) 실행원리와 구조

2023. 3. 27. 15:14JAVA

목차

  • JVM 개념
  • 클래스로더 서브시스템(ClassLoader Subsystem)
    • 로딩(Loading)
    • 링킹(Linking)
      • 검증(Verification)
      • 준비(Preparation)
      • 해석(Resolution)
    • 초기화(Initialization)
      • 부트스트랩 클래스 로더(Bootstrap class loader)
      • 확장 클래스 로더(Extension class loader)
      • 애플리케이션 클래스 로더(Application class loader)
  • JVM Memory
    • 메소드 영역(Method area)
    • 힙 영역(Heap area)
    • 스택 영역(Stack area)
    • PC 레지스터(PC Registers)
    • 네이티브 메소드 스택(Native method stacks)
  • 실행 엔진(Execution Engine)
    • 인터프리터(Interpreter)
    • Just-In-Time Compilter(JIT)
    • 가비지 컬렉터(Garbage Collector)
  • 자바 네이티브 인터페이스(Java Native Interface, JNI)
  • 네이티브 메소드 라이브러리(Native Method Libraries)

 

JVM 개념

JVM(Java Virtual Machine)은 자바 애플리케이션을 실행하기 위한 런타임 엔진(run-time engine)입니다. JVM은 자바 코드 안에 있는 main 메소드를 실제 호출하는 호출자입니다. JVM은 JRE(Java Runtime Environment)의 구성 요소입니다.

 

자바 애플리케이션들은 WORA(Write Once Run Anywhere)라고 불립니다. WORA의 의미는 프로그래머들은 운영체제를 가리지 않고 하나의 시스템에서 자바 코드를 개발할 수 있고 어떤 조정없이 어떤 다른 자바를 활성화할 수 있는 시스템에서 실행할 수 있다는 의미입니다. 이는 JVM 덕분에 가능한 일입니다. 즉, JVM을 이용하면 운영체제 실행 환경과 독립적으로 자바 애플리케이션을 실행할 수 있습니다.

 

.java 파일이 컴파일될때 자바 컴파일러에 의해서 자바 파일안에 클래스 이름과 같은 .class 파일(byte-code를 포함)이 생성됩니다. 이 클래스 파일(.class)들은 실행시 다양한 단계로 진행됩니다. 다음 그림은 전체적인 JVM을 설명하는 단계입니다.

 

 

Class Loader Subsystem

Class Loader Subsystem은 주요하게 3가지 책임을 가지고 있습니다.

  • Loading
  • Linking
  • Initialization

Loading

Class Loader는 ".class" 파일을 읽고 이진 데이터를 생성합니다. 생성된 이진 데이터는 Method Area에 저장됩니다. 각각의 ".class" 파일에 대해서 JVM은 Method Area에 다음과 같은 정보를 저장합니다.

  • 불러온 클래스의 완전한 이름과 해당 상위 클래스의 이름이 저장됩니다.
  • ".class" 파일이 클래스, 인터페이스 또는 Enum인지 저장합니다.
  • 접근제어자(Modifier), 필드 멤버와 메소드 정보 등을 저장합니다.

".class" 파일이 불러온 이후에 JVM은 힙 메모리에 클래스의 타입 객체를 생성합니다. 이 클래스의 타입 객체는 java.lang 패키지에 사전에 정의된 클래스 타입 객체입니다. 이 클래스 객체들은 클래스의 이름, 부모 이름, 메서드들과 변수 정보와 같은 클래스 레벨 정보를 얻기 위해 프로그래머에 의해서 사용됩니다. 이 객체 참조를 얻기 위해서 우리는 Object 클래스의 getClass() 메서드를 사용할 수 있습니다.

 

import java.lang.reflect.Field;
import java.lang.reflect.Method;

public class Test {

    public static void main(String[] args) {
        Student s1 = new Student();

        // JVM에서 생성한 Class 객체를 보유하는 중입니다.
        Class c1 = s1.getClass();

        // c1 객체를 사용하여 객체의 타입을 출력
        System.out.println(c1.getName()); // Student

        // 배열에서 모든 메서드들을 가져오기
        Method[] m = c1.getDeclaredMethods();
        // Expected Output :
        // getName
        // setName
        // getRoll_No
        // setRoll_No
        for (Method method : m) {
            System.out.println(method.getName());
        }

        // 배열에서 모든 필드를 가져오기
        Field[] f = c1.getDeclaredFields();
        // Expected Output:
        // name
        // roll_No
        for (Field field : f) {
            System.out.println(field.getName());
        }
        
    }
}

class Student {

    private String name;
    private int roll_No;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public int getRoll_No() {
        return roll_No;
    }

    public void setRoll_No(int roll_No) {
        this.roll_No = roll_No;
    }
}

실행결과

Student
getName
setName
getRoll_no
setRoll_no
name
roll_No

 

모든 ".class" 파일을 불러온 것에 대해서 클래스의 객체는 오직 한개만 생성됩니다.

Student s2 = new Student();
// c2 will point to same object where 
// c1 is pointing
Class c2 = s2.getClass();
System.out.println(c1==c2); // true

예를 들어 s1 객체와 s2 객체의 getClass() 객체를 호출하면 Class 객체가 반환되고 반환된 c1 객체와 c2 객체는 같은 객체입니다.

 

Linking

Linking 단계에서는 Verification, Preparation 그리고 선택적으로 Resolution을 수행합니다.

 

검증(Verfication)

Verification 단계에서는 ".class" 파일의 정확성을 확인합니다. 즉, 유효한 컴파일러에 의해서 적절한 형식으로 파일이 생성되었는지 아닌지를 검사합니다. 만약 확인이 실패하면, java.lang.VerifyError 런타임 에러가 발생합니다. 이 단계는 ByteCodeVerifier 컴포넌트에 의해서 수행됩니다. 일단 이 단계가 완료되면 컴파일을 위한 클래스 파일이 준비됩니다.

 

준비(Preparation)

JVM은 클래스의 정적 멤버들을 메모리에 할당하고 기본적인 값들을 메모리에 초기화합니다.

 

해석(Resolution)

상수 풀(run-time constant pool)에 있는 심볼릭 참조(symbolic reference)를 직접 참조(direct reference)로 대체하는 과정입니다. 즉, 추상적인 기호를 구체적인 값으로 동적으로 결정하는 과정입니다. JVM 명령인 anewarray, checkcast, getfield, getstatic, instanceof, invokeddynamic, invokeinterface, invokespecial, invokestatic, invokevirtual, ldc, ldc_w, multiancewarray, new, putfield, putstatic은 런타임 상수 풀에 있는 심볼릭 참조를 사용합니다.

 

심볼릭 참조(symbolic reference)

심볼릭 참조는 참조된 항목에 관한 이름이나 기타 정보를 제공하는 문자열로, 실제 객체(변수, 메소드, 타입 등)를 가져오는데 사용할 수 있습니다. 예를 들어 다음의 코드가 있습니다.

if("init".equals(opCode)){ /* ... */}

위 코드에 대한 바이트 코드는 다음과 같습니다.

3: ldc              #3 // String init
5: aload_1
6: invokevirtual    #4 // Method java/lang/String.equals:(Ljava/lang/Object;)Z
9: ifeq             12

위 #4를 보고 boolean java/lang/String.equals(Object)인지 어떻게 알수 있을가요? #4를 가지고 런타임 상수 풀에 있는 심볼릭 참조를 통해 현재 클래스로더가 로드한 실제 클래스를 확인하고 클래스 인스턴스에 대한 참조를 반환하기 때문입니다.

 

Constant pool:
   #3 = String             #26       // init
   #4 = Methodref          #27.#28   // java/lang/String.equals:(Ljava/lang/Object;)Z
   ...
   #26 = Utf8               init
   #27 = Class              #31      // java/lang/String
   #28 = NameAndType        #32:#33  // equals:(Ljava/lang/Object;)Z
   ...
   #31 = Utf8               java/lang/String
   #32 = Utf8               equals
   #33 = Utf8               (Ljava/lang/Object;)Z

상수 풀을 보면 위와 같습니다. 구체적인 값을 동적으로 결정한다는 의미는 프로그램 실행중에 실제 객체를 결정한다는 의미입니다. JVM은 이렇게 구한 직접 참조(direct references)를 기억하고 있으므로, 다시 #4와 같은  참조를 만나는 경우 다시 심볼릭 참조를 가지고 직접 참조를 찾는 과정을 거치지 않아도 됩니다.

 

해석(Resolution) 단계는 JVM 구현에 따라서 클래스를 검증할때 한번에 해석할 수도 있고(eager resolution), 당장에 심볼릭 참조를 직접 참조로 변경할 필요가 없다면 뒤로 밀려날수 있습니다.(lazy resolution) 따라서 준비(Preparation) 단계를 마치고 반드시 해석 단계가 일어나지 않으며 해석 단계는 선택적 단계입니다.

 

초기화(Initialization)

이 단계에서는 모든 정적 변수들이 할당됩니다. 이 할당은 클래스에서 위에서 아래로 실행되고  클래스 상속관계에서 부모 클래스에서 자식 클래스로 진행됩니다. 일반적으로 3가지 클래스 로더(class loader)가 있습니다.

 

클래스로더(Classloader)

클래스로더는 자바의 클래스를 로드하는 객체를 말합니다. 클래스로더를 통해서 런타임에 동적으로 클래스를 불러올수 있으며, 보통은 패키지에 있는 클래스 파일(.class)을 사용해서 클래스를 로드합니다.

부트스트랩 클래스 로더(Bootstrap class loader)

모든 JVM 구현은 부트스트랩 클래스로더를 가져야 합니다. 부트스트랩 클래스 로더는 "JAVA_HOME/jre/lib" 디렉토리에 있는 자바에서 기본적으로 제공하는 API 등과 같은 표준 JDK 클래스들을 불러옵니다. 부트스트랩 클래스 로더는 C, C++와 같은 네티이브 언어로 구현되며 자바에서 모든 클래스로더의 부모 역할을 합니다.

 

확장 클래스 로더(Extension class loader)

확장 클래스 로더는 부트스트랩 클래스 로더의 자식입니다. 확장 클래스 로더는 "JAVA_HOME/jre/lib/ext"(Extension path) 디렉토리에 있는 클래스를 불러오거나 "java.ext.dirs system property"에 의해서 명시된 다른 디렉토리를 불러옵니다. 확장 클래스 로더는 "sum.misc,Launcher$ExtClassLoader" 클래스로 자바에 구현되어 있습니다.

 

애플리케이션 클래스 로더(Application class loader)

애플리케이션 클래스 로더는 확장 클래스 로더의 자식입니다. 애플리케이션 클래스 로더는 애플리케이션 classpath로부터 클래스들을 불러오는 책임이 있습니다. 애플리케이션 클래스 로더는 내부적으로 "java.class.path"에 매핑되는 환경 변수를 사용합니다. 애플리케이션 클래스 로더는 또한 "sun.misc.Launcher$AppClassLoader" 클래스에 의해서 자바에서 구현되었습니다.

 

 

public class Test {

    public static void main(String[] args) {
        // 클래스 로더 서브시스템 예제
        // String 클래스는 부트스트랩 클래스 로더에 의해서 불러옵니다.
        // 부트스트랩 로더는 자바 객체가 아니므로 null이 반환됩니다.
        System.out.println(String.class.getClassLoader()); // null

        // Test 클래스는 애플리케이션 클래스 로더에 의해서 불러옵니다.
        System.out.println(Test.class.getClassLoader()); // jdk.internal.loader.ClassLoaders$AppClassLoader@71bc1ae4
    }
}

실행결과

null
jdk.internal.loader.ClassLoaders$AppClassLoader@71bc1ae4

JVM은 클래스들을 불러오기 위해서 위임-계층 원칙(Delegation-Hierarchy principle)을 따릅니다. 애플리케이션 클래스 로더는 확장 클래스 로더(Extension class loader)에게 Load 요청을 위임합니다. 그리고 확장 클래스 로더는 부트스트랩 클래스 로더에게 Load 요청을 위임합니다. 만약 클래스가 부트스트랩 경로(boot-strap path)에 발견된다면, 확장 클래스 로더와 애플리케이션 클래스 로더에 재전송 요청없이 클래스를 불러옵니다. 마지막으로 애플리케이션 클래스 로더가 클래스를 불러오는데 실패한다면 java.lang.ClassNotFoundException 런타임 예외를 발생합니다.

 

JVM Memory

메소드 영역(Method area)

메소드 영역 안에 클래스 이름, 부모 클래스 이름, 메서드, 변수 정보 등과 같은 모든 클래스 레벨 정보들이 저장됩니다. 메소드 영역 안에 정적 변수들도 저장됩니다. 메소드 영역에는 오직 JVM당 하나의 메소드 영역밖에 없습니다. 그리고 메소드 영역은 리소스가 공유되는 영역입니다.

 

힙 영역(Heap area)

모든 객체들의 정보가 힙 영역에 저장됩니다. 힙 영역 또한 JVM 당 하나밖에 존재하지 않습니다. 힙 영역 또한 리소스가 공유되는 영역입니다.

 

스택 영역(Stack area)

모든 스레드에 대해서 JVM은 스택 영역에 저장된 런타임 스택을 하나 생성합니다. 이 스택의 모든 블록은 메소드 호출들을 저장하는 record/stack 프레임 활성화라고 부릅니다. 메소드의 모든 지역 변수들은 프레임에 따라 저장됩니다. 스레드가 종료된 후에 런타임 스택은 JVM에 의해서 삭제될 것입니다. 스택 영역은 리소스가 공유되지 않습니다.

 

PC 레지스터(PC Registers)

현재 스레드의 실행 명령어의 주소를 저장합니다. 각각의 스레드는 PC 레지스터를 따로 가지고 있습니다.

 

네이티브 메소드 스택(Native method stacks)

모든 스레드에 대해 분리된 네이티브 스택이 생성됩니다. 스레드는 네티이브 메소드 정보를 저장합니다.

 

 

실행 엔진(Execution Engine)

실행 엔진은 ".class"(bytecode)를 실행합니다. 실행 엔진은 바이트 코드를 한줄식 읽고, 다양한 메모리 영역에 저장된 데이터와 정보를 사용하며, 명령을 실행합니다. 실행 엔진은 3가지로 분류될수 있습니다.

 

인터프리터(Interpreter)

인터프리터는 한줄씩 바이트코드를 번역하고 실행합니다. 단점은 하나의 메소드가 여러번 호출될때마다 인터프리터 번역이 요구되는 점입니다.

 

Just-In-Time Compiler(JIT Compiler)

JIT 컴파일러는 인터프리터의 효율성을 증가시키기 위해서 사용됩니다. JIT 컴파일러는 전체 바이트코드를 컴파일하고 인터프리터가 반복적으로 메소드 호출을 만날때마다 네티이브 코드로 변경합니다. JIT은 해당 부분에 대한 직접적인 네이티브 코드를 제공하므로 재번역이 필요하지 않아 효율성이 증가됩니다.

 

가비지 컬렉터(Garbage Collector)

가비지 컬렉터는 참조하지 않은 객체들을 해제합니다.

 

자바 네이티브 인터페이스(Java Native Interface)

자바 네티이브 인터페이스는 네이티브 메소드 라이브러리와 상호작용하는 인터페이스입니다. 그리고 자바 네티이브 인터페이스는 실행에 요구되는 네티이브 라이브러리(C, C++)를 제공합니다. 자바 네티이브 인터페이스는 JVM이 C/C++ 라이브러리를 호출하게 하는 것을 가능하게 하고 하드웨어와 관련된 C/C++ 라이브러리들에 의해서 호출되게 하는 것도 가능하게 합니다. 

 

네이티브 메소드 라이브러리(Native Method Libraries)

네이티브 메소드 라이브러리는 실행 엔진이 요구하는 네이티브 라이브러리(C, C++)의 컬렉션입니다.

 

 

 

 

 

 

References

https://www.geeksforgeeks.org/jvm-works-jvm-architecture/
https://zitto15.tistory.com/40
https://blog.hexabrain.net/397