본문 바로가기
Java관련/Java

JVM과 자바 실행 과정 알아보기

by devstep 2022. 8. 7.

자바 소스 파일(.java)을 실행하는 과정을 살펴보면서 JVM의 각 항목에 대해서 알아보았습니다.
JVM에 대해 조사할 때 소제목으로 알아보는 것보다는 자바 실행 과정이라는 키워드로 접근하는 것이 수월했습니다.

먼저 java 파일의 실행과정을 간단하게 살펴보고 JVM의 각 항목에 대해 적어보겠습니다.

.java 파일의 실행과정

(image : simplesnippets.tech)

1 컴파일 타임

🔷 1단계 : 코드 컴파일

javac abc.java

자바 컴파일러 javac를 통해 .java 파일이 바이트코드로 된 .class파일로 변환됩니다.
자바 컴파일러는 구문오류, 컴파일시간 오류를 확인하고 자바코드를 바이트코드로 변환합니다. 자바코드를 바이트코드로 변환할 때 발생한 에러를 컴파일 타임 에러라고 합니다.

바이트 코드는 JVM 에서만 사용할 수 있는 중간 코드이고, 하드웨어/OS계층에서 사용하기 위해서는 JVM에서 기계어로 해석하는 과정이 한번 더 필요합니다.
중간코드인 바이트코드는 OS플랫폼에 독립적인 파일로써 JVM이 구동된 환경에서는 OS 상관없이 같은 class파일을 실행할 수 있습니다.

2 런타임

런타임은 JVM의 구동절차 로 간략하게 아래와 같은 단계로 구동됩니다.

🔷 2단계: 클래스로더가 바이트코드를 JVM으로 로딩

  • java abc
  • 런타임 단계의 시작으로 클래스로더가 바이트코드를 JVM으로 로딩합니다.

🔷 3단계: 클래스로더가 바이트코드의 무결성 검사. 링킹Linking

바이트코드 Verifier가 바이트코드의 무결성을 검사하고 통과되면 인터프리터에 전달합니다.

🔷 4단계: 인터프리터가 바이트코드를 기계 코드로 변환

바이트코드 각 행을 라인별로 기계코드로 변환하고 실행을 위해 OS/하드웨어로 전달합니다.


JVM

JVM은 일반적으로 정의된 스펙이 있고 구현은 벤더마다 조금씩 다릅니다.
여기서 살펴볼 것은 일반적으로 적용될 수 있는 JVM 스펙에 정의된 JVM아키텍쳐에 대해 알아보겠습니다.

1. 클래스로더

애플리케이션을 실행하는 동안 클래스로더 하위 시스템을 사용하여 클래스 파일을 RAM으로 가져옵니다.

1-1 로딩

클래스 파일을 메모리에 로딩하는 역할을 합니다. 일반적으로 메인클래스(메인 메서드가 있는 클래스)를 로딩하는 것부터 시작합니다. 실행 중인 클래스의 클래스 참조에 따라 다음 클래스가 로딩 됩니다.

  • 바이트코드가 클래스에 대한 정적 참조를 만들 때 (예: System.out)
  • 바이트코드가 클래스 객체를 생성할 때 (예: Car car = new Car())

클래스가 로딩 요청이 있을 때 클래스를 찾지 못해 로드하는데 실패하면 java.lang.ClassNotFoundException 런타임 에러가 발생합니다.

클래스 로더가 class 파일을 로딩하는 과정을 자세히 살펴보겠습니다.

  • 부트스트랩 클래스 로더 : 부트스트랩 경로 — $JAVA_HOME/jre/lib 디렉토리(예: java.lang.* 패키지 클래스)에 있는 핵심 Java API 클래스와 같은 rt.jar에서 표준 JDK 클래스를 로드합니다. C/C++와 같은 네이티브 언어로 구현되며 Java의 모든 클래스 로더의 부모 역할을 합니다.
    • rt.jar는 java9까지 제공되며 그 이후 버젼에는 jrt-fs.jar 로 변경되었다고 합니다. rt.jar는 압축을 해제하면 우리가 기본적으로 사용하는 클래스들이 직관적으로 보이나 jrt-fs.jar는 추상화 되어 있는 것 같습니다.
  • 확장 클래스 로더 : 클래스 로딩 요청을 부모인 부트스트랩에 위임하고 실패할 경우 확장 경로(예: 보안 확장 기능)의 확장 경로($JAVA_HOME/jre/lib/ext 또는 java.ext에 의해 지정된 기타 디렉토리)에서 클래스를 로드합니다.
  • 시스템/응용 프로그램 클래스 로더 : -cp 또는 -classpath 명령줄 옵션을 사용하여 프로그램을 호출하는 동안 설정할 수 있는 시스템 클래스 경로에서 응용 프로그램 특정 클래스를 로드합니다. 내부적으로 java.class.path에 매핑된 환경 변수를 사용합니다.

1-2 링크(Link)

Linking은 Verification, Preparation, Resolution의 3단계로 이루어집니다.

  • Verification : Java 언어 사양에 따라 올바르게 작성되었는지, JVM 사양에 따라 유효한 컴파일러에서 생성되었는지 등 .class 파일의 정확성을 확인합니다.
  • Preparation : 정적 저장소 및 메서드 테이블과 같이 JVM에서 사용하는 모든 데이터 구조에 대해 메모리를 할당합니다.
  • Resolution : 메서드 영역을 검색해 참조된 엔티티를 찾습니다.

자바의 링크 과정은 전통적인 컴파일 언어와는 차이가 있는데요. C와 자바의 차이를 쉽게 설명해준 유튜브 영상입니다.

1-3 초기화(Initialization)

로딩된 클래스, 인터페이스의 초기화가 실행됩니다.(예: 일반적으로 클래스 생성자 호출)
정적변수가 코드에 정의 된 값으로 할당되고 static block이 있으면 실행합니다.

2. 런타임 데이터 영역(Runtime Data Area)

JVM이 OS에서 실행될 때 할당되는 메모리 영역입니다.
메모리 영역은 .class 파일을 읽는 것 뿐아니라 클래스로더 시스템이 생성하는 이진 데이터와 각 클래스의 메서드 영역에 다음 정보를 별도로 저장합니다.

  • 로딩된 클래스, 상위 클래스의 FQCN(Fully Qualified Class Name)
  • .class 파일이 Class/Interface/Enum과 관련 있는지 여부
  • 제어자(Modifiers), static variables(정적변수), 메서드 정보

로딩된 .class 파일을 힙 메모리에 표현하기 위해 java.lang 패키지에 정의된 대로 하나의 Class 객체를 생성합니다.
이 Class 객체는 나중에 코드에서 클래스 레벨 정보(클래스 이름, 부모 이름, 메서드, 변수 정보, 정적 변수 등)를 읽는 데 사용할 수 있습니다.

2-1 Method Area(스레드간 공유 영역)

모든 JVM 스레드는 동일한 메서드 영역을 공유합니다. 그러므로 메서드 데이터 접근과 동적 linking은 thread safe(스레드로부터 안전)해야 합니다.

메서드 영역은 아래와 같은 클래스 레벨 데이터(class level data)를 저장합니다.

  • 클래스로더 참조
  • Run time constant pool : 숫자 상수, 필드 참조, 메서드 참조, attribute, 메서드/필드에 대한 모든 참조. 메서드나 필드가 참조되면 JVM은 런타임 상수 풀을 사용해 메모리에서 메서드나 필드의 실제 주소를 검색합니다.
  • 필드 데이터 : 필드의 이름, 타입, 제어자(modifiers), attribute
  • 메서드 데이터 : 메서드의 이름, 리턴 타입, 파라미터 타입, 제어자, attribute

2-2 Heap Area(스레드간 공유 영역)

모든 객체 정보, 인스턴스 변수/배열이 저장됩니다.
GC의 대상이 되는 영역입니다.

2-3 Stack Area(스레드 당 독립 영역)

스레드가 시작되면 메서드 호출을 저장하기 위해 스레드 별도의 런타임 스택이 생성됩니다. 모든 메서드 호출에 대해 하나의 항목이 생성되어 런타임 스택의 맨 위에 push되며 이런 항목을 Stack Frame 이라고 합니다.

(image : http://www.tcpschool.com/c/c\_memory\_stackframe)

예외 발생시 stack trace의 각 행은 하나의 Stack Frame을 나타냅니다.

2-4 PC Registers(스레드 당 독립 영역)

PC 레지스터에는 현재 실행 중인 메서드가
네이티브 메서드가 아니면 현재 실행 중인 JVM 명령어의 위치가 저장되고,
네이티브 메서드이면 PC 레지스터에 저장되는 값은 정의되지 않습니다.(undefined).

2-5 Native Method Stacks(스레드 당 독립 영역)

자바 스레드와 OS 스레드간에는 직접 매핑이 있습니다. JNI(Java Native Interface)를 통해 호출되는 네이티브 메서드 정보를 저장하기 위해 별도의 네이티브 스택이 생성됩니다.

  • JNI는 Java와 이외의 언어로 만들어진 어플리케이션/라이브러리가 상호작용할 수 있도록 연결시켜주는 인터페이스를 제공합니다. 그리하여 JVM이 OS상의 입출력, 그래픽, 네트워킹, OS 스레드와 같은 기능들을 작동하기 위한 로컬 시스템 호출(local system calls)을 수행할 수 있도록 합니다.

3 실행 엔진(Excution Engine)

바이트코드의 실제 실행은 여기에서 이루어집니다. 실행엔진은 런타임 데이터 영역에 할당된 데이터를 읽어서 바이트코드의 명령을 한줄 씩 실행합니다.

3-1 인터프리터(Interpreter)

바이트코드를 해석하고 명령을 하나씩 실행합니다. 하나의 메서드를 호출할 때마다 매번 새롭게 해석하고 실행이 필요한 부분이 단점입니다.

3-2 JIT(Just-In-Time) 컴파일러

먼저 전체 바이트코드를 네이티브 코드로 컴파일합니다.
그런 다음 반복되는 메서드 호출의 경우 네티이브 코드를 직접 제공하여 명령을 하나씩 해석하지 않도록 합니다. 네이티브 코드는 캐시에 저장되므로 컴파일된 코드를 더 빠르게 실행할 수 있습니다.

  • 캐시에 저장된다는 의미가 이해되지 않는다. 네이티브 코드 저장 장소가 JVM 영역 내부 아닌가요?

⭐ JIT에서 컴파일한 네이티브 코드는 캐시에 저장된다란 의미
코드 캐시가 무엇이고 어떻게 동작하는지 살펴볼 수 있는 자료입니다.

간략하게 요약하면
JIT 컴파일러가 코드를 컴파일시 최적화하는 방식은 인라인 매커니즘, 루프 풀기등이 있습니다. 인라인 매커니즘, 루프 풀기는 포인터의 간접 참조를 감소하여 실행 태스크를 단축한다는 것을 알 수 있습니다.
최적화 된 코드가 어떻게 실행 태스크를 감소하는지 알 수 있게 된 부분이었습니다.
그리고 인터프리터의 실행은 느린 테스크라고 하는데요, 컴파일 된 기계코드를 가리키는 것이 비동기식이라는 점이 JIT에서 미리 컴파일된 코드를 실행하는 것이 빠른 이유도 살펴볼 수 있습니다.
JVM 코드 캐시(JVM code cache)라는 키워드로 검색하면 모니터링, 최적화하는 방법도 확인할 수 있습니다.


JDK와 JRE의 차이

프로그래밍 언어는 프로그램을 개발, 컴파일, 디버그, 실행하기 위해 어플리케이션 인터페이스와 라이브러리로 구성된 환경이 필요합니다.
자바는 그런 환경이 JRE, JDK로 2개가 있습니다. 자바로 작업하기 위해서는 이런 환경 중 하나를 설정 한 후 작업해야 합니다.

JRE는 사용자를 위한 것이고 JDK는 프로그래머를 위한 환경입니다.

JRE

자바 애플리케이션 실행에 필요한 최소 환경을 제공합니다. JVM 과 배포도구가 포함됩니다.

JDK(Java Development Kit)

자바 애플리케이션을 개발하고 실행하는데 사용되는 개발 환경입니다. JDK에는 JRE가 포함되어 있고, 자바9부터는 JRE를 따로 배포하지 않습니다.

JDK에 대해 더 알아보기

  • 배급처에서 배포하는 OpenJDK 배포 버전의 종류
  • OpenJDK는 Java Platform Standard Edition (Java SE)의 오픈 소스 구현으로, 여러 배급처가 존재합니다.
    오라클과 레드햇, Azul, AdoptOpenJDK 등의 OpenJDK 바이너리 공급 업체가 있습니다.

OpenJDK에 대한 것은 아래 글이 도움이 되었습니다.


참고자료

댓글