본문 바로가기
Java

JVM에 대해 자세히 알아보자!

by 매트(Mat) 2022. 7. 12.

JVM에 대해 자세히 알아보자!

JVM에 대해 알아보기 전에 Java의 특징에 대해 간략히 알아보자.

JVM, JRE, JDK

jvm1

JVM(Java Virtual Machine)이란 자바 바이트코드를 해석하고 실행하는 요소이다. JVM의 역할은 자바 애플리케이션을 클래스 로더(Class Loader)를 통해 읽어 들여서 자바 API와 함께 실행하는 것이다. JVM의 가장 중요한 특징은 Java와 OS(운영체제) 사이에서 중개자 역할을 수행하여 Java가 OS(운영체제)에 구애받지 않고 독립적으로 작동이 가능하다는 것이다. C/C++ 등의 언어처럼 컴파일러를 통해 바로 기계어로 변환하는 것이 아닌 JVM이 이해하는 자바 바이트코드를 해석하고 실행한다. 이를 통해 윈도우(내 로컬 PC)에서 작업한 자바 파일을 리눅스(Linux), 맥(Mac) OS에서 똑같이 실행할 수 있다.

바이트코드란 자바와 기계어 사이의 중간 언어라 할 수 있고 자바 코드를 배포하는 가장 작은 단위를 말한다.
자바 언어는 특정 OS(운영체제)에 종속적이지 않고, 바이트코드가 특정 OS(운영체제)에 종속적이라 할 수 있다. 어쨋든 각 OS에 맞게 기계어로 변환해주는 것은 누군가가 반드시 해줘야하기 때문이다.

각 OS 환경마다 컴파일러에 의해 해석된 기계어들이 다 다르기 때문에 C/C++ 같은 경우는 특정 OS에 종속될 수 밖에 없다.

JRE(Java Runtime Environment)란 자바 애플리케이션을 실행할 수 있도록 해주는 환경을 말한다. 즉, JRE가 있어야 자바 애플리케이션을 실행할 수 있고, 실행하려면 당연히 자바 바이트코드가 있어야 하므로 JRE는 자바 API와 JVM으로 구성된다.

JDK(Java Development Kit)란 Java로 소프트웨어를 개발할 수 있도록 여러 기능들을 제공하는 패키지(키트)이다. 즉, JDK는 JRE와 개발에 필요한 툴들을 모두 제공한다.

Java 11부터는 JDK만 제공되며 JRE를 따로 제공하지 않는다.

다양한 자바 구현

자바에는 표준 스펙이 있다. 이 표준 스펙에 맞추어 구현한 다양한 자바들이 존재한다.

윈도우(로컬 PC)에서 작업한 JDK가 OpenJDK이고, 리눅스(서버)에 배포하는데 리눅스의 JDK는 Amazon Corretto라 해도 문제가 없다. 왜냐하면 결국에는 자바 표준 스펙이 있고, 스펙에 맞춘 자바들이기 때문이다. (인터페이스와 구현에 대해 생각하면 이해하기 편하다.)

AWS의 Amazon Linux 2에서 Java 애플리케이션을 직접 실행하는 경우, Amazon Corretto를 사용하는 것이 이점이 있다.

JVM 구조

jvm3

자바 소스코드인 .java 파일javac로 컴파일하게 되면 자바 바이트코드인 .class 파일이 생성된다. 클래스 로더(Class Loader)는 이 컴파일된 자바 바이트코드를 런타임 데이터 영역(Runtime Data Area)에 로드(적재)하고, 실행 엔진(Execution Engine)이 로드한 자바 바이트코드를 실행한다.

클래스 로더

앞서 클래스 로더는 자바 바이트코드를 런타임 데이터 영역에 로드(적재)한다고 했다. 자바는 동적 로드, 즉 컴파일타임이 아닌 런타임에 클래스를 처음으로 참조할 때 해당 클래스를 로드하고 링크하는 특징이 있다. 이 동적 로드를 담당하는 부분이 JVM의 클래스 로더이다.

  • 로드: 클래스 파일을 읽어오는 과정
  • 링크: 클래스 파일을 사용하기 위해 검증하고, 기본 값으로 초기화하는 과정
  • 초기화: 클래스에 있는 static 값들을 초기화하고 변수에 할당

jvm4

클래스 로더 내부 과정의 순서는 loading -> linking -> initialization 순으로 진행된다.

클래스 로더의 특징

  • 계층 구조: 클래스 로더끼리 부모-자식 관계를 이루어 계층 구조로 생성된다.
  • 위임 모델: 클래스를 로드할 때 먼저 상위 클래스 로더를 확인하여 상위 클래스 로더에 있다면 해당 클래스를 사용하고, 없다면 로드를 요청받은 클래스 로더가 클래스를 로드한다.
  • 가시성(visibility) 제한: 하위 클래스 로더는 상위 클래스 로더의 클래스를 찾을 수 있지만 그 반대는 찾을 수 없다.
  • 언로드 불가: 클래스 로더는 클래스를 로드할 수는 있지만 언로드 할 수는 없다. 언로드 대신 클래스 로더를 삭제하고 새로운 클래스 로더를 생성하는 방법이 있다.

각 클래스 로더는 로드된 클래스들을 보관하는 네임스페이스(namespace)를 갖는다. 클래스를 로드할 때 이미 로드된 클래스인지 확인하기 위해서 네임스페이스에 보관된 FQCN(Fully Qualified Class Name)을 기준으로 클래스를 찾는다. 비록 FQCN이 같더라도 네임스페이스가 다르면, 즉 다른 클래스 로더가 로드한 클래스이면 다른 클래스로 간주된다.

FQCN(Fully Qualified Class Name)이란 클래스가 속한 패키지명을 모두 포함한 이름을 말한다. ex) java.lang.String

loading

  • 부트스트랩(Bootstrap): JVM을 실행할 때 생성되며, Object 클래스들을 비롯하여 자바 API들을 로드한다. 다른 클래스 로더와 달리 자바가 아니라 네이티브 코드로 구현되어 있다.
  • 익스텐션(Extension): 기본 자바 API들을 제외한 확장 클래스들을 로드한다. 다양한 보안 확장 기능 등을 여기서 로드하게 된다.
  • 애플리케이션(Application): Classpath 에 있는 클래스들을 로드한다. 즉, 개발자들이 자바 코드로 짠 클래스 파일들을 JVM에 로드하는 역할을 한다.

만약 위 과정을 모두 거쳤는데도 클래스 파일을 찾지 못한다면 ClassNotFoundException 예외를 발생시키게 된다.

linking

  • 검증(Verify): 클래스 파일이 유요한지를 검증하는 과정이다.
  • 준비(Prepare): 클래스가 필요로 하는 메모리를 할당하고 클래스에서 정의된 필드, 메서드, 인터페이스들을 나타내는 데이터 구조를 준비한다.
  • 분석(Resolve): 클래스의 상수 풀 내의 모든 심볼릭 레퍼런스(논리적인 레퍼런스)를 다이렉트 레퍼런스(실제, 물리 레퍼런스)로 변경한다.

심볼릭 레퍼런스(Symbolic Reference)란 다른 객체를 참조할 때 논리적으로만 연결시켜놓고 실제론 연결되어 있지 않은 것

다이렉트 레퍼런스(Direct Reference)란 참조하는 클래스의 특정 메모리 주소를 참조하는 것

initialization

  • 초기화: 클래스 변수들을 적절한 값으로 초기화한다. 즉, static initializer들을 수행하고, static 필드들을 설정된 값으로 초기화한다.

런타임 데이터 영역

jvm5

런타임 데이터 영역(Runtime Data Area)이란 바이트코드, 객체, 매개 변수, 지역 변수, 반환 값 등을 저장하기 위한 메모리 영역이다. 즉, JVM이라는 프로그램이 운영체제 위에서 실행되면서 할당받는 메모리 영역이다.

메서드 영역

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

런타임 상수 풀(Runtime Constant Pool)이란 메서드 영역에 포함되는 영역이긴 하지만 JVM 동작에서 가장 핵심적인 역할을 수행하기도 하고 각 클래스와 인터페이스의 상수뿐만 아니라 메서드와 필드에 대한 모든 레퍼런스까지 담고 있는 테이블이다. 즉, 어떤 메서드나 필드를 참조할 때 JVM은 런타임 상수 풀을 통해 해당 메서드나 필드의 실제 메모리상 주소를 찾아서 참조한다.

힙 영역

힙 영역(Heap Area)이란 인스턴스 또는 객체를 저장하는 공간으로 가비지 컬렉션 대상이 된다.

스택

JVM의 스택(Stack)은 각 스레드마다 하나씩 존재하며 스레드가 시작될 때 생성된다. 그리고 스택은 스택 프레임(Stack Frame)이라는 구조체를 저장한다. 스택 프레임은 메서드 콜을 말하는데 예외 발생 시 printStackTrace() 등의 메서드로 보여주는 Stack Trace의 각 라인은 하나의 스택 프레임을 표현한다.

PC 레지스터

PC(Program Counter) 레지스터(Register)는 각 스레드마다 하나씩 존재하며 스레드가 시작될 때 생성된다. PC 레지스터는 현재 수행중인 JVM 명령의 주소를 갖는다. -> 스레드마다 스레드 내 현재 실행할 스택 프레임을 가리키는 주소가 생성된다.

네이티브 메서드 스택

네이티브 메서드 스택(Native Method Stack)이란 자바 외의 언어로 작성된 네이티브 코드를 위한 스택이다. 즉, JNI(Java Native Interface)를 통해 호출하는 C/C++ 등의 코드를 수행하기 위한 스택이다.

네이티브 메서드 인터페이스

JNI(Java Native Interface)란 자바 애플리케이션에서 C/C++ 즉, native로 구현된 함수를 호출할 수 있게 해준다는 의미로 Java Native Interface이다. JNI는 메서드에 native라는 키워드가 있다. 그리고 Java Native Libraries는 JNI의 구현체로서 항상 JNI를 통해서 써야 한다.

실행 엔진

jvm6

실행 엔진(Execution Engine)은 클래스 로더를 통해 JVM 내의 런타임 데이터 영역에 배치된 바이트코드는 실행 엔진에 의해 실행된다. 실행 엔진은 자바 바이트코드를 명령어 단위로 읽어서 실행한다. 이때 바이트코드를 해석하는 방식이 2가지가 있다.

  • 인터프리터: 바이트코드 명령어를 하나씩 읽어서 해석하고 실행한다. 하나씩 해석하고 실행하기 때문에 느리다는 단점을 가지고 있다. 바이트코드라는 언어는 기본적으로 인터프리터 방식으로 동작한다.
  • JIT(Just-In-Time) 컴파일러: 인터프리터의 단점을 보완하기 위해 도입된 것이 JIT 컴파일러이다. 인터프리터 방식으로 실행하다가 적절한 시점에 바이트코드 전체를 컴파일하여 네이티브 코드로 변경하고 이후에는 해당 메서드를 더 이상 인터프리팅하지 않고 네이티브 코드로 직접 실행하는 방식이다. 네이티브 코드로 실행하는 것이 더 빠른 이유는 네이티브 코드는 캐시에 보관하기 때문에 한번 컴파일된 코드는 캐시에서 가져다 사용한다.

하지만 JIT 컴파일러가 컴파일 하는 과정은 바이트코드를 하나씩 인터프리팅하는 것보다 훨씬 느리기 때문에 한번만 실행되는 코드라면 그냥 인터프리팅 방식을 사용하는 것이 훨씬 유리하다.

마무리

자바 애플리케이션을 개발하는데 있어서 JVM의 구조까지 알 필요는 없지만 자바를 사용하는 개발자라면, JVM을 사용하는 개발자라면 한 번쯤은 자바라는 언어가 어떻게 컴파일 과정을 겪는지 알아두면 좋을 것 같다는 생각이 들었습니다.

References

댓글