JVM은 자바 기본서를 피면 가장 앞에 나온다.
분명 살면서 몇 번을 읽었지만, 누가 물어보면 자세히 답변이 어려운 것을 모두가 공감할 것이다.
대충 어찌어찌 해서 바이트 코드가 만들어지고, 그걸 실행 시켜서
어떤 OS 위에서도 잘 돌아가게 해주는 가상머신 아닌가..?
이런 답변만이 머릿 속을 맴돌 뿐이다..
JDK와 JRE는 봐도 봐도 헷갈리고 모호하다
이번 기회에 확실하게 알아보자.
JVM과 바이트 코드가 뭐 하는 물건인지?
JVM에서 말하는 컴파일과 코드를 실행 과정은 어떻게 이루어지는지?
긴 여정속에서 함께할 동료들은 어떤 친구들이 있는지?
복잡하고 아름다운 JVM의 내부 구조와 함께 한번 알아보자.
사실 가장 아래 래퍼런스들의 문서를 읽는 것이 더 도움이 될 것 같다.
1. JVM이란 무엇인가
JVM은 Java Virtual Machine으로,
JVM의 스펙에 따르면, JVM은 바이트 코드를 실행할 수 있는 추상적인 형태의 스택 기반 해석 머신이라고 한다.
OS에 종속되지 않고 바이트 코드를 해석해 기계어로 번역해주는 가상머신이다.
프로그램을 실행시킬 수 있도록 돕기 위해 존재한다.
기존의 프로그래밍 언어들은 기계어로 변환할 때 각 CPU나 OS에 맞는 방식으로 변환해줘야 했는데,
JVM이 중간에 끼어서 알아서 각 OS에 맞는 방식으로 변환해준다.
그러니까 초벌 구이처럼 한번 살짝 익혀서 (컴파일해서) JVM에게 주면
각 CPU나 OS의 상황에 맞게 기계어로 바꾸어 주는 것이다.
이러한 특성은 Write Once, Run Antwhere이라고 부른다.
한번만 딱 쓰고 어디서든 쓰자~ 라는 뜻.
이런 이미지를 머리에 넣으면 좋다.
기존 프로그램은 하드웨어 위에 OS가 있고, 그 위에 프로그램이 돌아가는 구조이다.
하지만, JVM의 경우 '가상' 머신으로써 Java Program이 어떤 OS에서도 돌아갈 수 있도록
그 사이에 낑겨 들어가 일종의 "인터페이스"를 제공해준다.
이러한 인터페이스는 native 메서드로 구현되는데, 네이티브 메서드란 Java 언어가 아닌 다른 언어들로 쓰인 메서드들을 말한다. 아래 그림에서 native method interface에 있는데, 이를 Java Native Intreface (JNI) 이다
자바 네이티브 인터페이스 JNI를 통해 이런 native 메서드들을 자바 메서드들처럼 편하게 사용할 수 있다.
OS나 CPU에 독립적인 JVM은 이런 JNI를 통해 'OS에 의존적인' C, C++ 메서드를 호출해 준다.
1.1 JVM 스펙
이제 기본 스펙을 한번 살펴보자.
JVM의 스펙에 따르면, JVM은 바이트 코드를 실행할 수 있는 추상적인 형태의 스택 기반 해석 머신이라고 한다.
어떤 딱 정해진 하나의 JVM이 있는게 아니라,
오라클에서 정해 놓은 스펙에만 맞으면 나머지 부분은 어떻게 구현해도 된다는 것이다!
(스택 기반이라는 말의 의미는 뒤에서 알아보자.)
오히려 오라클에선 창작자의 상상력을 위해 구체적인 구현을 제시하지 않았다고 한다.
이게 얼마나 자유로운 상황인 것이냐면..
꼭 컴파일 이전 언어가 java 언어가 아니여도 상관 없다. Kotlin도 JVM 위에서 동작하지 않는가? 그저 바이트 코드를 해석해낼 수만 있으면 어떤 형태여도 상관 없다. 심지어 하드웨어여도 상관 없다고 한다.
스펙에는 JVM의 JIT 컴파일러나, GC에 관한 스펙도 상세히 기술되어 있지 않다고 한다.
그러다보니, 다양한 회사에서 여러 구현체를 만들었고, OpenJDK, 오라클 자바, 줄루 등 구현체가 있다.
기본적으로 JVM이 제공해야 하는 가장 중요한 서비스는 코드 실행과 메모리 관리이다.
JVM은 런타임시 한줄 한줄 코드를 읽어내는 해석기 "인터프리터"로 바이트 코드를 실행한다.
이는 기존에 한번에 컴파일 방식인 AOT 컴파일 방식에 비해 느릴 수 밖에 없다. (우리가 아는 기존의 C와 같은 언어에서의 컴파일 방식)
AOT 컴파일 방식은 한번의 컴파일로 전체 소스를 기계어로 바꾼 다음 그걸 계속 사용하는데,
인터프리터는 한줄 한줄 바이트 코드를 기계어로 해석해준다.
한줄 한줄 해석하는 과정이 느린 문제를 해결하기 위해 반복되는 코드들을 컴파일하는 JIT 컴파일러를 도입하기도 했는데, 더 자세한 사항은 뒤에서 언급하겠다.
2. Java의 컴파일
JAVA에서의 '컴파일'이란 일반적으로 자바 언어를
JVM이 이해할 수 있는 바이트 코드로 변환하는 과정을 가리킨다.
다른 언어의 경우 프로그래밍 언어로 작성한 코드를 기계가 이해할 수 있도록
어셈블리 언어와 기계어 코드로 변환시키는 것을 컴파일이라고 부른다.
나만의 생각이지만 JVM 자체도 일종의 '머신' 즉, '기계'이므로,
JVM이 이해할 수 있는 코드로 바꾸는 과정을 컴파일이라고 부르는 것 같다.
기존 언어에서의 컴파일과 Java에서의 컴파일은 똑같이 변환 과정에서 코드 최적화를 진행한다.
그리고 기존 언어에서의 컴파일과 Java에서의 컴파일의 다른 점은 링크 단계가 없다는 점이다.
보통은 컴파일 과정에서 링커가 기계어 코드와 라이브러리들을 엮어 내는 링크 과정이 필요하다.
단순히 작성된 코드들 뿐만 아니라,
코드들이 사용한 라이브러리들도 프로그램에 포함 되어야 이용할 수 있지 않겠는가?
이러한 과정을 링크라고 부른다.
Java에서의 컴파일은 굳이 링크를 진행하지 않는데,
배짱을 부리는 것이 아니라, 어차피 링크 과정이 JVM 내에서 일어나므로,
Java에서의 컴파일시엔 링크 과정을 뺀 것이다.
2.1 컴파일 과정
컴파일 과정은 단순하다.
'.java' 의 확장명을 가진 자바 소스 파일은
JDK에 속한 javac 컴파일러에 의해 `.class` 확장명을 가진 바이트 코드로 컴파일 된다. (java + compiler = javac)
컴파일은 세부적으로 아래와 같은 3가지 과정을 거친다.
1. Lexical Analysis : 어휘 분석
2. Syntax Analysis : 구문 분석
3. Symantic Analysis : 의미 분석
PL 수업시간에 열심히 배운 내용들이다.
처음엔 Lexical Analyzer에 의해 모든 어휘소가 분석된다.
이후 파서 (Syntax Analyzer)에 의해 파스 트리가 생성된다. 이 Syntax 분석 과정에서 문법상 오류가 발견된다.
마지막으로 Symantic Analysis 과정에서 타입을 검사하고, 타입 변환 등을 수행한다.
타입 관련 오류는 이때 발견된다.
이런 과정을 열심히 거치면 중간 코드인 불리는 바이트 코드가 완성된다.
2.2 중간 코드는 왜 필요한걸까?
이런 중간 코드를 만드는 이유는, JVM이 각 OS나 CPU에 맞게 기계어로 처리를 해주는 과정을 좀 더 쉽게 수행하기 위해서이다.
JVM은 중간 언어를 받고, 최종적으로 중간 언어를 각 플랫폼에 맞는 적젏나 기계어로 바꿔주는 전략이,
소스 파일을 처음 부터 각 OS나 CPU에 맞는 기계어로 바꾸는 것보다 유리하기 때문이다.
아래 그림으로 이해해보자.
상급 언어를 저급 언어로 변환하는 과정에서 코드를 좀 더 효율적으로 실행할 수 있도록 최적화 하는 optimizer가 필요하다. (Java 언어를 바이트 코드로 컴파일 할때는 거의 최적화가 이루어 지지 않아, 해석하기 쉽다.)
이러한 옵티마이저와, 코드 생성기가 상황의 갯수 만큼 필요하다!
예를 들어 A, B, C, D 4개의 언어가 1, 2, 3으로 3개의의 CPU 혹은 OS에 컴파일 할 때,
각 상황에 맞는 옵티마이저와, 코드 제너레이터가 필요하므로, 총 4 * 3개씩 필요하다!
(A1,B1, ... , C3, D3 -> 12개)
하지만 오른쪽 그림처럼 중간 언어가 있다면, 좀 더 적은 갯수의 옵티마이저와 코드 생성기가 있어도 문제 없다.
언어들으 단지 자신을 중간 코드로 바꾸는 방법만 알면 되고, 중간코드에서 각 플랫폼으로 바꾸는 방법만 JVM이 알면 된다.
바이트코드로의 변환은 모두 목적지가 같고, 중간 언어에서 각 타깃에 맞게만 바꿔 주면 되기 때문이다.
언어 수준에서 힘들게 여러 타겟의 기계어에 대한 고려를 하지 않아도 된다.
이런 바이트 코드를 실제로 실행시키는 과정은 어떻게 될까?
이 부분을 알기 위해선 JDK와 JRE, 그리고 JVM을 구성하는 메모리 영역에 대해 이해해야 한다.
3. JDK와 JRE
JDK는 Java Development Kit의 약자로, 자바 개발 환경을 의미한다.
자바 프로그램을 개발을 위한 '도구'들을 가지고 있다.
이렇게 말하니 사실 와닿지 않는다.
우리가 컴파일 할때는 어떤 명령어를 쓸까? 컴파일시 사용하는 명령어 - "도구" javac가 JDK에 속해 있다!
그 외에도 위 그림과 같이 역 어셈블러 디버거, 의존관계 분석 툴 등이 존재한다.
예시를 봐도 엄청 와닿지는 않는다.
그리고 내부적으로 자바 실행 환경인 JRE를 가지고 있다.
JRE는 자바 실행 환경으로, 자바 실행 명령과
바이트 코드 실행에 필요한 다양한 클래스 라이브러리를 제공해 준다.
java lang, util 과 같은 런타임 패키지를 가지고 있다.
또한 클래스 라이브러리도 이곳에 위치하고 있다.
그리고 JVM은 실질적으로 기계에 닿기 위한 인터프리터와 JIT 컴파일러, 링커 등을 가지고 있다.
1. 컴파일은 JDK의 javac 명령어를 통해 가능하다.
2. 컴파일된 바이트 코드는 JRE의 java 명령어를 통해 실행 가능하다.
3. 그러면 JVM이 돌아가게 되고, 바이트 코드들이 실제로 수행되기 시작한다.
이런 JDK와 JRE는 OS나 CPU에 독립적이다.
종속적인 것은 플랫폼에 맞게 중간 언어를 해석하는 JVM 뿐이다.
4. 바이트 코드는 어떻게 실행할 수 있을까?
바이트 코드를 실행하는 방법에 대해 좀 더 자세히 알아보자.
Java App을 실행시키는 방법은 다음과 같다.
1. java 명령어를 입력한다.
2. 그러면 JRE가 시작되고,
3. 지정된 클래스를 로드하고,
4. 해당 클래스의 main() 메서드를 호출한다.
여기서 '지정된' 클래스 라는건 java 명령어를 입력할 때 지정된 클래스를 말한다.
실행시킬 클래스를 딱 지정하고, java명령어를 입력하면 내부의 main() 메서드를 실행시킨다는 것인데,
그 main() 메서드가 가져야 할 규칙은 아래와 같다.
The method must be declared 'public' and 'static',
it must not return any value,
and it must accept a String array as a parameter.
1. public static으로 선언 되어야 한다.
2. 리턴 값이 없어야 한다.
3. String Array를 파라미터로 받아야 한다.
별거 없다 그냥 아래와 같이 자주 보는 형태면 된다.
이런 형태의 main 메서드를 가진 클래스 파일을 실행항 때,
구체적인 명령어의 예시는 아래와 같다.
첫 번째는 클래스 파일로, 두 번째는 JAR 파일로 시작하는 예시이다.
다양한 방식으로 앱을 실행할 수 있는데,
나는 서버에서 실제로 앱을 실행할 때 주로 JAR 파일을 통해 실행했다.
JAR 파일은 java 앱을 효율적으로 배포할 수 있는 수단으로,
자바 앱을 구성하는 클래스나 리소스들을 단 하나의 파일로 압축한 형태이다.
실제로 ZIP 파일 포멧 압축 파일의 일종으로,
JDK 'jar' 명령어로 압축해 만들거나 압축 해제할 수 있다.
(JRE java.util.zip 패키지를 통해서도 읽고 쓸 수 있다.)
내부적으로 설정 파일인 MAIFEST 파일을 가지고 있는데
이런 매니패스트 파일에 JAR를 실행시키기 위한 메인 클래스 위치인 엔트리 포인트나
패키지 관련 데이터 등의 앱을 위한 메타 데이터들이 포함되어 있다.
이런 매니패스트 파일을 읽고 시작점이 되는 메인 클래스를 찾아내는 것이다.
JRE 실행
java 명령어를 입력하여 JRE가 실행되면,
JVM이 실행되고, 클래스 로더에 의해 main 메서드가 포함된 클래스를 로딩해서
JVM이 할당한 메모리의 메서드 영역에 올린다.
이러한 최초 실행 클래스를 initial calss라고 부른다.
이후 다른 언어의 컴파일 과정에서 발생하는 Link 과정이 여기서도 일어나게 된다.
그 다음 static method 등 initialization method를 실행한다. (여기서는 main 메서드가 static)
이런 과정을 Initialize라고 한다.
그래서 Loading -> Link -> Initialize 순서로 진행된다.
5. JVM의 구조
JVM의 구조는 아래와 같다.
크게 3 부분으로 나누어 보면
1. Class loader subsystem
2. Runtime Data Area (JVM Memory라고 부르는 부분이 이곳이다.)
3. Execution Engine
이렇게 세 부분으로 생각할 수 있다.
스펙상엔 없지만, JVM 구현체들은 Garbage Collector 또한 하나의 구성요소로 여기곤 한다.
일단 대략적인 설명을 하겠다.
1. 클래스 로더 시스템은 "로딩"을 맡고 있다.
JVM 내로 바이트 코드화된 .class 확장자 파일들을 로드하고,
링크를 통해 엮어 Runtime Data Area에 배치해 주는 시스템이다.
한꺼번에 이루어 지지 않고, 어플리케이션에서 필요한 경우에 동적으로 이루어진다.
2. Runtime Data Area는 JVM 메모리라고도 부르는데,
JVM이 구동되기 위한 데이터들이 보관되는 장소이다.
3. 그리고 Execution Engine은 내부적으로 인터프리터나 JIT 컴파일러가 포함되어
바이트 코드를 기계어로 변환하는 역할을 해준다.
5.1 Rumtime Data Area
JVM의 메모리는 다양한 구성요소로 이루어져 있다.
1. Method Area
2. Heap
3. Java Stacks
4. PC Register
5. Native Method Stack
OS의 프로세스 메모리 구조를 조금 떠올리면 좋을것 같다.
OS 프로세스 메모리 구조와 같이 하나의 Heap 영역을 공유하고,
각 쓰레드 단위로 Java Stack과 Native Method Stack등의 Stack을 배정받고,
쓰레드별 실행 상황을 저장할 PC Registar을 배정 받는다.
Method Area도 Heap 영역과 같이 쓰레드들이 공유해서 사용한다.
이 안엔 각 Class별로 Runtime Constant Pool이 있다.
모든 쓰레드들이 공유하는 Heap 영역과 Method 영역은 JVM이 시작될 때 하나씩 생성되고,
종료될 떄 함께 소멸한다.
5.1.1 Method Area
Method Area는 Class레벨의 모든 데이터가 저장되는 곳으로, 클래스들의 정보가 저장된다.
(클래스들의 놀이터 - 스태틱 영역)
Method Area는 클래스의 정보와 static으로 선언된 변수들을 포함한다.
우리가 따로 인스턴스를 만들지 않고도 static method를 호출할 수 있는 것은 전부 이 Method Area를 공유하기 때문이다.
클래스별로 저장되는 정보는 아래와 같다.
1. Runtime Constant Pool : constant 자료형들이 저장된다
2. 필드 관련 정보들 (이름, 타입, 접근 제어자)
3. 메서도 관련 정보들 (이름, 리턴 타입, 매개변수, 접근 제어자)
4. 타입 정보 (클래스가 class인지 interface인지, 생성자 정보, Super Class 이름)
5.1.2 Heap Area
Heap 영역은 Java 앱의 인스턴스들과 배열이 보관되는 곳으로 Runtime Data들이 보관된다.
JVM이 시작될 때 생성되며, GC 전략에 따라 애플리케이션이 실행되는 동안 크기가 변한다. (명령어로 크기 지정 가능)
우리가 객체 인스턴스를 만들면 Heap 영역에 저장되고, 래퍼런스 타입 변수로 이 메모리를 가리킨다.
모든 스레드들이 Heap 영역의 인스턴스들에 접근 가능해야 하므로, Heap 영역은 모든 JVM 스레드들이 공유한다. (그림 참조)
사용하지 않는 인스턴스가 차지한 공간은 할당 해제되어야 하므로, Heap 영역은 가비지 컬렉터의 가비지 수거 대상이다.
가비지 컬렉터는 이 영역에서 쓸모 없어진 객체들을 판별해 메모리 할당을 회수해간다.
5.1.3 Stack Area, PC Register
스레드가 생성되면 Java Stack(JVM Stack), Native Method Stack, PC Register가 생성된다.
PC Register는 현재 실행중인 스레드의 명령어의 위치가 저장되는 우리가 아는 그 레지스터이다.
Method Area를 클래스의 놀이터라고 했는데, 이름은 Stack 영역인 JVM Stack 영역이 메서드들의 놀이터이다.
이 Stack 영역에 메서드들의 수행 정보가 'Stack Frame'이라는 단위로 쌓이게 되는데, 위 그림에서 처럼 스레드별로 각각의 Stack Fream을 갖는다.
로컬 변수들과, 메서드 내의 연산을 위한 오퍼랜드 스택, 클래스의 런타임 상수 풀에 대한 참조 등이 저장된다.
메서드가 수행되며 '여는' 중괄호를 만들 때마다 스택 안에 쌓이게 된다.
(재귀 함수를 잘못 짜서 스택 오버플로우가 나는 경우가 바로 이 스택 영역이 꽉차게 되는 것이다.)
말로만 들으면 헷갈리니 그림으로 살펴보자.
예를 들어 main 함수가 진행되다가, if문 중괄호를 만났다고 해보자.
한 중괄호 안에서의 중괄호에 대한 프레임은 위 그림의 왼쪽 하단과 같이 내부적으로 만들어진다.
그리고 메인 메서드 안에서 fun() 이라는 메서드를 호출하면 위와 같은 모습이 된다.
따로 새로운 스택 프레임을 할당해준다.
그리고 호출이 종료되면 사라지는 것이다.
꽤나 직관적이다.
왜냐하면 한 메서드 안에서의 중괄호는 그 메서드의 변수들을 공유한다.
하지만 내부에서 다른 메서드를 호출하면,
그 메서드와는 변수들이나 연산 과정을 따로 공유하지 않는 것이다 당연하다.
Native Method Stack은 앞서 언급한 것과 같은 자바가 아닌 다른 언어로 쓰인
네이티브 메서드들이 사용하기 위한 스택이다. (JVM 스펙상 필수는 아니다.)
5.1.4 스레드와 메모리 영역
5.2 Class Loader Subsystem
클래스 로더는 이름 그대로 클래스 파일을 읽어
Runtime Data Area에 배치하는 역할을 한다.
이런 과정을 로딩이라고 부르는데,
클래스 로드 과정은 세부적으로 Loading -> Link -> Initialize 과정으로 나뉘게 된다.
로딩에는 두 가지 방법이 있는데,
Loadtime Dynamic Loading과
Runtime Dynamic Loading이 있다.
Loadtime Dynamic Loading은
하나의 클래스를 Loading하는 과정에서 필요한 Class들을 로딩하는 방식이다.
import가 되어 사용되는 등의 이유로 다른 클래스들을 함께 끌고 오는 방식이다.
로드타임 동적 로딩에 대한 예를 들어 보겠다.
부트스트랩 클래스 로더라는, JRE 조성 과정에서 가장 처음 호출되는 클래스 로더가 생성된 후
모든 클래스가 상속 받고 있는 대장님인 Object 클래스를 읽어오게 된다.
그리고 클래스 로더가 시작점이 되는 클래스를 로딩하는데,
그 클래스가 아래와 같이 생겼다고 가정해보자
public class JinhoApplication {
public static void main(String[] args) {
System.out.println("아니 나도 잡혀왔어");
}
}
자연스럽게 JinhoApplication.class 파일이 읽히면서
java.lang.String, java.langSystem 또한 로딩되는 것이다.
Runtime Dynamic Loading은 이름 그대로
객체를 참조하는 순간에 동적으로 클래스를 로딩하는 방식이다.
java 명령어에 의해 JRE가 조성될 때의 클래스 로딩을 살펴보자.
java 명령어가 실행되면 JRE 가 조성되며 부트스트랩 클래스 로더가 실행된다.
이 부트스트랩 클래스 로더는 플랫폼 클래스 로더를 로딩하고,
플랫폼 클래스 로더는 시스템 클래스 로더를 로딩하고,
시스템 클래스 로더가 시작 클래스를 로딩하면서 JVM의 클래스들이 로딩된다.
위 세가지 클래스 로더를 built-in 클래스 로더라고 한다.
JRE 조성 -> 부트스트랩 클래스 로더 -> 플랫폼 클래스 로더 -> 시스템 클래스 로더 -> 최초 클래스 로딩
이후 앞서 언급한 링크와 초기화 과정을 거치면 클래스 로딩 과정이 끝나게 된다.
(내용 보충 예정)
5.3 Execution Engine
위 그림은 핫스팟 JVM의 구현 모습이다.
Execution Engine은 내부적으로 인터프리터, JIT Compiler를 포함하고 있고,
GC도 이곳에 구현된다.
인터프리터는 바이트 코드를 해석하면서 실행하는 역할을 수행한다.
다른 언어에서의 기계어 변환이 이때 이루어지는데,
컴파일과 달리 한번에 변환되는 것이 아니라,
한줄 한줄 읽게 된다.
그러다 보니 컴파일 과정 보다 느릴 수 밖에 없다.
그런 느린 변환 과정을 위해 도입된 것이 JIT 컴파일러다.
JIT 컴파일러는 그때 그때 컴파일 하자는 의미의 Just In Time이라는 뜻이다.
"프로파일러"에 의해 반복되는 코드나 최척화 할만한 요소가 발견되면, 전체 바이트 코드를 컴파일하고
컴파일된 코드를 Native Code로 변경해 사용한다.
그리고 이미 기계어로 변경한 소스 코드는 저장소에서 가져다 쓴다.
반복되어 참조되는 부분을 위한 '캐싱'이다
이렇게 캐싱된 데이터를 활용한 부분은 따로 인터프리터가 읽어낼 필요가 없다.
5.4 JIT 컴파일러와 AOT 컴파일러
AOT 컴파일은 ahead-of-time compile로 우리가 잘 아는 기존의 "미리 컴파일" 하여 실행코드를 확보하는 방식이다.
반대로 JIT 컴파일은 런타임에 중간 언어를 기계어로 바꿔준다. 상용 JVM들은 대체로 JIT을 도입했다.
(물론 옵션으로 AOT 선택이 가능하긴 하다.)
런타임 실행 정보를 반영해서
자주 쓰이는 부분과 최적화할 부분을 판단하는 '프로파일'을 만들어 결정을 내리는 것이다.
이런 기법을 프로파일 기반 최적화 PGO라고 한다. (Profile-Guided Optimization)
이런 프로파일링을 애플리케이션을 수행할 때마다 시행하기 때문에,
그 과정에서 JVM Warm Up 문제가 발생하기도 한다.
(If 카카오 JVM Warm Up - https://www.youtube.com/watch?v=utjn-cDSiog)
그렇다고 프로파일링 결과를 미리 저장해뒀다가
다시 사용하는 경우, 아예 처음 부터 다시 계산하는 것 보다도 성능이 낮다고 한다.
그래서 JVM 구현에 따라, 매번 새로 계산하도록 하기 위해 프로파일링 결과는 JVM이 꺼질 때 폐기된다.
이런 프로파일 기반 최적화 방식은 AOT가 수행하는 최적화 보다 결과물이 좋다고 한다.
최적화 과정에서 런타임 정보를 반영하기 때문이다. 이것이 미리 컴파일한 결과를 사용하지 않고, 그때 그때 컴파일 하는 이유이기도 할 것이다.
이제 부터 설명되는 JIT 컴파일러에 대한 내용은 오라클의 HotSpot JVM의 JIT에 관한 설명이다.
Hotspot에서 바이트 코드가 네이티브 코드로 컴파일 되는 단위는 메서드 단위이다.
따라서, 한 메서드를 구성하는 바이트 코드는 한번에 네이티브 코드로 컴파일 된다.
이런 프로파일링을 수행하는 JIT 컴파일러 내부의 '프로파일링 스레드'는 컴파일할 메서드를 탐색한다.
한번에 컴파일 되는 단위가 메서드이므로, 적절한 대상 메서드를 찾는 것이다.
이런 컴파일 대상 메서드는 호출 횟수로 결정한다.
특정 호출 횟수를 넘어가면 그 사실을 VM의 귀까지 들어가게 되고,
메서드를 '컴파일 큐'에 넣는다.
자비로운 JIT 컴파일러는 컴파일할 가치가 있을 만큼 자주 호출되지 않는 대상이더라도,
컴파일 하기 적합한 메서드인 경우 그냥 컴파일 해준다.
이런 기법을 On-Stack Replacement (OSR)이라고 부른다.
이런 저런 기준으로 컴파일될 메서드에 당첨되면,
메서드는 실제 컴파일을 수행하는 '컴파일러 스레드'에 올라가, 백그라운드에서 컴파일 된다.
인터프리티드 단계에서 수집된 프로파일링 정보가 이때 사용되는데,
컴파일 과정에서 프로파일링 정보를 이용해 최적화 로직을 적용한다.
이렇게 컴파일된 코드는 '코드 캐시'영역에 저장된다.
저장된 코드는 아래 3가지 경우에 캐시에서 제거된다.
1. JVM이 추측한대로 최적화를 진행했지만, 실제로 그렇게 실행되지 않는 경우
2. 단계별 컴파일 기능으로 인해, 컴파일 형식이 바뀌는 경우
3. 메서드를 가진 클래스가 언로딩 될 때
코드 캐시가 꽉차는건 무서운 일이다.
코드 캐시는 최대 크기가 정해진 캐시로, 꽉차게 되면 JIT 컴파일러 없이 인터프리터로 해석해야 한다.
네이티브 코드들은 '블록'단위로 저장되는데,
OS에서 발생하는 단편화 문제가 발생할 수 있다.
마무리
이러한 JVM의 여러 구조들이 서로 협업해준 덕분에
편하게 코드를 작성하기만 하면 빠르게 어떤 CPU, OS에도 호환되는 기계어를 얻어낼 수 있었다.
물론 모든 상황에서 완벽하게 빠르고 효율적인 것은 아니라, GC나 JIT 컴파일의 방식에 따라 성능을 튜닝하거나 개선할 수 있다. JVM을 아는 것은 이런 측면에서 중요하다.
JVM을 알아야 GC와 JIT을 알 수 있고,
이 둘을 알아야 성능 분석, 모니터링이나 개선을 할 수 있다.
또 컴퓨터 공학 전반의 지식들이 얼마나 중요한지도 얼핏 알게 되었다.
분명 아무것도 모르는 시절엔 간단한 JVM 설명도 이해하기 힘들었다.
메모리가 어쩌니 컴파일이니 스레드니.. 온통 어려운 용어 투성이였다.
복수전공을 해나가고, 나름 복습도 몇 번 한 뒤에 다시 공부하니 조금 다른 것들이 보인다.
물론 아직도 모두 이해할 수 있는건 아니다.
하지만 지금의 나는 더 많은걸 이해할 수 있었던것 처럼,
나중의 나는 조금 더 이해할 수 있지 않을까.. 하는 기대를 해본다.
Reference
- 자바 최적화 <벤저민 J. 에번스, 제임스 고프, 크리스 뉴랜드 저>
- 스프링 입문을 위한 자바 객체 지향의 원리와 이해 <김종민 저>
- [Backend] JVM warm up / if(kakao)dev2022]
'🌱 Java & Spring 🌱' 카테고리의 다른 글
자바의 Type에 대해 (0) | 2023.06.15 |
---|---|
Lambda & Stream의 도입 배경과 원리, 최적화 전략! 알고 쓰자!!! (4) | 2023.06.02 |
Bucket4J 사용하는 법 자세히 알랴드림 (0) | 2023.03.20 |
[Spring] Template Callback Pattern in Spring (0) | 2022.10.14 |
[Error] Java 실행 명령어와 cannot find symbol 에러 (0) | 2022.06.27 |