본문 바로가기
JAVA

JVM의 동작 원리와 구조 구체적으로 이해하기

by seongju.lee 2024. 2. 12.

[목차]

1. 가상머신(VM)의 이해

2. JVM의 이해

3. JVM의 동작 원리와 구조(Class Loader, Runtime Data Area, Execution Engine)

 

위 목차 순서대로 JVM의 전반적인 동작 원리와 구조를 파악해보고자 한다.

 

1.  가상머신(VM)의 이해


JVM(Java Virtual Machine)이 무엇인지 이해하기 위해서는 당연히 가상머신(VM: Virtual Machine)이 무엇이고 어떻게 동작하는지 이해하고 있어야 한다. 가상머신은 물리적인 컴퓨팅 환경을 소프트웨어로 구현한 것인데, 가상머신은 시스템 가상 머신, 프로세스 가상 머신으로 나눌 수 있다.

 

  • 시스템 가상 머신
    • 하나의 컴퓨터로 여러 OS를 사용하는 환경을 고립되게 구축할 수 있는데, 이는 OS와 애플리케이션을 완전히 분리하는 형태를 의미한다. 

    • 실제 컴퓨터가 제공하는 것과 다른 형태의 명령어 집합 구조(ISA)를 제공한다.

 

  • 프로세스 가상 머신
    • OS안에서 일반 응용 프로그램을 실행하고, 단일 프로세스를 지원하는 형태로, 이는 물리적인 컴퓨터에서 단일 프로세스를 분리하는 것을 의미한다.

    • 아무 플랫폼에서나 같은 방식으로 실행하는 프로그램을 허용하고, 기초가 되는 하드웨어나 OS의 상세한 부분을 가져오는 독립적인 환경을 위한 것이 목적이다.

 

 

여기서 이해하고자 하는 가상머신은 프로세스 가상 머신이며 앞으로 말하는 가상머신은 프로세스 가상 머신을 의미한다. 이러한 가상머신을 개념 하는 방식은 두 가지(스택 기반의 VM, 레지스터 기반의 VM)가 존재한다. 이 중에, JVM이 스택기반의 VM이다.

 

 

 

 

2.  JVM의 이해


JVM은 프로세스 가상머신 중 스택기반 VM이다. 아래 구성도를 통해 JVM이 어떤 것이고, 무슨 일을 담당하는지 확인해 볼 수 있다.

 

JVM 구성도

구성도의 좌측 상단을 보면 Class File이 JVM으로 들어온다. 이 Class File이라는 것은 소스코드가 변환된 자바 바이트코드들이며, JVM의 명령어 집합이다. JVM에서 바이트코드를 명령어 단위로 읽어 JVM이 실행할 수 있도록 컴파일해주고 실행한다.

 

 

그렇다면 자바 바이트코드가 JVM 내부로 어떻게 들어오게 되고, JVM 내부에서 어떤 방식으로 바이트코드를 읽고, 컴파일 하는 지를 세부적으로 알아봐야 한다.

 

 

 

 

 

3.  JVM의 동작방식


JVM 구성도

이제부터는 위 구성도에서 나뉜 것처럼 Class Loader, Runtime Data Areas, Execution Engine에 대해 알아볼 것이다.

 

 

 

 

3.1.  클래스 로더 (Class Loader)

 

클래스를 메모리에 올리는 동적 클래스 로딩 기능을 담당한다.자바 클래스들이 한번에 메모리에 올라가는 게 아닌 애플리케이션에 의해 필요할 때 동적으로 메모리에 올라가게 되는 것이다. 즉, Runtime시에 동적으로 클래스를 로드하며 클래스를 처음으로 참조할 때 해당 클래스를 로드하고 링크하는 것이다. 클래스 로딩은 세 단계로 이루어진다.

 

 

3.1.1. Loading

클래스를 읽어오는 과정이다. 클래스 로더에 의해 메모리에 로드된 ".class"파일을 읽고, 분석하여 데이터를 런타임 데이터 영역(3.2)에 저장하게 된다. 저장하는 내용들은 상수, 클래스 바이트코드, 기본 값 할당 등 종류에 따라 런타임 데이터 영역의 서로 다른 부분에 필요한 정보들이 배치(3.2)된다.

추가로 해당 단계에서는 클래스, 인터페이스, 메서드, 필드 등에 대한 심볼릭 레퍼런스라는 값을 런타임 상수 풀(3.2.5)에 복사하는데, 심볼릭 레퍼런스는 클래스, 인터페이스, 메서드 등을 식별하기 위한 추상적인 정보를 포함한다. 이 정보는 다음 단계인 Linking(3.1.2)에서 사용된다.

 

 

3.1.2. Linking

참조와 실제 메모리 주소 값을 연결하는 과정으로, 링크 단계는 세 단계로 나뉜다.

 

  • Verify: 로드된 ".class" 파일이 유효한 지 확인하는 과정이다. ".class" 파일이 JVM의 명세대로 구현되지 않은 경우에는 에러가 발생하게 된다.
  • Prepare: 클래스나 인터페이스에 필요한 메모리를 할당하는 과정이다. 즉, 메모리를 준비하는 과정이다.
  • Resolve: Loading단계에서 복사한 심볼릭 레퍼런스(symbolic reference) 값을 다이렉트 레퍼런스(direct reference)라는 메모리 주소 값으로 변경한다. new나 instanceof 같은 요소가 해당 단계의 영향을 받는 것이다.
    (e.g. Book b = new Book();  // 참조변수 b가 Heap에 저장된 Book의 메모리 주소를 참조하도록 연결하는 과정)

 

 

3.1.3. Initialization

java코드에서 선언한 static 변수와 static 메서드를 지정한 값들로 초기화 및 초기화 메서드를 실행시켜 주는 과정이다. 앞선 단계에서 참조에 대한 값을 실제 메모리 주소로 변경하였으므로, 지정한 값들로 초기화 하는 것이다.

해당 과정까지 마치면 JVM에서 클래스 파일을 구동시킬 준비가 끝난다.

 

클래스 로더에 대해 간략하게 설명했다. 위 내용은 결국 읽어들인 클래스 파일들을 런타임 데이터 영역에 적절하게 배치하는 작업이다.

그렇기 때문에 결국 런타임 데이터 영역(3.2)에 대한 이해가 필요하고, 아래 3.2절에서 클래스 로더에 대한 내용도 나오니 함께 확인하면 이해가 수월하다.

 

 

 

 

3.2.  런타임 데이터 영역(Runtime Data Areas)

 

JVM은 프로그램 실행 중에 사용되는 런타임 데이터 영역을 정의한다. 이 영역 중 일부의 생명주기는 JVM이 시작될 때 생성되고 종료될 때 소멸되며, 스레드별로 생성되는 데이터 영역도 있다. 스레드별로 생성되는 영역은 스레드의 생명주기와 동일하게 스레드가 생성될 때 생성되고 종료될 때 소멸된다.

 

 

 

3.2.1.  PC 레지스터

JVM은 동시에 많은 스레드를 지원할 수 있다. 때문에 스레드별로 실행중인 현재 명령어의 위치를 저장하고 추적해야 할 필요가 있는데, 이 역할을 담당하는 것이 PC(Program Counter) 레지스터이다. PC 레지스터는 현재 실행 중인 명령어의 주소를 포함하며, 스레드 간의 컨텍스트 스위치가 발생할 때 이 레지스터를 적극 활용한다. 

 

JVM 내의 각 스레드는 자신만의 PC 레지스터를 가지고 있다. JVM 내의 각 스레드는 자신만의 호출 스택(3.2.2 참고)을 가지면서 어느 시점에서든 하나의 메서드의 코드를 실행한다. 그리고, 현재 실행중인 메서드가 네이티브 메서드라면 PC 레지스터는 정의되지 않는다. 이는 네이티브 메서드 실행이 JVM 관리 범위를 벗어나 os나 다른 네이티브 실행 환경에서 처리되기 때문이다. 즉, PC 레지스터는 각 스레드별로 현재 실행중인 메서드가 네이티브 메서드가 아니라면 현재 실행 중인 JVM 명령어의 주소를 저장하는 담당을 한다.

 

 

 

3.2.2.  JVM 스택

JVM 스택의 용도는 각 스레드가 실행 중인 메서드 호출 및 반환에 관여하고, 지역 변수와 결과들을 저장함으로써 메서드 호출과 실행에 대한 전반적인 관리를 하는데 있다.

 

JVM 내의 각 스레드는 생성될 때 자신만의 개인 스택을 가진다. 이 스택은 프레임을 저장하는데 사용되며, 이 프레임은 메서드 호출과 실행 과정에서 필요한 다양한 정보를 저장한다. 즉, 프레임은 프레임을 생성하는 스레드의 JVM 스택에 할당되며, 각 프레임은 지역변수 배열, 피연산자, 그리고 현재 메서드 클래스의 런타임 상수 풀에 대한 참조를 가진다.

 

 

이 스택이 프레임을 푸시(push)하거나 팝(pop) 하는 조작방식은 아래와 같다.

public class Example {
    public static void main(String[] args) {
        int result = methodA(); // methodA의 결과를 받음
        System.out.println(result);
    }

    public static int methodA() {
        int value = methodB(); // methodB의 반환 값을 받음
        return value + 10; // methodB의 결과에 10을 더해 반환
    }

    public static int methodB() {
        return 5; // 5를 반환
    }
}

 

 

스택 push 과정

1. main() 호출

  • 스레드가 시작되면 main()이 호출된다. main()의 프레임이 스택에 push된다.
  • 이 프레임에는 main()의 파라미터, 지역변수, 연산결과를 위한 피연산자 스택 정보들이 포함된다.

 

2. main()에서 methodA() 호출

  • methodA()의 프레임이 main()의 프레임 위에 스택에 push된다.
  • methodA() 프레임도 역시 파라미터, 지역변수, 피연산자 스택이 포함된다.

 

3. methodA()에서 methodB() 호출

  • 위와 마찬가지로 methodB()의 프레임이 스택에 push된다.

 

 

 

스택 pop 과정

 

반대 순서대로(methodB 프레임 -> methodA 프레임 -> main 프레임) 스택에서 pop된다. 반환되는 과정에서 반환된 값이 있다면, 이전 프레임의 피연산자 스택에 push된다. 예를 들어, methodB()에서 반환된 5는 methodA()의 프레임의 피연산자 스택에 push되어 사용되어 진다.

 

 

추가로, 스택에 대한 예외는 아래와 같이 있다.

  • StackOverflowError: 스레드의 계산이 허용된 JVM 스택 크기보다 큰 스택을 필요로 하는 경우, JVM은 StackOverflowError를 던진다.
  • OutOfMemoryError: JVM 스택이 동적으로 확장될 수 있는데 확장 시도 시 충분한 메모리를 확보할 수 없거나, 새 스레드를 위한 초기 JVM 스택을 생성할 때 충분한 메모리를 확보할 수 없는 경우, JVM은 OutOfMemoryError를 던진다.

 

 

 

3.2.3.  Heap

힙 메모리 공간은 모든 JVM 스레드 간에 공유되는 공간이다. 이는 클래스 인스턴스(Instance)와 배열(Array)이 저장되는 공간이다. Java 애플리케이션 내의 모든 동적 할당은 힙 메모리 공간에서 이루어진다. 또한, 모든 스레드 간에 공유되는 공간이다보니 동기화 이슈가 수반된다. 즉, 힙 메모리가 멀티 스레딩 환경에서 스레드 간에 공유된다는 것은 자원을 효율적으로 사용할 수 있다는 장점도 있지만, 데이터의 일관성과 스레드의 안정성을 보장하기 위한 추가적인 처리가 필요하다.

 

힙은 JVM이 시작될 때 생성되며 객체에 대한 메모리는 이 영역에서 할당된다. 때문에 메모리를 할당(allocate)하고 해제(deallocate)하는 작업이 필수적이다. 이 관리를 해주는 자동 저장 관리 시스템을 내부적으로 가지는데 이것이 가비지 컬렉터(garbage collector)이다. 이 덕분에 C, C++ 처럼 명시적으로 다 쓴 객체를 해제하지 않아도 내부적으로 회수하기 때문에 편리하다. (하지만, null 처리 등을 통해 다 쓴 객체의 참조를 해제하기 위해 유도해야 하는 경우도 있으니 참고하자)

 

 

Java8 이후의 힙 영역

 

힙 영역의 세부 구조로는 Young Generation과 Old Generation이 있다. 구체적인 설명은 GC에 대한 깊은 이해가 필요하다보니 간략하게 설명만 하면 아래와 같다.

  • Young Generation
    • Eden Space: 새로 생성된 객체가 배치되는 곳입니다. 대부분의 객체는 여기서 생성되고, 일정 시간이 지나지 않고 사용되지 않는다면 소멸된다.
    • Survivor Space (S0 and S1): Eden 영역에서 살아남은 객체들이 이동하는 곳이다. 객체들은 S0와 S1 사이에서 이동하며, 여러 번의 가비지 컬렉션(GC) 사이클을 거치면서 살아남은 객체는 Old Generation으로 이동한다.

 

  • Old Generation
    • 장시간 동안 사용되는 객체들이 이동하는 영역이다. Young Generation에서 살아남은 객체들이 이곳으로 이동하며, 가비지 컬렉션이 발생하는 빈도가 상대적으로 낮다.

 

힙 영역에 대한 전체적인 메모리 관리 구조를 설명할 때, 중요한 부분이 메타데이터를 저장하는 Metaspace라는 영역이 있다.

 

Java8버전 이후의 Heap 영역의 구조는 정확히 말하면 Young Generation, Old Generation만 포함한다. Java8 이전까지는 클래스의 메타데이터(클래스 정의, 메서드 정보, 클래스 변수 등)가 Heap 영역에 저장(이 공간을 PermGen 영역이라 함)되었다. 하지만, Java8 버전 이후 Metaspace라는 공간이 운영체제가 관리하는 네이티브 메모리 영역으로 분리되면서 메모리 관리 전략이 진화되었다.

 

이전에는(Java8 이전) JVM 시작 시, 설정된 고정된 크기에 많은 클래스 로드와 많은 정적 데이터를 사용하는 경우 PermGen 영역이 고갈되면서 'java.lang.OutOfMemoryError: PermGen space' 오류로 이어지곤 했다. 그러나, Metaspace라는 네이티브 메모리 영역을 사용함으로써 JVM이 메모리 요구사항에 따라 필요한 만큼 동적으로 메모리를 할당받을 수 있게 되었다.

 

 

 

3.2.4.  Method Area

이 부분이 위에서 언급했던, Metaspace라는 영역이다. 이 영역은 클래스 로더에 의해 로드된 클래스, 메서드 정보, 정적 변수 정보들이 저장되는 공간이다. 많은 클래스 로드와 많은 정적 데이터를 남발하면 메모리 공간이 부족할 수 있지만, Java8 이후 네이티브 메모리로 공간을 분리하면서 개선하였다. 

 

개선점을 3.2.3에서 설명했지만, 부가설명을 해보고자 한다. PermGen 영역이 사라지면서 크게 변경된 부분이 있다. 클래스 메타데이터는 네이티브 메모리에 할당되고, 인터닝된 문자열(문자열 상수 풀에 있는 문자열)이나 클래스 변수가 heap영역으로 옮겨졌다. PermGen영역을 사용하면서는 모든 static 레퍼런스들이 해당 영역에 쌓였다면, heap영역으로 완전히 분리한 것을 의미한다.

또한, static 이나 string constant pool이 heap영역으로 옮겨졌으니 static 또한 GC의 대상이 되어 관리될 수 있다는 것을 의미한다. 

 

 

 

3.2.5.  Runtime Constant Pool

런타임 상수 풀은 기본적으로 메서드 영역(3.2.4) 내부에 일부로 존재한다. 이 런타임 상수 풀에는 다양한 타입의 리터럴, 상수들 뿐만 아니라 각 클래스, 인터페이스, 필드, 메서드에 대한 심볼릭 참조도 저장된다. 앞서 메서드 영역(3.2.4)에서 설명한 것처럼 string constant pool이 java7 이후부터는 heap영역으로 옮겨졌지만, 문자열 리터럴에 대한 레퍼런스는 Metaspace에서 관리된다는 의미이기도 한다.

 

이 런타임 상수 풀의 역할을 이해하기 위해서는 자바에서의 동적 로드에 대해 이해해야 한다. 자바에서 클래스와 인터페이스는 처음으로 사용될 때, 즉 처음으로 클래스가 참조되는 시점에 클래스 로더에 의해 동적으로 로드되는 특성을 가진다. 앞서 클래스 로더(3.1)의 동적 로드 과정을 설명한 내용을 구체화 해보자.

 

첫번째 단계(3.1.1)인 Loading 단계에서는 JVM이 메모리에 로드된 클래스 파일을 분석하는데, 이 과정에서 클래스나 인터페이스의 상수뿐만 아니라 메서드와 필드에 대한 심볼릭 레퍼런스를 런타임 상수 풀로 복사한다.

 

두번째 단계(3.1.2)인 Linking 단계에서는 Loading에서 복사한 런타임 상수 풀 내부의 심볼릭 레퍼런스를 실제 메모리 주소인 다이렉트 레퍼런스로 변경한다.

 

이런 식으로 동적 로드 과정에서 런타임 상수 풀을 운용하면서 레퍼런스들을 관리한다. 즉, 특정 메서드나 필드에 대한 레퍼런스 값들을 가지고 있으므로, JVM은 필요 시에 런타임 상수 풀을 통해 실제 값들을 참조하게 된다.

 

 

 

3.2.6.  Native Method Stacks

Java 언어가 아닌 네이티브 코드 실행을 지원하기 위한 스택 메모리 영역이다. JVM 스택과 동일하게 일반적으로 스레드별로 독립적인 네이티브 메서드 스택을 가진다. JVM에서 주로 자바 바이트코드를 실행하지만, 시스템콜이나 하드웨어 수준 작업을 수행하기 위해 네이티브 코드의 실행이 필요하다. 이 스택은 결국 JNI(Java Native Interface)를 통해 호출된 네이티브 메서드의 실행을 지원하는데 사용된다.

 

JNI는 Java 애플리케이션에서 네이티브 메서드를 호출할 때 네이티브 코드를 실행하기 위한 인터페이스이며, 코드 매핑과 독립적인 메모리 관리를 담당한다. 이 JNI를 통해 수행되는 코드를 위한 스택 영역이 바로 네이티브 메서드 스택인 것이다.

 

 

 

 

3.3 실행 엔진(Execution Engine)

실행 엔진은 Java 바이트 코드를 실제로 실행하는 역할을 한다. 실행 엔진은 클래스 로더에 의해 런타임 데이터 영역에 위치한 클래스 파일들로부터 바이트 코드를 받아 기계어로 변환하여 명령어 단위로 실행한다. 실행 엔진이 바이트 코드를 기계어로 변환하고 실행하는 방식에는 크게 두 가지가 있다.

 

 

 

3.3.1. 실행 엔진의 실행방식

  1. 인터프리터(Interpreter): 인터프리터는 바이트 코드 명령어를 하나씩 읽어서 바로 실행한다. 명령어를 읽자마자 즉각적으로 실행할 수 있지만, 계속 읽기와 실행을 반복하며 같은 코드를 반복 실행하더라도 매번 다시 해석해야 하므로 실행 속도가 느리다.

  2. JIT 컴파일러(Just-In-Time Compiler): JIT 컴파일러는 인터프리터의 단점을 극복하기 위해 도입되었다. 프로그램 실행 중에 바이트코드 전체 혹은 일부를 기계어로 변환한다. 이 변환된 작업은 캐시에 저장되어 동일한 코드가 반복 실행되면 더 빠르게 실행될 수 있다. 이 JIT 컴파일러 방식은 전체적인 프로그램 실행속도를 향상시키지만, 컴파일 과정에서 지연시간이 발생할 수 있다.

 

위와 같이 인터프리터와 JIT 컴파일러는 서로 다른 특성을 가지기에 아래와 같이 동작할 수 있을 것이다.

  • 인터프리터: 프로그램의 실행을 시작할 때, JVM은 바이트코드를 한 줄씩 인터프리팅하여 실행한다.

  • JIT 컴파일러: 프로그램 실행 중에, JVM은 실행 빈도가 높은 코드를 식별한다. JIT 컴파일러는 이러한 코드를 기계어로 컴파일하고, 메모리에 캐시되어 같은 코드가 다시 실행될 때 더 빠르게 실행된다.

 

 

 

3.3.2 실행 엔진을 통한 바이트코드의 실행방식

지금까지 정리한 내용을 바탕으로 바이트코드가 로드된 시점부터 실제로 실행되는 개략적으로 정리해보면 아래와 같다.

  • 바이트코드 로딩: 클래스 로더에 의해 JVM의 메서드 영역에 로드된 클래스의 바이트코드를 실행 엔진이 가져온다.
  • 바이트코드 실행:
    • 인터프리팅: 초기 실행이나 덜 자주 사용되는 코드는 인터프리터에 의해 해석되고 실행된다.
    • 컴파일링: 자주 사용되는 코드는 JIT 컴파일러에 의해 기계어로 컴파일되고, 이후 실행 시에는 더 빠르게 처리된다.

 

 

 

[참고 자료]

 

 

 

'JAVA' 카테고리의 다른 글

4. Collections.sort()와 Arrays.sort()  (0) 2023.01.17
3. 예외와 예외처리  (0) 2022.12.15
1. 자바 컴파일 과정  (0) 2022.11.23
22. 인터페이스  (0) 2022.11.14
21. 추상 클래스, 추상 메서드  (0) 2022.11.11