Java의 동작원리

성훈·2024년 10월 4일

Java

목록 보기
1/2
post-thumbnail

Java의 등장배경

Java는 1990년 초 James Gosling의 팀에 의해 개발된 객체지향언어입니다. 당시 네트워크 장치 및 임베디드 시스템들은 각기 다른 하드웨어와 소프트웨어를 가지고 있었습니다. 그에 따라 플랫폼마다 다른 버전으로 빌드되어야 했습니다.
Java는 이를 개선하고자 한 번 작성된 프로그램은 어느 플랫폼에서도 실행하고자 하는 플랫폼 독립성을 목표로 개발되었습니다. 이를 위해 Java의 가상 컴퓨터인 JVM은 운영체제별로 그에 맞는 형태로 설치되어, 다양한 운영체제에서도 java 파일을 컴파일하여 실행시킬 수 있습니다.
이 후 Java는 30년이 지난 지금 Java 23 버전까지 진화하였고, 대규모 시스템에서도 안정성이 입증되어 많은 기업들에게 사용되고 있습니다.

Java 구성

JDK(Java Development Kit)란?

Java 애플리케이션을 개발하고 실행시킬 수 있는 도구를 모아둔 개발 킷입니다.
JRE와 Java Development Tools이 포함되어 있습니다.

JRE(Java Runtime Environment)란?

Java Compiler로 컴파일한 클래스 파일을 실행할 수 있는 Java 실행환경입니다.
개발시 자주 쓰이는 라이브러리들을 모아둔 Java Class Library와 JVM으로 이루어져 있습니다.

Development Tools란?

Java Application을 개발, 컴파일, 디버깅, 실행, 모니터링 할 수 있는 도구들입니다.
자바의 컴파일러인 javac, 실행 도구인 javaAppLauncher, API 생성도구인 javadoc 등이 포함되어 있습니다.

Java 동작원리

그렇다면 Java는 어떻게 동작할까요?

Java 코드는 바이트코드로 컴파일된 후, 클래스로더에 의해 JVM의 메모리(Runtime Data Area)에 로딩하여 인터프리터와 JIT 컴파일러를 통해 실행시키며 Garbage Collector가 메모리를 관리합니다.

하나씩 알아보겠습니다.

코드 작성 및 Compile

코드를 작성 후 Java Compiler로 컴파일을 합니다. 컴파일된 코드는 바이트코드로 변환됩니다.

public class TestClass {

    static int staticVar = 100;
    static final int STATIC_FINAL_VAR = 200;

    int instanceVar;
    final int instanceFinalVar = 50;

    static {
        staticVar = 150;
    }

    {
        instanceVar = 50;
    }

    public static void main(String[] args) {
        TestClass test = new TestClass();
    }
}
컴파일한 바이트 코드 예시(역어셈블)
public class TestClass {
  static int staticVar;
  static final int STATIC_FINAL_VAR;

  int instanceVar;
  final int instanceFinalVar;

  static {};
    Code:
       0: bipush        150
       2: putstatic     #2                  
       5: return

  {};
    Code:
       0: bipush        50
       2: putfield      #4               
       5: return

  public static void main(java.lang.String[]);
    Code:
       0: new           #5                  
       3: dup
       4: invokespecial #1                
       7: astore_1
       8: return
}
// 16진수 바이트코드 예시
10 96 B3 00 02 B1 10 32 B5 00 04 B1 BB 00 05 59 B7 00 01 4C B1

바이트코드는 JVM의 인터프리터가 해석할 수 있는 코드입니다.
이 과정에서 소스 코드를 토큰으로 분해하여 구문을 검증합니다. 심볼 테이블 생성, 클래스 계층 구조 확인, 접근 제어 확인, 최적화 등이 이루어지면 바이트코드 생성이 완료되어 .class 파일로 저장됩니다.

심볼 테이블이란?
소스코드가 컴파일될 때 식별자에 대한 정보를 저장하는 테이블입니다. 심볼 이름, 심볼 유형, 데이터 타입 및 메모리 위치 등이 저장되어 유효성을 검증하고 최적화하는데 사용됩니다.

Class Loader

클래스를 JVM의 Runtime Data Area로 로드하는 역할을 합니다.
클래스가 처음으로 인스턴스화 되거나 클래스의 멤버가 처음 사용될 때 동적으로 로드됩니다.
Loading, Linking, Initialization의 과정을 거칩니다.

Loading

세 가지 클래스 로더가 "위임 방식"으로 클래스를 메서드 영역으로 로드합니다.
Bootstrap Class Loader -> Platform Class Loader(java 9 이후) -> Application(System) Class Loader -> User Defined Class Loader(존재 시) 순으로 Class Loader가 클래스를 로드합니다.

위임방식이란?
필요한 클래스를 로드할 수 있는 해당 Class Loader에게 위임하는 형태를 의미합니다.
우선, JVM이 해당 클래스가 이미 메서드 영역에 로드되어 있는지 확인합니다. 로드되어 있으면 로드되어 있는 클래스를 사용하고, 아니면 Application Class Loader에게 로드를 요청합니다.
Application Class Loader가 이를 확인하고 Extension Class Loader에, Extension Class Loader는 BootStrap Class Loader에 요청을 위임합니다. 이후 Bootstrap Class Loader부터 클래스 로드가 시작하여 로드할 수 있는 클래스들을 로드하고 하위 Class Loader에게 위임합니다.

위임방식의 이유?
안정성과 보안성 때문입니다. 상위 Class Loader가 먼저 클래스를 로드하기 때문에 하위 로더가 임의로 클래스의 동작을 변경할 수 없고, 여러 Class Laoder가 기본 라이브러리 등을 각각 로드해서 충돌하는 일을 미연에 예방할 수 있습니다.

Bootstrap Class Loader

JVM에서 필요한 기본적인 클래스들을 로드합니다. java.lang, java.nio 등 클래스와 런타임 환경에 내장된 코어 라이브러리 등을 JAVA_HOME/lib 디렉터리에서 찾아 로드합니다.

Platform Class Loader

플랫폼 관련 클래스들을 로드합니다. javax, java.sql, java.xml 등의 API와 JDK의 플랫폼 수준 모듈 클래스들을 JAVA_HOME/lib/ext와 플랫폼 모듈 경로에서 찾아 로드합니다.

Application Class Loader

애플리케이션 클래스 경로(ClassPath)에 있는 클래스를 로드합니다.

User-Defined Class Loader

개발자가 직접 코드 상에서 새로운 class loader를 생성해서 직접 사용할 수도 있습니다. 이는 App class Loader 이후에 작동합니다.

마지막 클래스로더까지 내려왔는데도 클래스를 마지막까지 찾을 수 없는 경우 NoClassDefFoundError, ClassNotFoundException 등의 예외를 던집니다.

Linking

로드된 클래스 파일들을 검증하고 실행할 수 있도록 준비합니다. Verification, Preparation, Resolution 과정을 거칩니다.

Java의 동적링킹(Dynamic Linking)
C 언어와 같이 컴파일할 때 프로그램 내에서 변수와 함수의 메모리 위치가 정해지는 언어와 달리, Java는 클래스가 처음 사용될 때 로드되고 링킹이 이루어지기 때문에 메모리 위치를 컴파일 순간에 정할 수가 없습니다. 따라서 클래스 로드시에 동적으로 링킹이 이루어집니다.

Verification

클래스 파일이 구조적으로 올바른지 확인합니다. Java의 버전이 맞지 않거나, JVM의 스펙에 맞지 않는 바이트 코드인지 등을 확인하여 실행 시 오류를 최소화하기 위한 과정입니다.

검증에 실패한다면 VerifyException을 던집니다.

Preparation

클래스나 인터페이스의 정적 필드들이 JVM의 메모리에 할당되는 과정입니다. 숫자 자료형은 0, boolean은 false, char와 참조형은 null이 할당됩니다. 메서드 영역으로의 메서드 정의도 이 단계에서 이루어집니다.

public class HelloJack {
    static int age = 0;  
    static String name = null;  
  	static boolean married = false;
}

Resolution

바이트코드에 있는 클래스, 메서드, 필드 등의 심볼릭 레퍼런스를 다이렉트 레퍼런스(실제 메모리 주소)로 변환하는 과정입니다. 바이트 코드에서 클래스, 메서드, 필드 등의 참조는 이름 형태로 저장되어 있을뿐 저장 위치 등은 알 수가 없습니다. 상수 풀(Constant Pool)에서 해당 데이터를 가져와 변환시킵니다.

예를 들어보겠습니다.

public class Example {
    public void sayHello() {
        System.out.println("Hello World!");
    }
}

위 코드가 컴파일 되면 Example.class 파일이 생성됩니다. 이 파일에는 상수 풀이 포함되어 있으며 상수 풀에는 System 클래스와 out 필드, println 메서드가 포함되어 있고 "Hello World"라는 문자열에 대한 정보도 포함됩니다.
sayHello()라는 메서드가 실행되면 JVM은 상수 풀에서 System 클래스와 out 필드, println 메서드의 정보를 찾아 실제 메모리로 위치를 변환시킵니다.
그런 다음 JVM은 해당 메모리에서 메서드를 실행시켜 "Hello World"를 출력시킵니다.
심볼릭 레퍼런스를 계속 사용하지 않고 메모리로 위치로 직접 변환시키는 이유는 성능상의 이유입니다. 이후 해당 메서드를 사용할 때 참조 대신 메모리를 직접 사용하면 되기 때문입니다.

Constant Pool :
#1  = Methodref         #3.#8     // java/lang/Object."<init>":()V
#2  = Fieldref          #9.#10    // java/lang/System.out:Ljava/io/PrintStream;
#3  = Class             #11       // java/lang/Object
#4  = Class             #12       // Example
#5  = String            #13       // "Hello World!"
#6  = Methodref         #14.#15   // java/io/PrintStream.println:(Ljava/lang/String;)V
#7  = Methodref         #4.#8     // Example.sayHello:()V
#8  = NameAndType       #16.#17   // "<init>":()V
#9  = Class             #18       // java/lang/System
#10 = NameAndType       #19.#20   // out:Ljava/io/PrintStream;
#11 = Utf8              "java/lang/Object"
#12 = Utf8              "Example"
#13 = Utf8              "Hello World!"
#14 = Class             #21       // java/io/PrintStream
#15 = NameAndType       #22.#23   // println:(Ljava/lang/String;)V
#16 = Utf8              "<init>"
#17 = Utf8              "()V"
#18 = Utf8              "java/lang/System"
#19 = Utf8              "out"
#20 = Utf8              "Ljava/io/PrintStream;"
#21 = Utf8              "java/io/PrintStream"
#22 = Utf8              "println"
#23 = Utf8              "(Ljava/lang/String;)V"

해석에 실패하면 NoClassDefFoundError와 같은 오류가 발생합니다.

Initialization

생성자 호출, 정적 필드의 초기화, 정적 초기화 블록이 실행됩니다. 클래스가 처음 사용될 때 한 번만 수행됩니다.

public class HelloJack {
    static int age = 29;  
    static String name = "Jack";  
  	static boolean married = false;
 	static {
  		System.out.print("Class loading..")
     }
}

Runtime Data Area

Runtime Data Area는 JVM이 운영체제로부터 할당받는 메모리 영역입니다. 모든 스레드가 공유하는 메서드 영역(Runtime Constant Pool 포함), 힙 영역과 스레드 별로 존재하는 스택 영역, PC Register, Native Method Stack으로 이루어집니다.

메서드 영역

example image

클래스의 메타데이터가 저장됩니다. 클래스 정보, 메서드 정보, 정적 변수, Constant Pool(상수 풀) 등이 저장되며 모든 스레드가 함께 공유합니다.

  • Constant Pool vs Runtime Constant Pool
    Constant Pool은 클래스 파일에 저장된 문자열, 상수 값, 클래스 및 메서드 참조 정보를 가지고 있습니다. 컴파일 시점에 생성되고 클래스 파일 안에 포함됩니다.
    Runtime Constant Pool은 메서드 영역에 로드되는 동적 상수 풀입니다. 클래스가 로드될 때 Constant Pool의 내용이 복사되어 사용됩니다. JVM은 클래스의 필드를 사용할 때 이를 참조하여 실제 클래스의 정보가 저장된 곳으로 이동하여 사용합니다.
Constant pool
  
Constant pool:
   #1 = Methodref          #6.#18         // java/lang/Object."<init>":()V
   #2 = Fieldref           #5.#19         // TestClass.staticVar:I
   #3 = Fieldref           #20.#21        // java/lang/System.out:Ljava/io/PrintStream;
   #4 = Methodref          #22.#23        // java/io/PrintStream.println:(I)V
   #5 = Class              #24            // TestClass
   #6 = Class              #25            // java/lang/Object
   #7 = Utf8               staticVar
   #8 = Utf8               I
   #9 = Utf8               TestClass
  #10 = Utf8               staticFinalVar
  ...

힙 영역

생성된 객체의 데이터가 저장되는 공간입니다. 객체가 생성되면 객체는 힙 영역에 저장되고, 객체가 생성될 때 생성된 변수는 스택 영역에 변수와 힙 영역의 주소 형태로 저장됩니다. 힙 영역은 모든 스레드에 함께 공유합니다.

스택 영역

메서드 호출 시 발생하는 지역 변수, 매개변수, 리턴 값 등이 저장되는 공간입니다. 각 스레드별로 독립적으로 생성되고 스레드 간에 접근이 제한됩니다.

스택 프레임

메서드가 호출되면 스택 프레임 하나가 생성되어 스택 영역에 추가됩니다. 스택 프레임 안에는 지역 변수 배열, 피연산자 스택, 런타임 상수 풀 레퍼런스 등이 포함됩니다. 메서드 수행이 완료되면 스택 영역에서 스택 프레임은 제거됩니다.

  • 지역변수 배열 : 메서드에서 필요한 변수들이 저장된 배열입니다. 클래스 인스턴스, 파라미터, 지역변수 순으로 배열에 저장됩니다.
  • 피연산자 스택 : 메서드의 연산이 이루어지는 공간입니다. 지역변수 배열과 데이터를 교환하며 연산이 이루어집니다.
  • 런타임 상수 풀 레퍼런스 : 메서드 영역에 있는 런타임 상수풀을 참조합니다.

PC Register

스레드가 현재 실행 중인 JVM 명령어의 주소를 저장하는 레지스터입니다. 스레드별로 생성되어 현재 어떤 명령어를 실행시키고 있는지 추적하여 흐름을 제어합니다.

Native Method Stack

Native method를 실행시킬 때 사용되는 영역입니다.

// 클래스와 메서드(메서드 영역에 저장)
public class Example {
    static int staticVar = 100; // 정적 변수 (메서드 영역에 저장)
    int instanceVar = 50;     // 인스턴스 변수 (힙 영역에 저장)
	
  	// main 메서드로서 진입점 역할을 함. 실행 시 스택 프레임에 추가
    public static void main(String[] args) {
        int localVar = 10; // 지역 변수 (스택 영역에 저장)
        Example obj = new Example(); // 매개 변수는 스택 영역에, 객체는 힙 영역에 저장
        obj.method(localVar); // 메서드 호출 (스택 영역에 스택 프레임 생성)
    }
	
  	// 매개변수 (스택 영역에 저장)
    public int method(int param) {
        int twice = param * 2; // 지역 변수 (스택 영역에 저장)
  		return twice; // 반환 값 또한 스택 영역에 저장
    }

}

Execute Engine

바이트코드를 명령어 단위로 실행하는 엔진입니다.

Interpreter

바이트코드를 한 줄씩 해석하고 실행합니다.

JIT(Just In Time) Compiler

자주 실행되는 바이트코드를 실시간으로 감지하여 네이티브 코드로 컴파일합니다. 이를 통해 프로그램 성능을 향상시킵니다.

동적 프로파일링과 HotSpot
JIT 컴파일러는 실행 중 데이터를 수집하여 코드가 여러번 실행되는 지점(HotSpot)을 기계어로 컴파일합니다.

Garbage Collector

사용되지 않는 객체를 관리하여 메모리를 확보합니다. 따라서 힙 영역이 GC의 대상이며 Mark and Sweep, Generational Garbage Collection 등의 알고리듬을 통해 이루어집니다.

Native Method Interface(JNI)

자바 프로그램이 네이티브 메서드(C/C++ 코드로 작성된 메서드)를 호출하게 하는 인터페이스입니다. 운영체제 및 하드웨어와의 통신을 위한 네이티브 코드 호출 시 사용됩니다.

Native Method Library

네이티브 메서드를 포함한 라이브러리입니다. 각 운영체제에 맞는 형식으로 제공되며 자바에서 호출될 네이티브 코드가 들어있습니다.

출처

profile
백엔드 개발자

0개의 댓글