[JAVA] JVM 이란 무엇인가? JVM 의 역할과 구조, 동작 방식까지.

JVM 은 Java Virtual Machine 의 약자로, 말 그대로 자바를 실행시키는 가상 머신이다.

자바 바이트코드(.class 파일)는 JRE(Java Runtime Enviromnment) 위에서 동작한다.
JRE는 자바 API와 JVM으로 구성되며, 이 JRE에서 가장 중요한 요소는 자바 바이트코드를 해석하고 실행하는 JVM이다.


JVM을 학습해야 하는 이유는 무엇일까?

코드에 문제가 없고 프로그램이 실행만 잘 되면 JVM은 신경쓰지 않아도 상관없지 않을까? 결론부터 말하자면 ‘그렇지 않다’ 이다.

JVM이 프로그램에 필요한 메모리를 관리해주기 때문에 JVM에 대한 이해 없이 코드를 작성하게 되면, 메모리의 누수가 발생해 OutOfMemoryException이 발생할 수 있다. 이는 프로그램 전체의 심각한 성능 저하로 이어질 수 있으며, 디버깅을 하기 힘들어질 수 있다.

그렇기 때문에 JVM을 제대로 이해하고 올바르게 사용하는 것이 매우 중요하다.


JVM 의 역할

JVM의 역할은 자바 애플리케이션을 클래스 로더(Class Loader)를 통해 읽어 들여 자바 API와 함께 실행시키는 것이다. JVM은 Java와 OS 사이에서 중계자 역할을 수행하며, Java가 OS에 독립적으로 실행 및 재사용을 가능하게 해준다.

Java Compiler는 .java 파일을 .class 라는 자바 바이트코드로 변환시켜주는데 바이트코드는 기계어(Native Code)가 아니므로 OS에서 바로 실행되지 않는다. 이 때, JVM은 OS가 바이트코드를 이해할 수 있도록 해석해주는 역할을 담당한다.

또, JVM은 메모리 관리도 담당한다. 이를 ‘Garbage Collection’ 라고 하는데, 힙 영역의 객체들을 관리하는 것을 말한다.


JVM의 구조

아래와 같이 Class Loader(클래스 로더)가 컴파일 된 자바 바이트코드를 Runtime Data Area(런타임 데이터 영역)에 로드하고, Excution Engine(실행 엔진)이 자바 바이트 코드를 실행한다.

JVM Architecture

Class Loader

JVM 내로 클래스들을 Load하고, Link를 통해 작업을 수행하는 모듈로써, 런타임 시 클래스 파일을 Runtime Data Area로 로드하는 작업을 한다.

Class Loader는 Loading → Linking → Initialization 순으로 진행된다. Loading은 클래스 파일을 탑재하는 과정, Linking은 클래스 파일을 사용하기 위해 검증하고, 기본 값으로 초기화하는 과정이다. Initialization은 static field의 값들을 정의한 값으로 초기화하는 과정이다.

Class Loader

Loading

Class Loader가 필요한 클래스 파일들을 찾아 탑재하게 된다. 각각의 클래스 파일들이 기본으로 제공받는 클래스 파일인지 혹은 개발자가 정의한 클래스 파일인지와 같은 기준에 의해 Class Loader의 수준도 세 가지로 나뉘게 된다.

  • Bootstrap Class Loader
    Bootstrap Class Loader 는 다른 모든 Class Loader 의 부모가 되는 Class Loader 이다. rt.jar를 포함하여 JVM을 구동시키기 위한 가장 필수적인 라이브러리의 클래스들을 JVM에 탑재하게 된다. 가장 상위의 Class Loader이므로 다른 Class Loader 와는 다르게 탑재되는 운영체제에 맞게 네이티브 코드로 쓰여있다. 이들은 $JAVAHOME/lib 에 존재하는 코어 자바 API를 제공하며 최상위 우선순위를 가진다.

  • Extension Class Loader
    Bootstrap Class Loader 다음으로 우선순위를 가지는 Class Loader 이다. localedata, zipfs 등 다른 표준 핵심 Java Class 의 라이브러리들을 JVM에 탑재하는 역할을 하고 있다. 이들은 $JAVAHOME/jre/lib/ext_ 에 있다.

  • Application Class Loader
    System Class Loader 라고도 한다. Classpath 에 있는 클래스들을 탑재한다. 개발자들이 자바 코드로 짠 클래스 파일들을 JVM에 탑재하는 역할을 하고 있다. 만약 개발자가 Class Loader를 구현하여 사용하게 되면, 기본적으로 Application Class Loader 의 자식 형태로 사용자 정의된 Class Loader를 구성하게 된다.

우리가 작성하는 대부분의 코드는 Application Class Loader를 통해 읽히게 된다.

Class Loader가 읽는 순서는 최상위인 Bootstrap → Extension → Application 순으로 읽게 되는데, Application Class Loader 에서도 클래스를 읽지 못한다면, ClassNotFoundException 과 같은 예외가 발생하게 된다.

Linking

Linking은 로드된 클래스 파일들을 검증하고, 사용할 수 있게 준비하는 과정을 의미한다. Linking 또한 Verification, Preparation, Resolution 이라는 세 가지 단계로 이루어져 있다.

  • Verify
    클래스 파일이 유효한지를 확인하는 과정이다. 클래스 파일이 JVM의 구동 조건 대로 구현되지 않았을 경우에는 VerifyError 를 던지게 된다.

  • Prepare
    클래스 및 인터페이스에 필요한 static field 메모리를 할당하고, 이를 기본값으로 초기화 한다. 기본값으로 초기화된 static field 값들은 뒤의 Initialization 과정에서 코드에 작성한 초기값으로 변경된다. 이 때문에 JVM에 탑재된 클래스 파일의 코드를 작성시키지는 않는다.

  • Resolve
    Symbolic Reference 값을 JVM 의 메모리 구성 요소인 Method Area 의 런타임 환경 풀을 통하여 Direct Reference 라는 메모리 주소 값으로 바꿔준다. 해당 단계의 영향을 받는 JVM Instruction 요소는 new 및 instanceof 가 있다.

Initialization

Linking 과정을 거치면 Initialization 단계에서는 클래스 파일의 코드를 읽게 된다. Java 코드에서의 클래스와 인터페이스의 값들을 지정한 값으로 초기화 및 초기화 메소드를 실행시켜준다. 이 때, JVM 은 멀티 스레딩으로 작동하며, 같은 시간에 한 번에 초기화하는 경우가 있기 때문에 초기화 단계에서도 동시성을 고려해줘야 한다. Class Loader 를 통한 클래스 탑재 과정이 끝나면 본격적으로 JVM 에서 클래스 파일을 구동시킬 준비가 끝나게 된다.

Runtime Data Area

JVM이 프로그램을 수행하기 위해 운영체제로부터 할당받은 메모리 공간으로, Method Area, Heap, JVM Stack, PC Register, Native Method Stack으로 구분된다.

Method Area

모든 스레드가 공유하는 메모리의 영역으로 JVM이 시작될 때 생성된다. JVM이 읽어들인 각각의 클래스와 인터페이스에 대한 런타임 상수 풀, 필드와 메소드 코드, Static 변수, 메소드의 바이트코드 등을 보관한다.

Heap

동적으로 할당되는 데이터가 저장되는 영역이다. 예를 들어, 객체, 배열 등이 생성되었을 때 저장되는 공간이다. Heap에 할당된 데이터는 GC의 대상이 된다. JVM 성능 등의 이슈에서 가장 많이 언급되는 공간이다.

JVM Stack

JVM Stack은 Stack Frame이라는 구조체를 저장하는 스택으로, Thread의 수행 정보를 Frame 을 통해서 저장하게 된다. JVM Stack은 Thread가 시작될 때 생성되며, 각 Thread 별로 생성이 되기 때문에 다른 Threa는 접근할 수 없다.

Method가 호출되면 Method와 Method 정보는 Stack에 쌓이게 되며 Method 호출이 종료될 때 Stack Point에서 제거된다. Method 정보는 해당 Method의 매개 변수, 지역 변수, 임시 변수, 그리고 어드레스(메소드를 호출 한 주소) 등을 저장하고 Method 종료 시 메모리 공간이 사라진다.

PC Register

Thread가 시작될 때 생성되며, Thread가 어떤 명령어로 실행되어야 할 지에 대한 기록을 하는 부분으로 현재 수행중인 JVM 명령의 주소 값을 갖는다.

Native Method Stack

일반적으로 JVM은 네이티브 방식을 지원한다. 따라서 스레드에 네이티브 방식의 메소드가 실행되는 경우 Native Method Stack에 쌓인다. 일반적인 메소드를 실행하는 경우 JVM 스택에 쌓이다가 해당 메소드 내부에 네이티브 방식을 사용하는 메소드(예를 들면, C언어로 작성된 메소드)가 있다면 해당 메소드는 네이티브 스택에 쌓인다.

Execution Engine

Class Loader를 통해 JVM 내의 Runtime Data Area에 배치된 바이트코드를 명령어 단위로 읽어서 실행한다. 즉, 바이트코드를 기계어로 번역하여 실행한다.

Excution Engine에는 Java 클래스를 실행하기 위한 세 가지 주요 구성 요소가 포함되어 있는데, Interpreter와 JIT Compiler, Garbage Collection이 있다.

Interpreter

자바의 특징에 대한 대표적인 표현 중에 Write Once Run Anywhere이라는 문구가 있다. 자바가 플랫폼에 독립적이고, 이식성이 높은 언어인 이유는 인터프리터 덕분이다. 각 플랫폼에 맞는 인터프리터가 바이트 코드를 실행하기 때문에 Windows, Linux, Mac 어디에서든 실행될 수 있다. 인터프리터는 바이트 코드를 읽고(read), 운영체제가 실행할 수 있도록 기계어로 변경하는 역할을 수행한다.

JVM Interpreter는 런타임 중에 바이트 코드를 한 라인씩 읽고 실행하기 때문에, 속도 이슈가 동반된다. 바이트코드 역시 기계어로 변환되어야 하기 때문에 C, C++처럼 미리 컴파일을 통해 기계어로 변경되는 언어에 비해 속도가 느리다. 반복문 같은 경우 컴파일 언어와 다르게 인터프리터는 코드 각 줄을 매번 읽고 번역해야 한다.

JIT(Just In Time) Complier

인터프리터의 속도 문제를 해결하기 위해 디자인된 기능이다. 간단히 설명하자면, ‘자주 실행되는 바이트코드 영역을 런타임 중에 기계어로 컴파일하여 사용한다.’

적절한 시점에 바이트코드를 컴파일하여 각 OS에 맞는 Native Code로 변경하고, Native Code로 직접 실행하는 방식이다. 또한, JIT 컴파일러는 같은 코드를 매번 해석하지 않고, 실행할 때 컴파일을 하면서 해당 코드를 캐싱해버린다. 이후에는 바뀐 부분만 컴파일하고 나머지는 캐싱된 코드를 사용한다. (캐싱된 코드는 코드 캐시라는 곳에 저장된다.)

바이트 코드를 JIT 컴파일러를 이용해 프로그램을 실제 실행하는 시점(바이트 코드를 실행하는 시점)에 각 OS에 맞는 Native 코드로 변환하여 실행 속도를 개선했다. 하지만, 바이트 코드를 Native Code로 변환하는 데에도 비용이 소요되기 때문에 반복 수행이 발생하지 않으면 Interpreter보다 느리다.

그렇기 때문에, JVM은 모든 코드를 JIT Compiler 방식으로 실행하지 않고, Interpreter 방식을 사용하다 *일정 기준이 넘어가면 JIT Complier 방식을 사용하다가 일정 기준이 넘어가면 JIT Compiler 방식으로 명령어를 실행한다.

JIT Compiler 에서 적절한 시점이란?

여기서 적절한 시점이란 자주 호출되는 코드임을 확인되는 때를 말한다. Execution Engine은 JVM 옵션을 통해서 튜닝이 가능하며, 컴파일 임계치에서 임의의 값을 지정하게 되면 코드 호출 빈도가 해당 임계치에 도달하면 바이트코드를 컴파일하는 방식으로 많이 사용된다는 기준을 설정할 수 있다.

컴파일 임계치 = method entry counter(호출 빈도) + back-edge loop counter(루프 횟수)

Garbage Collection

메모리를 자동으로 관리하는 자바 프로그램이다. 이것은 항상 백그라운드에서 실행되는 Demon Thread으로, 더 이상 참조되지 않는 객체를 파괴하여 힙 메모리를 해제한다. GC가 수행되는 동안 GC를 실행하는 Threa 외의 모든 Thread가 일시정지된다.


JVM 동작 방식

How JVM Work

  1. 프로그램이 실행되면 JVM은 OS로부터 이 프로그램이 필요로 하는 메모리를 할당 받는다. JVM은 이 메모리를 용도에 따라 여러 영역으로 나누어 관리한다.
  2. 자바 컴파일러(javac)가 자바 소스코드(.java 파일)를 읽어들여 자바 바이트코드(.class 파일)로 컴파일한다.
  3. Class Loader가 동적 로딩(Dynamic Loading)을 통해 클래스 파일들을 로딩 및 링크하여 Runtime Data Area, JVM의 메모리에 올린다.
  4. Execution Engine이 메모리에 올라간 class 들을 기계어로 번역하여 명령어 단위로 실행한다.
  5. 해석된 바이트코드는 Runtime Data Area에 배치되어 실질적인 수행이 이뤄지게 된다.
  6. 실행과정 속에서 JVM은 필요에 따라 Thread Synchronization과 GC 같은 관리 작업을 수행한다.

Thread Synchronization란?

Thread 는 한 번에 여러 작업을 동시에 진행하는 것을 의미한다. 프로그램 내에서 두 개 이상의 Thread를 시작할 때, 여러 스레드가 동일한 리소스에 액세스하려고 시도하고 마지막으로 동시성 문제로 인해 예기치 않은 결과가 발생할 수 있다. 예를 들어, 여러 스레드가 동일한 파일 내에서 쓰려고 하면 스레드 중 하나가 데이터를 덮어쓸 수 있거나, 한 스레드가 동시에 같은 파일을 여는 동안 다른 스레드가 같은 파일을 닫을 수 있기 때문에 데이터 손상이 일어날 수 있다. 이러한 문제점을 해결하기 위해 공유 데이터에 대하여 스레드들의 동시 접근을 방지하는 Thread Synchronization을 진행한다.

여러 스레드의 작업을 동기화하고 주어진 시점에서 하나의 스레드만 리소스에 액세스할 수 있는지 확인해야 한다. 이 부분에서 monitors라는 개념을 사용하여 구현된다. Java의 각 객체는 스레드가 잠기거나 잠금을 해제할 수 있는 monitor와 연결된다. 이후 한 번에 하나의 스레드만 monitor에서 잠금을 유지할 수 있다.


Referenece

태그:

카테고리: ,

업데이트:

댓글남기기