JVM 그리고 Java Code 실행 과정

이서우
이서우의 블로그
15 min readJan 28, 2021
Photo by Mike Kenneally on Unsplash
  • JVM
  • 컴파일하는 방법
  • 실행하는 방법
  • 바이트코드
  • JIT 컴파일러
  • JVM 구성 요소
  • JDK와 JRE의 차이

JVM

자바 소스 코드를 컴파일하면 바이트코드로 되어있는 클래스 파일이 생성되는데 이 클래스 파일을 해석해서 해당 운영체제와 하드웨어에 적합한 기계어로 변환하여 실행하는 소프트웨어가 JVM(Java Virtual Machine)입니다.

컴파일을 해서 운영체제가 프로그램을 바로 실행시키게 하지 않고 JVM을 중간 단계에 둔 이유는 프로그램의 이식성을 높이기 위함입니다.

운영체제는 서로 다른 기능을 제공하기 때문에 프로그램을 실행하고 관리하는 방법이 다릅니다. 그래서 해당 운영체제에 맞게 프로그램을 개발해야 합니다. 예를 들어 윈도우즈에 최적화되어 있는 프로그램을 리눅스에서 실행하려면 프로그램 소스 파일을 수정하고 다시 컴파일해야 합니다. 이렇게 운영체제별로 개발하는 것보다 여러 운영체제에서 동일한 실행 결과가 나오도록 설계된 JVM이 있으면 운영체제에 독립적으로 프로그램을 실행할 수 있으므로 이식성을 높일 수 있습니다.

JVM은 여러 운영체제에서 사용할 수 있으며 해당 운영체제에 맞는 JVM을 설치하면 됩니다. 그러면 한 운영체제에서 컴파일된 바이트코드를 다른 운영체제의 JVM이 실행할 수 있습니다.

또한 Java 언어뿐만 아니라 JVM이 인식할 수 있는 바이트코드로 컴파일이 가능한 모든 언어는 JVM 기반으로 동작시킬 수 있기 때문에 운영체제에 독립적이라는 공통된 특징을 가집니다.

컴파일하는 방법

명령 프롬프트에서 자바 소스 코드를 컴파일하는 방법은 다음과 같습니다.

notepad를 실행시켜서 Hello.java 파일을 생성합니다. notepad는 메모장의 실행 파일 이름입니다.

C:\HelloJava>notepad Hello.java

Hello World!를 출력하는 자바 소스 코드를 작성하고 저장합니다.

public class Hello{
public static void main(String[] args){
System.out.println("Hello World!");
}
}

javac 명령어로 컴파일을 합니다. (javac.exe는 JDK에 포함되어 있습니다.)

C:\HelloJava>javac Hello.java

하위 버전 JDK로 컴파일한 클래스 파일은 상위 버전 JDK로 실행 가능하지만 상위 버전으로 컴파일한 클래스 파일을 하위 버전으로 실행하려면 -source와 -target 옵션으로 하위 버전 JDK를 설정해서 컴파일해야 합니다.

컴파일이 성공적으로 완료되면 바이트코드를 포함하고 있는 클래스 파일이 생성됩니다. dir 명령어로 디렉터리 목록을 출력해서 확인할 수 있습니다.

C:\HelloJava>dir
C 드라이브의 볼륨에는 이름이 없습니다.
볼륨 일련 번호: C014-C26A
C:\HelloJava 디렉터리2021-01-22 오전 01:57 <DIR> .
2021-01-22 오전 01:57 <DIR> ..
2021-01-22 오전 01:53 416 Hello.class
2021-01-22 오전 01:53 109 Hello.java
2개 파일 525 바이트
2개 디렉터리 151,065,219,072 바이트 남음

실행하는 방법

java 명령어로 JVM을 구동시켜서 클래스 파일을 실행할 수 있습니다.(java.exe는 JDK와 JRE에 포함되어 있습니다.) JVM은 실행 진입점인 main() 메서드를 찾아서 순서에 맞게 실행합니다.

C:\HelloJava>java Hello
Hello World!

바이트코드

바이트코드는 Opcode(operation code)와 Operand로 구성된 명령어 집합입니다. 자바 소스 코드를 컴파일하면 생성되는 클래스 파일이 바이트코드로 되어있습니다.

하나의 Opcode가 1 byte로 표현되기 때문에 바이트코드라고 합니다. 그렇기 때문에 하나의 Opcode는 0~255까지의 범위에서 하나의 값을 가질 수 있습니다.(1 byte=8 bits, 2⁸=256)

javap 명령어와 -c 옵션으로 클래스 파일을 역어셈블해서 문자로 표현된 Opcode를 확인할 수 있습니다.

C:\HelloJava>javap -c Hello.class
Compiled from "Hello.java"
public class Hello {
public Hello();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
public static void main(java.lang.String[]);
Code:
0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #3 // String Hello World!
5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
}

aload_0, invokespecial, return, getstatic, ldc, invokevirtual가 1 byte로 표현된 Opcode를 문자로 표현한 명령어들입니다.(aload_0=0x2a=0010 1010)

이러한 명령어들의 집합인 바이트코드를 JVM이 운영체제에 맞는 기계어로 변환해서 실행합니다.

JIT 컴파일러

JIT(Just in Time) 컴파일러는 프로그램 실행에 걸리는 시간을 단축합니다. javac 명령어로 컴파일하는 것과 무관하며 java 명령어를 사용해서 JVM이 프로그램을 실행할 때 관련이 있습니다. JVM이 구동될 때 인터프리터와 JIT 컴파일러가 동시에 동작합니다. JIT 컴파일러는 기본적으로 활성화되어 있습니다.

JVM이 구동되면 인터프리터가 바이트코드를 한 줄씩 해석하고 네이티브 코드로 변환해서 실행합니다.(네이티브 코드는 특정 환경에서 실행되도록 한 기계어입니다. 인터프리터와 JIT 컴파일러는 네이티브 코드의 생성 방법, 최적화 방법 및 최적화 비용에 차이가 있습니다.) 만약 바이트코드에서 반복되는 부분이 있으면 JIT 컴파일러가 해당 부분을 컴파일해서 네이티브 코드를 캐시에 보관하고 재사용합니다. 캐싱 해둔 부분이 호출되면 다시 변환하는 작업 없이 보관된 네이티브 코드를 계속 사용할 수 있습니다. 이러한 과정은 런타임에 이루어집니다.

네이티브 코드를 실행하는 게 한 줄씩 인터프리팅하는 것보다 빠르지만 JIT 컴파일러가 컴파일하는 과정은 바이트코드를 한 줄씩 인터프리팅하는 것보다 많은 시간이 소요됩니다. 한 번만 실행되는 코드라면 컴파일보다 인터프리팅하는 게 효울적입니다. 따라서 JVM은 바이트코드에서 반복적으로 수행되는 부분을 확인하고 일정 정도를 넘을 경우 컴파일을 수행하도록 합니다.

JVM 구성 요소

JVM은 크게 클래스 로더, 메모리, 실행 엔진으로 구성됩니다. 클래스 로더는 클래스 파일의 바이트코드를 읽어서 메모리에 로드하고 실행 엔진이 바이트코드를 실행합니다.

클래스 로더

클래스는 한 번에 로드되지 않고 애플리케이션에서 필요로 할 때 클래스 로더가 동적으로 메모리에 로드합니다. 이 과정은 로딩, 링킹, 초기화 순서로 진행됩니다.

로딩은 클래스 파일에서 바이트코드를 읽어오는 과정입니다. 파일 내용에 맞는 적절한 바이너리 데이터를 생성하여 메모리의 메서드 영역에 클래스 정보를 저장합니다. 클래스 정보는 다음과 같습니다.

  • FQCN(Fully Qualified Class Name)
  • 클래스 파일이 클래스, 인터페이스, 이늄(Enum) 중 무엇과 관련 있는지
  • 포함된 메서드와 변수
  • 클래스를 읽고 있는 클래스 로더

클래스를 로딩하는 과정에 사용되는 클래스 로더는 기본적으로 부트스트랩 클래스 로더(Bootstrap Class Loader), 플랫폼 클래스 로더(Platform Class Loader), 애플리케이션 클래스 로더(Application Class Loader)가 있고 계층 구조를 이루고 있습니다.

JVM은 클래스의 로드 여부를 확인하고 메서드 영역에 없을 경우 클래스 로더 시스템에 특정 클래스를 로드하도록 요청합니다. 요청을 받은 애플리케이션 클래스 로더는 상위에 있는 플랫폼 클래스 로더에 요청을 위임합니다. 요청을 위임받은 플랫폼 클래스 로더는 최상위에 있는 부트스트랩 클래스 로더에 다시 요청을 위임합니다. 부트스트랩 클래스 로더까지 요청을 위임한 후 로딩 요청을 수행합니다. 클래스를 찾으면 로드하고 못 찾으면 하위 클래스 로더로 내려가며 과정을 수행합니다. 클래스의 유형과 클래스의 경로에 따라 특정 클래스를 로드하는 클래스 로더가 결정됩니다. 애플리케이션 클래스 로더에서도 클래스를 못 찾으면 ClassNotFoundException 또는 NoClassDefFoundError를 반환합니다. 로딩이 정상적으로 완료된 클래스는 해당 클래스 타입의 클래스 객체를 생성하여 메모리의 힙 영역에 저장합니다.

부트스트랩 클래스 로더는 가장 높은 우선순위가 부여되는 최상위 클래스 로더이고 다른 로더와 달리 C, C++와 같은 네이티브 언어로 구현되어 있습니다. 이 로더는 JAVA_HOME/lib/rt.jar에 있는 핵심 Java API를 로드합니다. 참고로 Java 9에서 rt.jar은 제거됐고 JAR 파일에 저장된 클래스 및 리소스 파일은 lib 디렉터리 하위에서 보다 효율적인 형식으로 저장되어 있습니다.

플랫폼 클래스 로더는 JAVA_HOME/lib/ext 디렉터리 또는 java.ext.dirs 시스템 속성에 지정된 디렉터리에서 클래스를 검색합니다. 플랫폼 클래스 로더는 Java 9부터 변경된 이름입니다. 이전에는 익스텐션 클래스 로더(Extension Class Loader)로 불렸습니다.

애플리케이션 클래스 로더는 java.class.path에 매핑된 환경 변수 또는 CLASSPATH 환경 변수의 값에 해당하는 경로에서 클래스를 검색합니다. 시스템 클래스 로더(System Class Loader)라고도 불립니다.

클래스가 로드되면 링킹을 시작합니다. 링킹은 검증(Verification), 준비(Preparation), 분석(Resolution) 순서로 진행됩니다.

검증 단계에서는 클래스가 Java Language Specification 및 JVM Specification을 준수하는지 확인합니다. 클래스 로더 시스템에서 가장 복잡하고 많은 시간이 소요되는 단계입니다. 확인에 실패하면 java.lang.VerifyError 런타임 예외가 발생합니다.

준비 단계에서는 정적 변수 등 클래스가 필요로 하는 메모리를 할당하고 메모리를 기본값으로 초기화합니다.

분석 단계에서는 심볼릭 레퍼런스를 메서드 영역에 들어있는 실제 객체를 다이렉트로 가리키도록 변경합니다. 이 단계는 순차적으로 진행할 수도 있고 나중에 해당 레퍼런스를 사용할 때 진행할 수도 있습니다.

초기화 과정은 링킹 과정의 준비 단계에서 마련된 메모리에 정적 변수를 초기화 및 할당합니다.

메모리

JVM 내부의 메모리 영역은 메서드, 힙, 스택, PC 레지스터, 네이티브 메서드 스택으로 나뉘어 있습니다.

메서드 영역에는 정적 변수를 포함한 클래스 레벨의 정보가 저장됩니다. 이 영역에 저장된 정보는 모든 스레드가 공유할 수 있습니다.

힙 영역에는 모든 객체의 정보가 저장됩니다. 모든 스레드가 공유할 수 있으며 가비지 컬렉션 대상입니다.

스택은 스레드마다 하나씩 생성되며 런타임 스택이라고 부릅니다. 메서드 콜에 대한 스택 프레임이 저장됩니다. 스택 프레임의 하위 항목에 지역 변수가 포함됩니다. 스레드가 종료되면 런타임 스택도 소멸됩니다. 스택, PC 레지스터, 네이티브 메서드 스택의 정보는 특정 스레드에 국한되어 공유됩니다. 모든 영역에서 공유하는 자원이 아닙니다.

PC 레지스터는 스레드마다 하나씩 생성되며 현재 실행 중인 JVM 명령의 메모리 주소가 저장됩니다.

네이티브 메서드 스택은 C, C++ 등 Java 외의 언어로 작성된 네이티브 코드를 위한 스택입니다.

실행 엔진

JVM에 할당된 모든 코드는 실행 엔진에 의해 실행됩니다. 실행 엔진은 한 줄씩 바이트코드를 읽고 메모리에 있는 정보를 사용하여 명령을 실행합니다. 실행 엔진은 인터프리터, JIT 컴파일러, GC로 구성됩니다.

인터프리터는 바이트코드를 한 줄씩 해석하고 네이티브 코드로 변환하여 실행합니다. 반복 호출되는 메서드를 매번 해석해야 하는 단점이 있습니다.

JIT 컴파일러는 인터프리터의 효율을 높이기 위해 사용됩니다. 반복 호출되는 메서드를 네이티브 코드로 변환해두고 재사용합니다.

가비지 컬렉션(Garbage Collection, GC)은 메모리 최적화를 위한 방법입니다. Java에서는 메모리 해제를 명시적으로 하지 않아도 됩니다. 가비지 컬렉터가 더 이상 참조되지 않는 객체를 수집해서 해당 객체를 위해 할당했던 메모리를 해제하여 다시 사용 가능한 자원으로 만듭니다. 가비지 컬렉터는 백그라운드에서 실행되는 데몬 스레드입니다.

자바 네이티브 인터페이스(Java Native Interface, JNI)는 네이티브 메서드 라이브러리(Native Method Library)와 상호작용하며 실행에 필요한 네이티브 라이브러리를 제공합니다. 네이티브 메서드는 Java가 아닌 C, C++ 등 다른 언어로 작성되었으며 네이티브 키워드가 붙어있습니다.

public static native Thread currentThread();

currentThread() 메서드는 C로 구현되어 있습니다.

네이티브 메서드 라이브러리는 네이티브 라이브러리들의 모음입니다. JNI를 통해서 사용할 수 있습니다.

JDK와 JRE의 차이

JDK(Java Development Kit)에 JRE(Java Runtime Environment)와 관련된 모든 것들이 포함되어 있고 JRE는 따로 패키징 된 것입니다. 이미 개발된 프로그램만 실행하는 게 목적이면 JRE만 설치하면 되고 프로그램 개발이 목적이면 JDK를 설치하면 됩니다.

Java 11부터는 JRE를 별도로 배포하지 않지만 벤더를 통해 JRE만 설치할 수 있고 Java 9부터 도입된 모듈 시스템을 사용해서 JRE를 구성할 수도 있습니다.

JRE = JVM +libraries to run application

JDK = JRE + tools to develop application

--

--