개발자로 후회없는 삶 살기

[Core] JVM의 역할과 동작 원리 본문

[백엔드]/[Java | 학습기록]

[Core] JVM의 역할과 동작 원리

몽이장쥰 2024. 6. 19. 21:21

서론

※ 과거에 기록한 내용에서 중요한 부분만 발췌하여 모두가 이해하기 쉽게 다시 서술한다.

 

본론

- JVM, JDK, JRE, JAR 구분

1. JVM

자바 바이트 코드를 어떻게 실행할 지에 대한 표준 스펙으로 바이트 코드를 OS에 특화된 코드로(기계어) 변환하고 실행하는 것의 표준이고 구현체이다.

 

class 파일을 열어보면 바이트 코드를 디컴파일해서 보기 좋게 만들어 준다. JVM이 바이트 코드를 Mac, Window에 맞는 머신 코드로 변경한 후(기계어) 실행하는 것이다. 이외에도, 클래스 로드, 메모리에 로드하고 실행하는 자바를 사용하는 개발자를 위한 OS 인터페이스라고 볼 수 있다. 하는 일이 물리 컴퓨터의 OS와 비슷하다.

 

※ 바이트 코드 : java 파일을 컴파일해서 생긴 class 파일 안에 들어있는 컴파일 코드

 

2. JRE

JVM은 홀로 배포되지 않고 최소한의 배포 단위가 JRE이다. JVM과 라이브러리로 구성되고 자바 어플리케이션을 실행할 수 있는 것만 들어있다. 자바를 개발하는데 필요한 툴은 제공되지 않는다.

 

3. JDK

개발에 필요한 툴 ( javac 컴파일러 등) 까지 같이 제공하는 것이다. 그런데 자바 11부터는 JRE를 따로 제공하지 않고 JDK로 같이 제공하여 구분 되지 않는다.

 

4. JAR

여러 개의 자바 소스 파일과 리소스를 하나의 파일로 모아서 응용 소프트웨어나 라이브러리를 배포하기 위한 패키지 파일 포멧

 

✅ JVM 구조

jvm은 자바 바이트 코드를 os에 맞는 기계어로 변환하고 이를 실행하는 표준 스팩이라고 했다. (그래서 자바 언어는 os에 종속되지 않는 언어이고 jvm만 os에 맞게 설치하면 자바를 실행할 수 있다.) 이를 어떻게 하는지 알아보자

 

1. 클래스 로더 시스템

자바 바이트 코드를 읽어서 메모리에 배치한다. 바이트 코드를 실행하기 위한 첫 단계이다.

 

-> 역할

크게 3가지 영역이 있습니다.

 

1) 로딩 : 실제 클래스 파일에서 바이트 코드를 읽어옴
2) 링크 : 레퍼런스를 연결
3) 초기화 : 클래스에 있는 static한 값들을 초기화(스태틱 클래스, 변수 등)

작성한 클래스 파일을 로드하는 역할로, 이와 관련된 내용은 아래에서 심화로 다룬다.

 

2. 메모리

크게 5가지 영역이 있다. 

 

-> 영역

1) 메소드

클래스 수준의 정보(패키지 경로, 클래스 이름) 등을 저장하고 있다. 메서드 영역은 공유 자원으로 다른 영역에서 참조할 수 있다. 메서드 외 영역은 스레드 별로 각각 생성되어, 독립적으로 관리되는 자원이다.

 

2) 힙

new Book()

객체를 저장, 공유하는 자원이다. 코드가 실행됨에 따라 생성되는 모든 객체가 힙에서 관리된다.

 

3) 스택 + PC

스레드마다 런타임 스택을 만들고 그 안에 스택 프레임을 쌓는다.

 

예를들어서, 에러 메세지를 보면 메서드가 쭉 쌓여있는 것을 볼 수 있다. 이러한 메서드 호출 스택이 스택안에 쌓여있다. 스택은 스레드마다 하나씩 만들어지고 현재 어느 위치를 실행하고 있는지를 가리키는 PC(Program Counter) 레지스터 역시 쓰레드마다 생긴다.

 

스레드가 프로세스 내에서 작업을 하면 런타임 스택에 스택 프레임을 쌓고 만약 오류가 나면 런타임 스택의 메서드 호출 스택을 출력하여 오류 원인을 출력한다.

 

4) 네이티브 메서드 스택

네이티브 메서드를 호출할 때 생기는 별도의 스택으로 역시 쓰레드마다 생긴다.

 

✅ 네이티브 메서드

구현을 c로 한 메서드, API이다. 예를들어 Thread.currentThread() 메서드는 native라는 키워드가 붙은 API이다. 네이티브로 구현되어 있는 코드를 호출할 수 있는 인터페이스라고 해서 JNI라고 부르고 실제 구현된 자체를 네이티브 메서드 라이브러리라고 부르며 항상 JNI를 통해서 호출해야 한다. 또한 이런 네이티브 라이브러리를 통해 실행된 메서드는 네이티브 메서드 스택에 쌓인다. 이러한 배경지식은 어플을 프로파일링할 때 사용한다.

 

3. 실행 엔진

클래스 로더가 클래스를 메모리에 올린다고 했고 메모리에 올린 코드를 이제 실행해보자. 바이트 코드를 이해하고 한줄 한줄 네이티브 코드로 바꿔서 실행한다.

 

-> 역할

1) 인터프리터 + JIT 컴파일러

바이트 코드를 네이티브 코드로 인터프리터가 한 줄씩 번역하고 실행을 하는데 똑같은 코드가 여러번 나와도 매번 바꾸는 게 비효율적이라서 캐시처럼 반복적 코드가 나오면 JIT(Just In Time) 컴파일러에게 보내서 반복된 코드를 전부 찾아서 미리 다 바꿔두고 인터프리터가 JIT를 사용한다. 이런식으로 프로그램 실행 속도를 향상시킨다.

※ 네이티브 코드(= 기계어) : CPU와 운영체제가 직접적으로 실행할 수 있는 코드
JIT 컴파일러 : 역시 바이트 코드를 네이티브 코드로 번역하는 컴파일러

 

2) GC(가비지 컬랙터)

① Throughput GC
② Stop the World GC

더 이상 참조하지 않는 객체를 정리하는 것이 GC이고 이것도 실행 엔진이 하는 일이다. 서버 운영 중에 굉장히 많은 객체를 생성하고 반응 시간이 굉장히 중요할 때 사용하며 gc를 사용할 때 나타나는 멈춤 현상을 최소화할 수 있는 gc를 사용하는 게 좋다. 경우에 따라 옵션 조정을 해야할수도 있고 우리가 사용할, 사용하는 gc를 선택해야 하는 경우도 있다.

-> 정리

클래스 로더가 바이트 코드를 읽고 메모리에 배치할 때 힙에 객체, 메서드에 클래스 정보를 배치한다. 실행하면 스레드가 만들어지고 코드들이 객체에 있을 테니 힙과 메서드의 프로세스를 스레드가 읽으면서 스레드 별로 스택, pc, 네이티브 메서드 스택이 생기고 실행 엔진의 인터프리터가 힙과 메서드의 바이트 코드를 한줄 한 줄 읽으면서 어떤 코드는 스택에 넣는 것도 있고 빼내는 것도 있다.

한줄 한줄 하는게 비효율적이니 JIT 컴파일러도 쓰고 GC로 메모리 최적화를 해준다. 실행하다가 네이티브 메서드를 실행하게 되면 JNI를 통해 네이티브 메서드 라이브러리를 호출하고 이를 네이티브 메서드 스택에 저장하여 사용한다.

 

- 클래스 로더

① 로딩 : 클래스 로더가 클래스 파일에 있는 내용을 읽어서 메서드 영역에 클래스 정보를 저장한다. 클래스, 인터페이스, enum, 클래스 내부 메서드와 변수를 저장하고 로딩이 끝나면 해당 클래스 타입의 class 객체를 생성해서 힙 영역에 저장한다. 따라서 로딩이 끝나면 객체에 접근할 수 있고 모든 영역에서 메서드 영역을 공용으로 사용할 수 있다.
② 링크 : 읽은 클래스를 저장할 메모리를 준비하고 book = new Book() 처럼 book은 실제 레퍼런스가 아닌 논리적인 레퍼런스인데 이걸 실제 힙에 들어있는 북 객체를 가리키도록 한다.
③ 초기화 : 이때 클래스에 있는 static한 값들을 전부 메서드 영역에 할당

실제 코딩을 할 때 클래스 로더와 관련되어 있는 라이브러리나 툴을 경험할 일이 많다. 자세히 알아보자.

 

=> 로딩

이를 좀 더 자세하게 그리면 이와 같다. 클래스를 로딩하는 과정에 여러개의 클래스 로더들이 부모 자식 관계를 맺고 로딩을 한다. 로딩을 할 때는 클래스 로더가 .class 파일에 있는 내용을 읽어서 적절한 바이너리 데이터로 만들어서 메소드 영역에 클래스 정보를 저장한다.

 

class, interface, enum, 클래스 내부 메서드와 변수를 저장하고 로딩이 끝나면 해당 클래스 타입의 class 객체를 생성해서 힙 영역에 저장한다. 따라서 로딩이 끝나면 객체에 접근할 수 있고 메소드 영역과 힙 영역에 객체와 클래스 정보가 저장된다.

 

 

App > 플랫폼 > 부트스트랩

우리가 읽어들인 클래스 로더를 확인해보면 로더는 계층형 구조로 부모가 있다. 최상위 부트스트랩 로더는 네이티브 코드로 구현 되어있어서 null 값으로 출력된다.

 

-> 3가지 클래스 로더

1. 부트스트랩

 

"jdk.boot.class.path.append" 프로퍼티 값으로 설정 되어있는 클래스들만 읽는다.

 

2. 플랫폼
3. 어플리케이션 : 우리가 작성한 코드들은 99% 어플 로더가 읽는다. 

읽고 싶은 클래스가 있으면 제일 부모한테 먼저 요청해서 부모가 읽어오고 부모가 못 읽으면 그 다음 부모가 읽고 본인도 못 읽으면 ClassNotFound 예외가 발생한다.

 

-> 링킹

로딩이 되면 링킹을 하며, 3단계로 이루어진다.

 

1. verify : .class 형식이 유효한지 체크
2. Prepare : 읽은 클래스를 저장할 메모리를 준비
3. Resolve : 심볼릭 레퍼런스를 실제 레퍼런스로 교체하는데 (옵셔널)

심볼릭 레퍼런스란 book은 실제 레퍼런스가 아니라 논리적인 레퍼런스인데 이걸 실제 힙에 들어있는 book 객체를 가리키도록 한다.

 

-> 초기화

준비한 메모리에 스태틱한 변수를 할당하는 과정으로 static 값들이 전부 다 이때 메서드 영역에 할당된다.

Comments