JVM Back to the Basics - JVM 구조

10 minute read

개요

Dynamic Instrumentation 코어 모듈을 개발하는 과정에서 WebGoat를 대상으로 instrumentation를 시도해보았다. Javassist 라이브러리를 통해 메소드를 transform하는 방식으로 진행하는데, Spring Boot에서 cglib로 동적으로 생성되는 바이트코드에 대한 classpath를 Javassist가 찾지 못하는 것 같았다 - NotFoundException 발생. 그렇게 원인을 찾던 중, JVM Class loading 메커니즘은 물론이고 Spring Boot가 돌아가는 방식에 대해 잘 모르고 있기 떄문에 디버깅이 오래 걸리고 있다. 따라서 이 “JVM Back to the Basics” 시리즈에서는 JVM의 구체적인 구조 및 프로세스에 대해 한 번 자세히 알아보려고 한다.

Virtual Machine (VM)이 무엇인가?

가상 머신은 말 그대로 물리적인 기계처럼 연산을 수행할 수 있는 기계를 simulate하는 소프트웨어이다.

크게 두 가지 종류의 VM이 있다.

  1. 하드웨어 기반 or 시스템 기반 VM
  2. 소프트웨어 기반 or 애플리케이션 기반 or 프로세스 기반 VM

하드웨어 기반 VM의 예시로,

  • KVM (Kernel-based virtual machine) for Linux systems
  • VMware
  • Xen
  • Cloud computing 등이 있고, 하드웨어 기반 VM의 주 장점은 하드웨어 자원을 효과적으로 활용할 수 있다는 점이다.

소프트웨어 기반 혹은 애플리케이션 기반 VM은 특정 프로그래밍 언어로 작성된 애플리케이션을 실행하기 위한 런타임 엔진 역할을 한다.

예시로,

  • JVM은 Java 애플리케이션을 위한 런타임 엔진 역할을 한다.
  • PVM은 PERL과 같은 스트립트 언어를 위한 런타임 엔진 역할을 한다.
  • CLR (Common Language RUNTIME)은 .NET 기반 애플리케이션을 실행하기 위한 런타임 엔진 역할을 한다.

그럼 JVM을 알아보자. 먼저,

  1. JVM은 JRE의 일부분이다.
  2. JVM은 Java 애플리케이션을 로딩하고 실행하는 것을 담당한다.

JVM 정의

Java Virtual Machine(JVM)이라는 용어는 자주 쓰인다. 먼저 우리가 JVM이라고 칭할 때, 다음 세 가지 중 하나를 의미한다는 것을 알고 넘어가야 한다.

  • 추상적인 규격/명세 (abstract specification)
  • 실질적인/구체적인 구현체 (concrete implementation)
  • 런타임 인스턴스 (runtime instance)

위 세 가지를 의미하기 위해 우리는 JVM을 자주 사용해왔다.

Java virtual machine은 말 그대로 “virtual”(가상의) 기계이다 - 왜냐하면 특정한 규격/명세에 의해 정의된 추상적인 컴퓨터이기 때문이다.

Java 프로그램을 실행하기 위해서는 이러한 추상적인 개념의 콘크리트한 구현체가 필요하다. 이러한 구현체들은 여러 가지의 플랫폼에 존재하고 여러 다른 벤더로부터 제공되고 100% 소프트웨어이거나 하드웨어와 소프트웨어의 조합의 형태를 가진다.

런타임 인스턴스는 한 개의 실행 중인 Java 애플리케이션을 호스팅한다.

요약하자면, 각 Java 애플리케이션은 JVM에 대한 추상적인 규격의 콘크리한 구현체의 런타임 인스턴스 안에서 실행된다.

The Lifetime of a Java Virtual Machine

JVM의 런타임 인스턴스는 명확한 life 미션을 갖고 있다 - 하나의 Java 애플리케이션을 실행하는 것

Java 애플리케이션이 시작되면 런타임 인스턴스가 생성되고, 애플리케이션이 종료되면 인스턴스도 죽는다. 만약 세 개의 Java 애플리케이션을 동일한 컴퓨터에서 동일한 구현체를 사용하여 동시에 실행시키면 세 개의 JVM 인스턴스가 생성되고, 각 Java 애플리케이션은 자신의 JVM에서 실행되게된다.

JVM 인스턴스는 특정 초기 클래스의 main 메소드를 호출함으로써 자신의 애플리케이션을 실행 시작시킨다. 이 때, main 메소드는 public 및 static해야하고 void를 리턴하고 한 개의 파라미터 (String 배열)를 받아야 한다. 이러한 main 메소드를 가진 클래스는 Java 애플리케이션의 시작점(starting point)이 될 수 있다.

예를 들어 자신의 command line arguments를 출력하는 애플리케이션을 보자.

// On CD-ROM in file jvm/ex1/Echo.java
class Echo {

    public static void main(String[] args) {
        int len = args.length;
        for (int i = 0; i < len; i++) {
            System.out.println(args[i] + " ");
        }
        System.out.println();
    }
}

우리는 특정한 구현체-의존적인(implementation-dependent) 방법으로 전체 애플리케이션을 시작하는 main 메소드를 포함하는 시작 클래스의 이름을 JVM에게 줘야한다.

JVM의 실제 구현체로 Sun의 Java 2 SDK의 java 프로그램이 있다. 위에 Echo 애플리케이션을 Sun의 java로 윈도우 OS에서 실행하고자 한다면 다음과 같은 커맨드를 입력할 수 있다.

java Echo Greetings, Planet.

여기서 “java”는 운영체제가 Sun의 Java 2 SDK로부터 JVM이 실행되어야 한다는 것을 의미하고, “Echo”는 시작 클래스의 이름, “Greetings, Planet.”은 애플리케이션의 command line arguments가 된다. Command line 인자들은 main 메소드로 커맨드 라인에 입력된 순서대로 전달된다.

애플리케이션의 시작 클래스의 main 메소드는 그 애플리케이션의 초기 스레드의 시작점이 된다. 추후에 초기 스레드는 다른 스레드들을 시작시킬 수 있다.

JVM 안에서 스레드는 두 가지 종류로 존재한다 - daemon 혹은 non-daemon. Daemon 스레드는 가상 머신 자체가 사용하는 일반적인 스레드로써 garbage collection을 수행하는 스레드 등을 말한다. 애플리케이션은 자기가 생성하는 스레드를 daemon 스레드로 지정할 수 있다. 애플리케이션의 초기 스레드 (main 함수에서 시작되는 스레드)는 non-daemon 스레드이다.

만약 Non-daemon 스레드가 실행 중이라면 Java 애플리케이션은 계속해서 실행된다. 즉, JVM 인스턴스는 계속해서 살아있는다. Java 애플리케이션의 모든 non-daemon 스레드가 종료되면 JVM 인스턴스는 exit한다. Security manager에 의해 허용된다면 애플리케이션은 Runtime이나 System 클래스의 exit 메소드를 호출하여 스스로 소멸할 수 있다.

위의 Echo 애플리케이션 예시에서 main 메소드는 다른 스레드를 호출하지 않는다. Command line argument들을 출력하고 main 메소드는 리턴한다. 이는 애플리케이션의 유일한 non-daemon 스레드를 종료시키고, 따라서 JVM 인스턴스가 exit하게된다.

JVM 아키텍쳐

Java virtual machine 명세서(specification)를 보면, 가상 머신 인스턴스의 행동은 서브시스템, 메모리 구역, 데이터 타입, 그리고 명령으로 설명되어있다. 이러한 구성 요소들은 추상적인 JVM의 추상적인 내부 아키텍쳐를 설명한다. 이러한 구성 요소들을 통해 JVM 구현체의 내부 아키텍쳐에 대한 무조건적인 규칙을 정하는 것보다는, 구현체의 외부 행동을 엄격하게 정의할 수 있는 방법을 제공하는 것에 그 목표가 있다. 명세서에 보면 JVM 구현체가 어떻게 행동해야하는지에 대한 것들이 이러한 추상적인 구성요소 및 그것들의 상호작용을 통해 정의되어 있다.

아래 그림은 명세서에 정의된 JVM 아키텍쳐가 포함하는 주요 서브시스템 및 메모리 구역을 나타낸다.

각 JVM은 class loader subsystem(클래스 로더 서브시스템)을 가지고 있다. 이는 FQN(Fully Qualified Name)이 주어졌을 때 타입(클래스 및 인터페이스)을 로딩하는 메커니즘을 수행한다.

또, 각 JVM은 execution engine(실행 엔진)을 갖고 있는데, 이는 로딩된 클래스에 있는 메소드 안에 있는 명령들을 실행하는 메커니즘을 수행한다.

jvm-subsystems

JVM이 프로그램을 실행할 때, 로딩된 클래스 파일로부터 추출하는 바이트코드 및 다른 정보, 프로그램이 인스턴스화하는 객체들, 메소드 파라미터, 리턴 값, 로컬 변수, 계산 중간값들과 같이 많은 것들을 저장하기 위한 메모리가 필요하다. JVM은 프로그램을 실행하기 위해 필요한 메모리를 여러 개의 runtime data area(런타임 데이터 구역)으로 조직화한다.

모든 JVM 구현체에 똑같은 런타임 데이터 구역이 특정한 형태로 존재하기는 하지만, 명세서가 꽤 추상적이기 때문에, 런타임 데이터 구역의 구조적인 디테일에 대한 많은 부분은 구현체의 디자이너에게 달려있다.

구현체에 따라 서로 매우 다른 메모리 제약사항을 갖고 있을 수 있다. 몇몇 구현체는 다른 구현체보다 일을 처리하기 위해 많은 메모리를 가지고 있을 수도 있고, 어떠한 구현체는 가상 메모리를 좀 더 많이 활용할 수도 있다. 런타임 데이터 구역의 명세의 추상적인 특징 덕분에 다양한 종류의 컴퓨터 및 디바이스에 JVM을 구현하기 쉬워진다.

몇몇 런타임 데이터 구역은 애플리케이션의 모든 스레드들에 의해 공유되고, 몇몇은 특정 스레드에 유니크한 데이터 구역이 될 수 있다. JVM의 각 인스턴스는 하나의 method area(메소드 구역)heap(힙)을 가진다. 이 구역들은 JVM에서 실행되는 모든 스레드들에 의해 공유된다. 가상머신이 클래스 파일을 로딩하면, 클래스 파일에 있는 바이너리 데이터로부터 타입에 대한 정보를 파싱한다. 그리고는 이 타입 정보를 메소드 구역에 올린다. 프로그램이 실행되면서 가상머신은 프로그램이 인스턴스화하는 모든 객체를 힙에 올린다. 다음 그림이 이 두 메모리 구역을 나타낸다.

memory-areas

새로운 스레드가 생길 때 마다, 그 스레드는 자신만의 pc register (program counter)Java stack을 얻는다. 스레드가 네이티브 메소드가 아닌 Java 메소드를 실행한다면, pc 레지스터의 값은 다음으로 실행할 명령을 가르킨다. 스레드의 Java 스택은, 해당 스레드의 Java 메소드(네이티브 메소드 X) 호출 상태를 저장한다. Java 메소드 호출의 상태는 그것의 로컬 변수, 호출될 때의 파라미터, (존재 한다면) 리턴 값, 중간 계산 값을 포함한다. 네이티브 메소드 호출 상태는 구현-의존적인 방식으로 native method stacks 에 저장된다. 레지스터나 다른 구현-의존적인 메모리 구역에 저장될 수도 있다.

Java 스택은 stack frame (or frame)이라는 것들로 이루어져 있다. 스택 프레임은 한 개의 Java 메소드 호출의 상태를 담고 있다. 스레드가 메소드를 호출하면, JVM은 새로운 프레임을 해당 스레드의 Java 스택에 넣는다. 메소드가 완료되면 JVM은 해당 메소드에 대한 프레임을 pop하여 제거한다.

JVM은 중간 데이터 값을 저장하기 위한 레지스터를 가지고 있지 않다. Instruction set(명령 집합)은 Java 스택을 중간 데이터 값 저장소로 사용한다. Java 설계자들은 JVM의 instruction set을 컴팩트하게 유지하고 범용 레지스터가 몇 개 없거나 불규칙적인 아키텍쳐에서의 JVM 구현을 가능케하기 위해서 이러한 방식을 선택하였다. 또, JVM의 instruction set의 스택-기반 아키텍쳐는 몇몇 가상머신 구현체에서 런타임시 동작하는 just-in-time 및 동적 컴파일러가 수행하는 코드 최적화를 가능하게 한다.

다음 그림을 통해 JVM이 각 스레드를 위해 생성하는 메모리 구역을 볼 수 있다. 이 메모리 구역들은 각 스레드가 고유하게 가진다. 한 스레드는 다른 스레드의 pc 레지스터나 Java 스택에 접근할 수 없다.

runtime-data-area-unique-to-thread

이 그림은 세 개의 스레드가 모두 실행 중인 JVM 인스턴스의 스냅샷이다. 이 스냅샷을 찍는 순간, 스레드 1, 2는 Java 메소드를 실행하고 있고, 스레드 3은 네이티브 메소드를 실행하고 있다.

NOTE: Java 메소드를 실행하고 있는 스레드에 대해서, pc 레지스터는 다음으로 실행할 명령을 표시한다 - 스레드 1, 2에 대한 pc 레지스터는 밝은 색의 회색으로 표시하였다. 그러나 스레드3는 네이티브 메소드를 실행하고 있기 때문에 pc 레지스터의 내용이 undefined이다. 따라서 색을 어두운 회색으로 표시하였다.

JVM 안에 있는 Memory Areas

Java 가상 머신이 프로그램을 실행할 때는 여러가지의 것들을 저장하기 위해 메모리가 필요하다. 예를 들어, 로딩된 클래스 파일들로부터 추출한 바이트 코드 및 그 외 정보, 프로그램에 의해 인스턴스화되는 객체들, 메소드 파라미터, 리턴 값, 지역 변수, 계산 중간 값 등의 여러 가지 데이터 말이다. JVM은 프로그램을 실행하기 위해 필요한 메모리를 여러개의 런타임 데이터 구역(area)으로 체계화한다.

JVM의 메모리 구역은 다음과 같다.

  1. Method Area
  2. Heap Area
  3. Stack Area
  4. PC Registers
  5. Native Method Stack

각각 살펴보자.

Method Area

  • 한 개의 JVM에는 한 개의 메소드 구역이 있다.
  • 메소드 구역은 JVM이 가동될 때 생성된다.
  • 메소드 구역 안에는, 정적 변수와 같은 클래스 레벨의 바이너리 데이터가 저장된다.
  • 클래스에 대한 constant pool들이 메소드 구역에 저장된다.
  • 메소드 구역은 여러 개의 스레드에 의해 동시에 접근될 수 있다.
  • 메소드 구역의 크기는 고정될 필요가 없다. Java 애플리케이션이 실행하는 동안 가상 머신은 니즈에 따라 메소드 구역을 확장시키거나 축소시킬 수 있다.
  • 모든 스레드들은 동일한 메소드 구역을 공유한다. 따라서 메소드 구역의 데이터 구조에 접근하는 것을 thread-safe하게 설계해야 한다.

Heap Area

  • 한 개의 JVM에는 한 개의 힙 구역이 있다.
  • 힙 구역은 JVM이 가동될 때 생성된다.
  • 객체와 그것과 매핑되는 인스턴스 변수들이 힙 구역에 저장된다.
  • Java에서 배열은 객체이므로, 이 또한 힙 구역에 저장된다.
  • 힙 구역은 여러 개의 스레드에 의해 접근될 수 있으므로, thread-safe하지 않을 수 있다.

Heap Memory Statistics

Java 애플리케이션은 Runtime 클래스의 싱글톤 객체를 사용하여 JVM과 통신할 수 있다. getRuntime 메소드를 통해 Runtime 객체를 얻을 수 있다.

Runtime run = Runtime.getRunner();

runtime.maxMemory() // 힙에 할당 가능한 최대 메모리의 바이트 수를 리턴함
runtime.totalMemory() // 힙에 할당된 메모리의 총 바이트 수를 리턴함
runtime.freeMemory() // 힙에 남아있는 사용 가능한 바이트 수를 리턴함

위를 출력해보면, 아웃풋이 다음과 같이 나올 수 있다.

889192448 // Max
60283120  // Total
58719832  // Free

Set Maximum and Minimum heap size

힙 메모리는 우리의 요구사항에 따라 한정된 메모리로 늘리거나 줄일 수 있다.

Max heap size를 설정하기 위해 다음 커맨드를 사용한다.

java -Xmx512m HeapSpaceDemo

여기서, mx는 maximum size, 512m는 512 MB, HeapSpaceDemo는 Java 클래스 이름이다.

java -Xms65m HeapSpaceDemo

minimum heap size를 설정할 때는 ms(minimum size)로 명시한다.

혹은, 다음과 같이 한 번에 할 수도 있다.

java -Xms256m -Xmx1024m HeapSpaceDemo

Stack Memory

각 스레드가 생성될 시 JVM은 런타임 스택을 생성한다. 스레드에 의한 모든 메소드 호출과 그에 해당하는 로컬 변수가 스택에 저장된다.

메소드 호출이 있을 때 마다, 새로운 엔트리가 스택에 추가되며, 이러한 엔트리를 스택 프레임(stack frame) 혹은 activation record라고 부른다.

호출한 메소드가 종료되면, 그에 해당하는 엔트리는 스택에서 제거된다. 모든 메소드가 촐요되어 스택이 비게되면, JVM은 스레드를 종료시키기 전에 스택을 소멸시킨다.

NOTE: 스택에 저장되는 데이터는, 그것을 가지는 스레드에게 private하다.

스택 프레임에 대해 좀 더 자세히 들여보자.

스택 프레임은 3가지 부분으로 구성되어 있다.

  1. 로컬 변수 배열
  2. 피연산자 스택
  3. 프레임 데이터

로컬 변수 배열

  • 메소드의 모든 매개변수와 로컬 변수를 담고 있다.
  • 배열의 각 슬롯은 4 바이트이다.
  • int, float, 그리고 참조 변수는 한 개의 슬롯을 차지한다.
  • long, double은 두 개의 연결된 슬롯을 차지한다.
  • byte, short, char은 저장되기 전 int로 변환되어 한 개의 슬롯에 저장된다.
  • boolean 타입 저장 방법은 JVM마다 다르지만, 대부분의 JVM은 한 개의 슬롯에 저장한다.

피연산자 스택

  • JVM은 피연산자 스택을 하나의 작업 공간으로 사용한다.
  • 명령에 따라 값들이 피연산자 스택으로 올라갈 수도 있고, 연산이 수행될 수 있고, 결과 값이 저장될 수도 있다.
  • 피연선자 스택 또한 스택이므로 LIFO 방법론을 따른다.
  • 예를 들어, iadd 명령은 피연산자 스택에서 두 개의 int를 pop하여 서로 더한 후, int 결과 값을 push한다. JVM이 두 개의 int형 로컬 변수를 더한 뒤 다른 새로운 로컬 변수에 결과 값을 저장하는 예시는 다음과 같다.
iload_0    // push the int in local variable 0
iload_1    // push the int in local variable 1
iadd       // pop two ints, add them, push result
istore_2   // pop int, store into local variable 2

operand-stack-process

프레임 데이터

로컬 변수 배열과 피연산자 스택 외에도 Java 스택 프레임에는 constant pool resolution, 메소드와 관련된 모든 symbolic reference, normal method return 및 exception dispatch를 지원하기 위한 데이터를 포함하고 있다.

이러한 데이터는 스택 프레임의 프레임 데이터에 저장된다. 프레임 데이터는 exception 테이블에 대한 참조값을 갖고 있는데, 여기서 exception 테이블은 이에 해당하는 예외 발생 시의 catch 블록 정보를 담고 있다. 메소드가 예외를 발생시키면 JVM은 프레임 데이터가 참조하는 exception 테이블을 사용하여 어떻게 예외를 처리할지를 결정하게 되는 것이다.

JVM이 constant pool에 있는 엔트리를 필요로하는 명령을 만나게 되면, 프레임 데이터가 가진 constant pool에 대한 포인터를 통해 해당 정보에 접근한다.

PC Registers (Program Counter Registers)

각 스레드가 생성될 때, 스레드 마다 별도의 PC 레지스터가 생성된다. PC 레지스터는 현재 실행 중인 명령의 주소를 가지고 있다. 현재 명령이 완료되면, PC 레지스터는 자동으로 바로 다음 명령의 주소를 갖게된다. 여기서 말하는 주소(address)는 네이티브 포인터나 메소드의 바이트 코드의 시작점으로부터의 offset이 될 수 있다.

Native Method Stacks

각 스레드 마다 각자의 런타임 스택이 생성된다. 여기에는 애플리케이션에 있는 모든 네이티브 메소드들이 포함된다. 네이티브 메소드라함은 Java 언어 외의 언어로 작성된 메소드를 말한다. 달리 말해, JNI (Java Native Interface)를 통해 호출되는 C/C++ 코드를 실행하기 위해 사용되는 스택이다. 언어에 따라 C, 혹은 C++ 스택이 생성된다.

스레드가 Java 메소드를 호출하면, JVM은 새로운 프레임을 만들어 Java 스택으로 push한다. 그러나 스레드가 네이티브 메소드를 호출하면, 잠시 Java 스택을 뒤로한다. 새로운 프레임을 Java 스택에 올리지 않고, JVM은 단순히 동적으로 네이티브 메소드를 링킹하여 바로 이를 호출한다.

TBD…

출처

Artima, Bill Venners, https://www.artima.com/

Leave a comment