Technology/Java

JVM이란 무엇인가? JVM 파헤쳐보기

ikjo 2022. 7. 23. 01:16

JVM이란 무엇인가?

JVM(Java Virtual Machine)은 자바 가상 머신으로서 자바를 실행하기 위한 가상 기계(컴퓨터)라고 할 수 있다. 이때 가상 기계란 소프트웨어로 구현된 하드웨어로서 가상 컴퓨터는 실제 컴퓨터(하드웨어)가 아닌 소프트웨어로 구현된 컴퓨터라는 뜻으로 생각할 수 있다.

 

자바로 작성된 프로그램은 모두 이 JVM에서만 실행되기 때문에 자바 프로그램이 실행되기 위해서는 이 JVM이 반드시 필요하다. 이러한 특성으로 자바 프로그램은 OS와 하드웨어에 독립적이라 다른 OS에서도 프로그램의 변경없이 실행이 가능한 장점이 있다. 이러한 특성을 "Write once, run anywhere"라고도 한다. 다만, JVM의 경우에는 운영체제에 종속적이기 때문에 해당 OS에서 실행 가능한 별도의 JVM 설치가 필요하다.

 

하지만 일반적인 프로그램은 컴퓨터의 운영체제를 거쳐 프로그램이 실행되는 반면 자바 프로그램의 경우 운영체제를 거치기 전에 JVM을 거치기 때문에 실행 시에 속도가 느리다는 단점이 있다. 이러한 단점을 개선하기 위해 JVM에서 처리하는 바이트 코드(컴파일된 자바 코드)를 하드웨어의 기계어로 바로 변환해주는 JIT 컴파일러와 Hostpot VM 등 최적화 기술들이 등장했고 이들을 통해 속도 문제를 상당히 개선시킬 수 있었다.

 

JVM의 구성 요소는 크게 클래스 로더(Class Loader), 메모리(Memory or 실행 데이터 영역 Runtime Data Area), 실행 엔진(Execution Engine)으로 분류할 수 있는데, 각각의 요소들에 대해 살펴보자.

 

 

클래스 로더

클래스 로더는 JVM이 JRE에 의해 부팅된 이후인 자바 프로그램 실행 단계에서 동작하며, "로딩 → 링크  초기화"라는 일련의 과정(전처리 과정)을 통해 실행 엔진이 클래스 파일을 실행시킬 수 있도록 클래스 정보를 동적으로 JVM 메모리 영역의 "메서드 영역"으로 저장한다. 이때 동적으로 적재한다는 것은 자바 프로그램 실행 시 모든 자바 클래스를 한꺼번에 불러오는 게 아니라 프로그램에서 필요로 하는 경우에만 불러온다는 것이다. 이는 불필요한 메모리 사용을 최대한 낮추기 위한 나름의 최적화 방식이다.

image src :https://javatutorial.net/tag/java-class-loader/

 

로딩(Loading)

로딩 단계에서 클래스 로더는 클래스 파일을 읽고 해당되는 이진 데이터를 생성한 다음 JVM 메모리 영역의 메서드 영역에 다음 정보들을 저장한다.

 

 - Fully-Quailified Class Name(FQCN) : 로드된 클래스를 비롯한 그의 부모 클래스의 정보(패키지 경로, 이름 등)
 - 클래스 파일의 Type 정보(Class, Interface, Enum)
 - 제어자, 변수 등 메서드 정보

 

이때 클래스 로더는 Bootstrap 클래스 로더, Extension 클래스 로더, Application 클래스 로더 총 3가지의 클래스 로더가 있는데, 이들은 계층형 구조를 가지며 이 중 최상위 클래스 로더는 Bootstrap 클래스 로더이다.

 

Bootstrap(Native C로 구현) : 최상위 클래스 로더로서 ${JAVA_HOME}/jre/lib/rt.jar에 담긴 JDK 클래스 파일(java.lang 등)을 로딩함

 

Extension(Java로 구현) : ${JAVA_HOME}/jre/lib/ext 폴더나 java.ext.dirs 시스템 환경 변수로 지정된 폴더에 있는 클래스 파일을 로딩함

 

Application(Java로 구현) :  -classpath(또는 -cp)나 JAR 파일 안에 있는 Manifest 파일의 Class-Path 속성 값으로 지정된 폴더에 있는 클래스(개발자가 애플리케이션 구동을 위해 작성한 대부분의 클래스가 여기에 해당함)를 로딩함

 

※ Java 8 기준

 

클래스 로더의 3가지 원칙 : 위임, 가시 범위, 유일성

서로 상하 관계에 있는 클래스 로더들이 정해진 순서에 따라 클래스를 로딩하는데, 클래스 로딩을 요청할 때는 우선순위가 낮은 클래스 로더에서 높은 클래스 로더 순서대로 로딩 요청을 위임한다.(위임 원칙) 이렇게 해당 로딩 요청은 가장 먼저 최상위 클래스 로더(Bootstrap 클래스 로더)에서 클래스 로딩을 시도하는데, 해당되는 클래스가 없을 시에는 Extension 클래스 로더에서 시도하고, 마찬가지로 해당되는 클래스가 없을 때는 Apllication 클래스 로더에서 시도한다.

 

즉, "기본 라이브러리인 rt.jar" → "외부 라이브러리인 ext" → "JVM이 프로그램을 실행할 때 클래스를 찾기 위한 기준이 되는 경로인 classpath" 순으로 클래스 로딩을 시도하는 것이다. 최종적으로 클래스 파일이 없다면 ClassNotFoundException이 발생하게 된다.

 

이때 어떤 상위 클래스 로더가 특정 클래스 파일을 찾는 데 성공하면 그 클래스 파일 정보를 자식 클래스 로더에 전달해준다. 이러한 특성으로 하위 클래스 로더는 상위 클래스 로더가 로딩한 클래스를 볼 수 있게 된다. 하지만 반대로 어떤 하위 클래스 로더가 특정 클래스 파일을 찾았다면 해당 상위 클래스 로더는 하위 클래스 로더가 로딩한 클래스를 볼 수 없다.(가시 범위의 원칙)

 

아울러, 하위 클래스 로더는 상위 클래스 로더가 로딩한 클래스를 다시 로딩하지 않게 함으로써 로딩된 클래스의 유일성을 보장할 수 있다.(유일성의 원칙) 이렇게 클래스 로더에 의해 로딩된 클래스들은 JVM 상에서 없앨 수 없다.

 

※ 참고

클래스 파일이 로딩된 이후, JVM은 java.lang 패키지에 미리 정의된 클래스에 대한 객체를 생성하여 힙 영역에 저장하는데, 개발자는 이 객체들을 통해 클래스명, 메서드, 변수 등의 클래스 수준의 정보를 얻을 수 있다. 예를 들면 Obejct 클래스의 getClass() 메서드를 통해 해당 객체가 어느 클래스에 속하는지를 알 수 있는 것이다.

 

링크(Linking)

링크 단계에서는 검증(Verify), 준비(Prepare), 분석(Resolve)을 수행한다.

 

검증 : ByteCodeVerifier 컴포넌트에 의해 클래스 파일이 올바르게 포맷되었는지, 유효한 컴파일러에 의해 생성되었는지 등을 검증하며 검증 실패 시 java.lang.VerifyError 에러가 발생한다.

 

비 : 정적 변수에 맞는 메모리를 할당하고 해당 메모리에 대한 기본 값으로 초기화(ex. static int : 0, static String : null 등)

 

석 : 클래스 상수 내 모든 symbolic references(이름에 의한 참조)를 direct references(실제 메모리 주소에 의한 참조)로 바꾸는 작업으로서 사용하는 환경에 따라 동작 유무가 정해지므로 선택적으로 동작한다.

 

초기화(initialization)

모든 정적 변수들의 값이 자바 소스 코드 상 정의된 값으로 초기화되며 static block을 실행한다. 이 작업은 클래스 계층 구조상 상위 클래스에서 하위 클래스 순으로 처리되며, 하나의 클래스 파일에서는 상단에서 하단 순으로 처리된다.

 

앞서 링크 단계 中 "준비" 단계에서 정적 변수에 대해 해당 메모리에 맞는 기본값으로 저장되고 초기화 단계에서 해당 정적 변수에 대해서 정의된 값으로 초기화되고 static block이 실행되므로, 정적 변수의 초기화 순서는 기본적으로 아래와 같다.

 

static 변수 기본적 초기화 -> static 변수 명시적 초기화 -> static block 실행

 

참고로 변수의 값 초기화에 있어 기본적 초기화와 명시적 초기화를 나누는 이유는 해당하는 변수가 차지할 메모리 공간을 마련하는데 의미가 있다.

 

 

JVM의 메모리 구조

자바 프로그램이 실행되면, 자바 프로그램 실행기가 JVM을 부팅하고, JVM은 운영체제로부터 프로그램을 수행하는데 필요한 메모리를 할당받는다. 그리고 나서 JVM은 이 메모리를 용도에 따라 5가지 영역(메서드 영역, 힙 영역, 스택 영역, PC 레지스터, 네이티브 메서드 스택)으로 나누어 관리한다.

 

이때 JVM의 메모리 구조상 영역을 크게 코드 실행 영역데이터 저장 영역으로 나눌 수 있다. 우선 코드 실행 영역에는 스레드 실행 정보를 저장하는 PC 레지스터와 네이티브 메서드 정보를 저장하는 네이티브 메서드 스택이 존재하며, 데이터 저장 영역에는 클래스 정보를 저장하는 스태틱(메서드) 영역, 객체와 배열 등의 정보를 저장하는 힙 영역, 메서드 정보를 저장하는 스택 영역이 존재한다.

 

image src: https://www.programcreek.com/2013/04/jvm-run-time-data-areas/

 

메서드 영역

메서드 영역은 JVM 내 단 하나의 메모리 영역으로 모든 JVM 스레드가 이를 공유한다. 이미 언급했듯이 클래스 로더 로딩 단계에서 클래스 파일을 이 영역에 저장하는데, 프로그램 실행 중 어떤 클래스가 사용되면, JVM은 해당 클래스의 클래스 파일(*.class)을 읽어와 분석하고 클래스에 대한 정보(클래스명, 생성자, 클래스 변수 등 클래스 수준의 정보)를 이곳에 저장한다.

 

메서드 영역은 스태틱(static) 영역이라고도 불린다. 여기서 스태틱이란 "고정된"이라는 뜻을 가지고 있는데, 스태틱 영역에 올라간 정보는 main 메서드가 시작되기 전에 올라가서 main 메서드가 종료된 후에 내려올 정도로 스태틱 영역에 단단히 고정돼 있기 때문에 스태틱 영역이라고 한다.

 

힙 영역

힙 영역은 인스턴스가 생성되는 공간으로서 JVM 내 단 하나의 메모리 영역으로 모든 JVM 스레드가 이를 공유한다. 프로그램 실행 중 생성되는 new 연산자로 생성된 모든 객체(인스턴스 변수 포함)와 배열 등의 인스턴스는 모두 이곳에 생성된다.

 

아울러, 자바 소스 코드 파일에 포함된 모든 리터럴의 목록이 있는 클래스 파일이 클래스 로더에 의해 메모리에 올라갈 때, 이 리터럴의 목록에 있는 문자열 리터럴들은 힙 영역 내 상수 저장소에 저장된다.

 

클래스 로더 로딩 단계가 종료 후 해당 클래스 타입의 객체를 생성하여 이 영역에 저장한다. 이때 JVM을 구성하는 요소 중 실행 엔진에 포함되어있는 가비지 컬렉터(Garbage Collector)는 참조되지 않는 객체를 자동으로 힙 영역에서 제거하는 역할을 하며 이 힙 영역의 객체 정보는 오직 가비지 컬렉터에 의해서만 제거된다.

 

참고로 힙이라는 자료구조는 대용량 자료를 저장할 수 있도록 메모리를 사용하는 방식인데, 완전 이진 트리 형태로 여러 개의 값들 중 최대값이나 최솟값을 빠르게 찾아내도록 만들어진 자료구조이다.

 

스택 영역(Call Stack)

JVM은 모든 스레드에 대해 각각의 독립적인 런타임 스택을 이곳에 저장하는데, 앞서 다룬 메서드 영역과 힙 영역과 달리 스택 영역은 공유되는 자원이 아니라 스레드별 독립적인 자료구조에 해당된다.

 

스택 영역은 메서드의 작업에 필요한 메모리 공간을 제공하는데, 메서드가 호출되면 메서드를 위한 메모리(지역 변수, 매개 변수 등 연산의 중간 결과)가 이 영역에 할당되며, 메서드가 작업을 마치면(또는 예외 발생) 할당되었던 메모리공간은 반환되어 비워진다. 이때 반환타입이 있는 메서드는 종료되면서 결과값을 자신을 호출한 메서드(caller)에 반환한다.

 

스택이라는 자료구조의 특성에 따라 스택 영역 최상위에 위치한 메서드가 현재 실행 중인 메서드이며, 나머지는 대기상태에 있게 된다.

 

PC 레지스터

PC 레지스터는 스레드가 시작될 때 생성되는 공간으로 스레드마다 하나씩 존재하며 스레드가 어떤 부분을 어떤 명령으로 실행해야 할 지 등 스레드 실행 정보(프로그램 카운터 값)을 저장하는 메모리 영역이다.

 

네이티브 메서드 스택

네이티브 메서드 스택은 JVM의 스택이 아니라 주로 C 스택을 가리킨다. 자바가 아닌 다른 언어로 작성된 네이티브 메서드를 지원하기 위해 사용되는 스택이다. 네이티브 메서드 스택은 JVM 스택과 마찬가지로 스레드 단위의 자료구조이다.

 

JVM 메모리 구조 요약 정리

메모리 영역 저장 데이터 비고
메서드 영역 클래스 정보 JVM 내 단 하나의 자료구조(모든 스레드가 공유)
힙 영역 객체, 배열 정보 JVM 내 단 하나의 자료구조(모든 스레드가 공유)
스택 영역 메서드 정보 스레드 단위의 독립적인 자료구조
PC 레지스터 스레드 실행 정보 스레드 단위의 독립적인 자료구조
네이티브 메서드 스택 네이티브 메서드 정보 스레드 단위의 독립적인 자료구조

 

 

실행 엔진

실행 엔진은 바이트 코드로 된 클래스 파일을 실행하고 이를 네이티브 코드로 변환시키는 역할을 한다. 실행 엔진은 "인터프리터", "JIT 컴파일러", "가비지 컬렉터" 총 3가지로 구성된다.

 

인터프리터(Interpreter)

인터프리터는 바이트 코드를 한줄씩 실행 및 해석하여 네이티브 코드를 생성한다. 하지만 인터프리터 자체는 느리고 하나의 메소드를 반복적으로 호출할 경우 매번 해석을 재요청해야하기 때문에 비효율적이다.

 

JIT(Just-In-Time, 실시간) 컴파일러

인터프리터가 같은 코드를 매번 해석하지 않도록 JIT 컴파일러는 컴파일을 통해 "인터프리터 방식으로 생성된 네이티브 코드"를 캐싱한다. 이후에는 바뀐 부분만 컴파일 하고 나머지 부분은 캐싱된 코드를 사용함으로써 인터프리터로 인한 느린 속도를 보완할 수 있다.

 

이때 단순히 한 줄을 컴파일 하는데 소모되는 비용은 인터프리터 보다 컴파일러가 더 크지만 자주 사용되는 코드를 캐싱함으로 인해 인터프리터만을 사용하는 것보다 훨씬 성능이 유리해진다. 즉, 인터프리터와 컴파일러의 방식을 적절히 혼합해서 같은 함수가 여러번 불릴 때 매번 네이티브 코드를 생성하는 것을 방지함으로써 속도를 개선할 수 있는 것이다. 여기서 어떤 코드를 컴파일할지를 결정하는 것은 Hotspot VM이 결정해준다.

 

가비지 컬렉터(Garbage Collector)

가비지 컬렉터는 참조되지 않은 객체를 삭제하는 역할을 하는데, 참조되지 않는 객체가 하나라도 발생할 때마다 삭제하는 것이 아니라 일정량이 쌓이면 한번에 삭제한다.

 

이외의 구성요소 

앞서 JVM을 크게 클래스 로더, 메모리, 실행 엔진 3가지로 분류해보았다. 하지만 이외에도 "자바 네이티브 인터페이스(Java Native Interface)""네이티브 메서드 라이브러리(Native Method Libraried)"가 존재하는데, 이에 대해서도 간략하게 살펴 보자.

 

image src : https://www.javacodegeeks.com/2018/04/jvm-architecture-execution-engine-in-jvm.html

 

자바 네이티브 인터페이스

자바 네이티브 인터페이스는 네이티브 메서드 라이브러리와 상호작용하는 인터페이스로서 실행 엔진에서 필요로 하는 C나 C++의 네이티브 라이브러리를 제공하는 역할을 한다. 이는 JVM이 C나 C++ 라이브러리를 호출할 수 있게 해주고 하드웨어에 특화된 C나 C++ 라이브러리를 호출할 수도 있게 해준다.

 

네이티브 메서드 라이브러리

네이티브 메서드 라이브러리는 실행 엔진이 필요로 하는 C나 C++로 이루어진 네이티브 라이브러리들의 집합체이다. 

 

 

참고 자료

  • 위키북스 "스프링 입문을 위한 자바 객체 지향의 원리와 이해"
  • 도우출판  "자바의 정석"
  • https://www.geeksforgeeks.org/jvm-works-jvm-architecture/
  • https://engkimbs.tistory.com/606
  • https://github.com/HomoEfficio/dev-tips/blob/master/Java ClassLoader 훑어보기.md