JVM(Java Virtual Machine), JMM(Java Memory Model) 관련 글들을 읽다가 "논리적인 개념"과 그 개념을 구현한 "구현체"를 헷갈려했다. 이 글을 통해 JVM 아키텍처에 대해 제대로 정리해 보자.
JVM 명세
To implement the Java Virtual Machine correctly, you need only be able to read the class file format and correctly perform the operations specified therein. Implementation details that are not part of the Java Virtual Machine's specification would unnecessarily constrain the creativity of implementors.
Java Virtual Machine을 올바르게 구현하려면 클래스 파일 포맷을 읽고 여기에 지정된 작업을 올바르게 수행할 수 있어야 합니다. 구현 세부 사항은 Java Virtual Machine 명세의 일부가 아니며 구현자의 창의성을 불필요하게 제한합니다.
- The Structure of the Java Virtual Machine
위의 인용구는 Java SE 17 Edition의 The Java Virtual Machine Specification, Java SE 17 Edition을 참조했다.
내용을 보면 JVM 명세만 따르면 누구든지 JVM을 구현할 수 있다는 뜻이다.
이 글에서 사용되는 네이티브 언어는 C, C++ 같은 언어를 말하며, 네이티브 코드는 CPU가 바로 읽을 수 있는 어셈블리 코드로 이해하면 된다.
JVM 구조
JVM 구조는 복잡하다. 먼저 크게 네 부분으로 나누고, 각 부분에 대해 세부적으로 파악하자.
- Class Loader SubSystem: 클래스를 동적으로 로드하고, 로딩된 클래스를 메모리로 관리한다. 클래스를 로드하는 과정은 다시 세 부분으로 나눈다.
- Loading: `.class` 파일(바이트 코드)을 읽어 클래스의 메타정보를 메모리에 올린다. "메모리에 올린다"는 말은 Runtime Data Area 안에 있는 Method Area에 클래스의 정보를 저장한다는 의미다.
- Linking: Verify → Prepare → Resolve 단계를 순서대로 거쳐 실행을 준비한다. 세부 내용은 뒤에서 다룬다.
- Initialization: 클래스의 정적 초기화 블록(`static {}`)을 실행하며, 정적 필드(static field)를 사용자가 지정한 값으로 초기화한다.
- Runtime Data Area: JVM이 기동 될 때 OS로부터 할당받는 메모리 공간이다. 실행에 필요한 다양한 데이터를 담아두는 저장소 역할을 한다. 저장소 종류를 간략하게 표현하면 아래와 같으며, 자세한 내용은 뒤에서 다루니 간략하게 보고 넘어가 보자.
- Method Area: 클래스의 메타정보, Runtime Constant Pool, static Method, static Field 등이 저장되는 영역이다. JVM이 기동 될 때 생성되며, 스레드 간 공유되는 (논리적으로) Heap의 일부이다.
- Heap: JVM이 기동 될 때 생성되며, 모든 클래스의 인스턴스(객체)와 배열이 저장되는 영역이다. 이 영역 역시 스레드 간 공유되는 영역이며 GC(Garbage Collector)에 의해 관리된다.
- JVM Stack: 스레드가 생성될 때마다 함께 생성되어 스레드에 할당된다. JVM Stack은 메서드가 호출 때마다 Stack Frame(스택 프레임)을 push하고, 메서드가 종료되면 pop을 수행하는 것만 한다.
- PC Register: 모든 스레드는 자체적으로 Program Counter Register를 갖는데, 이 PC Register는 스레드의 현재 실행(current execution) 명령어의 주소를 저장한다. "현재 실행"이라는 의미도 뒤에서 다루겠다.
- Native Method Stack: 모든 스레드는 별도의 네이티브 스택(Native Stack)이 생성되는데, 네이티브 메서드 정보(C 언어 같은 다른 언어로 작성된 명령어)를 저장한다.
- Execution Engine: `.class`(바이트 코드)를 실행하는 핵심부다. 크게 세 부분으로 분류된다.
- Interpreter: 바이트 코드를 한 줄씩 해석하고 실행한다.
- JIT Compiler: Just-In-Time Compiler는 자주 사용되는(핫스팟) 바이트 코드를 네이티브 코드(Assembly Code)로 변환한다.
- GC(Garbage Collector): Runtime Data Area 안의 Heap 영역을 자동으로 관리한다. "관리한다"는 의미는 참조되지 않는 객체에 할당된 메모리를 회수하는 것을 말한다.
- Native Interface: 일반적으로 Java Native Interface(JNI)를 사용하며 JVM과 네이티브 언어(예를 들어 C, C++)를 연결하는 역할을 한다. Native Method LibrariesLibraries로부터 네이티브 언어로 된 함수들을 가져와 JVM에 의해 호출된다. JVM에 의해 호출되지만 네이티브 코드는 JVM의 제어를 벗어나 실행된다. OS의 파일 시스템 작업 등을 할 때 사용할 수 있다.
이제 각 영역에 대해 하나씩 자세하게 알아보자.
Class Loader SubSystem
Class Loader Subsystem은 세 부분으로 나뉜다.
- 클래스를 로드하는 과정인 Loading
- 로드된 클래스를 연결하는 과정인 Linking
- 연결된 클래스를 사용자가 정의한 값으로 초기화하는 Initialization
먼저, 세 가지 중 클래스를 로드하는 과정인 Loading에 대해 알아보자.
Loading
클래스를 로드하는데 핵심 역할을 하는 객체인 "클래스 로더"가 있다.
객체? 맞다. 클래스 로더는 JVM에서 살아있는 "객체"다.
그럼 클래스 로더는 어떻게 JVM 메모리에 존재하는 걸까? 닭이 먼저냐 계란이 먼저냐 같은 질문에는 답이 있었다.
태초에 "Bootstrap ClassLoader"가 있었다.
Bootstrap ClassLoader
명세에 따르면 모든 JVM 구현체엔 클래스를 로드할 수 있는 Bootstrap ClassLoader가 있어야 한다.
그리고 충격적이게도 Bootstrap ClassLoader는 Java 객체가 아니다. Java 언어가 아닌 C, C++ 같은 네이티브 언어로 구현되어 있다.
이 클래스 로더는 JVM이 기동될 때 생성되며, JVM 자체를 실행하는데 필요한 시스템 클래스 `$JAVA_HOME/lib` 디렉토리에 있는 핵심 Java API 클래스(예: java.lang.String 또는 java.util.*)들을 로드한다.
Platform ClassLoader
java.lang.ClassLoader의 인스턴스다. Java에서 기본적으로 제공하는 클래스(java.sql, java.xml)를 로드할 때 사용된다.
Extension ClassLoader는 뭐지?
먼 옛날 Java 9 이전엔 Extension ClassLoader가 존재했었다. 당시엔 비표준 라이브러리가 들어있는 `lib/ext` 디렉토리에서 JAR 파일을 로드했었다.
하지만 Java 9에서 모듈 시스템이 도입되며 Platform ClassLoader로 대체되었다. 더 이상 `lib/ext` 디렉토리는 사용되지 않으며 비표준 라이브러리들은 모듈 경로(Module Path)에 위치한다.
여기서 헷갈리지 말아야 할 부분이 있다.
JVM 명세에서는 클래스 로더에 대한 구현 세부 사항에 대해 언급하지 않았다.
다시 말해, JVM 구현체에 따라 다르다는 것을 인지해야 한다.
System(Application) ClassLoader
Platform ClassLoader와 마찬가지로 java.lang.ClassLoader의 인스턴스다.
System ClassLoader는 일반적으로 Applicaion ClassLoader를 지칭한다.
그리고 우리가 정의한 클래스들(com.example.MyClass)은 Applicaion ClassLoader에 의해 로드되며 로드 경로는 `java.class.path($CLASSPATH)`이다.
`ClassLoader.getSystemClassLoader()`로 반환되는 것은 Applicaion Class Loader이다.
`Thread.currentThread().getContextClassLoader()` 역시 Applicaion Class Loader를 가리킨다.
이제 클래스 로더가 뭔지 윤곽을 잡은 것 같다.
그럼 클래스가 로드되는 시점은 언제인가?
클래스가 로드되는 시점
JVM은 모든 클래스(.class)를 한 번에 로드하지 않는다.
다시 말해, 바이트 코드 명령어를 발견했을 때(`new` 키워드) 클래스를 찾아 로드하려고 시도한다.
public class A {
public static void main(String[] args) {
B b = new B();
}
}
위의 예시 코드에선 바이트 코드에서 `new B()` 명령어를 발견했을 때가 클래스를 로드하는 시점이 되고, 클래스 로더에 클래스를 로드하라고 요청한다.
클래스 로더의 계층 구조
클래스 로더가 언제 클래스를 로드하는지 알았다.
이번엔 클래스를 로드하는 방법에 대해 알아보기 전에 클래스 로더가 갖는 계층 구조를 먼저 알아두자.
클래스를 로드할 때 클래스의 계층 구조를 먼저 이해하지 않으면 헷갈릴 수 있기 때문이다.
클래스는 트리 형태의 계층 구조를 갖는다. 트리 구조는 루트 노드, 중간 노드(없을 수 있다), 리프 노드로 되어 있다.
트리 구조이기 때문에 한 노드는 자식 노드를 가질 수 있다.
이런 트리 구조에서 루트에 위치한 클래스 로더가 바로 Bootstrap ClassLoader이며, 자식으로 Platform ClassLoader를 가진다.
그리고 Platform ClassLoader는 다시 System(Application) ClassLoader를 자식으로 갖는다.
이 계층 구조에 특이한 점이 하나 더 있다.
상위 클래스 로더(예: Bootstrap ClassLoader)는 하위 클래스 로더(Platform ClassLoader 또는 System(Application) ClassLoader)의 클래스를 알 수 없다는 것이다. 이를 가시성 제한(visibility)이라 부른다.
클래스를 로드하는 방법
그럼 클래스 로더가 어떻게 클래스를 로드할까?
클래스 로더의 동작 방식은 위임 모델(Delegation Model)을 따른다.
동작 방식: 위임 모델
클래스 로더는 기본적으로 위임 모델(Delegation Model)을 따른다. 위임 모델을 따르는 이유는 클래스를 중복으로 로드하는 것을 방지하고, 보안성 확보를 위함이다. 그렇다면 중복으로 로드하는 것을 방지하고, 보안성을 확보하는 과정을 살펴보자.
위임 모델의 동작 원칙은 심플하게 아래와 같다.
- 가장 먼저 부모에게 물어본다.
- 부모가 못 찾으면 자신이 직접 찾는다.
이 원칙에 따라 클래스가 로드되는 과정도 단순하다.
- 가장 먼저 부모 클래스 로더에게 클래스가 있는지 물어본다.
- 부모 클래스 로더가 찾으면 메모리에 올린다.
부모 클래스 로더가 못 찾으면 다시 자신이 찾는다.
위임 모델의 핵심은 요청자가 "가장 먼저 부모에게 물어본다"이다. 트리 구조로 되어 있다면 루트부터 클래스를 탐색한다는 의미다.
따라서 클래스를 본격적으로 탐색하는 클래스 로더는 루트에 위치한 Bootstrap ClassLoader이다. (최상위 클래스 로더)
여기서 헷갈릴 수 있다. "탐색의 시작"은 루트에 있는 Bootstrap ClassLoader가 맞다. 하지만 "탐색의 시작"과 "요청의 시작"은 명확하게 구별된다. "요청의 시작"은 분명 최하위에 있는 System(Application) ClassLoader 이기 때문이다.
결론: 위임 모델을 따르면 항상 Bootstrap ClassLoader에서 클래스를 로드할 기회를 먼저 얻도록 한다.
따라서 중복 로드를 방지하며, 신뢰성 있는 표준 라이브러리를 먼저 로드하기 때문에 보안성을 확보한다. (표준 라이브러리와 같은 이름으로 클래스 이름을 지어도 소용없는 이유다.)
쉬운 이해를 위해 사용자가 작성한 클래스가 로드하는 상황을 가정했다.
- (요청의 시작) System(Application) ClassLoader → Platform ClassLoader:
JVM은 `.class` 파일의 바이트 코드에서 `new` 명령어를 만났다.
이제 System ClassLoader는 클래스를 로드해야 한다.
자신이 바로 찾지 않는다. 가장 먼저 부모 클래스 로더(Platform ClassLoader)에게 찾고자 하는 클래스가 있는지 물어본다. (위임) - Platform ClassLoader → Bootstrap ClassLoader:
Platform ClassLoader 역시 자신이 바로 찾지 않는다.
다시 부모 클래스 로더(Bootstrap ClassLoader)에게 찾고자 하는 클래스가 있는지 물어본다. (위임) - (클래스 탐색 시작) Bootstrap ClassLoader → Platform ClassLoader:
루트에 있는 Bootstrap ClassLoader에게 요청이 도달하고 나서 본격적으로 클래스를 찾는다.
만약 찾고자 하는 클래스를 못 찾았다면 Bootstrap ClassLoader는 다시 Platform ClassLoader에게 직접 찾으라고 한다. - Platform ClassLoader → System(Application) ClassLoader:
Platform Class Loader도 클래스를 못 찾았다.
하는 수 없이 System(Application) ClassLoader가 직접 찾아야 한다.
다행히도 찾고자 하는 클래스를 찾았다. 못 찾았다면 `ClassNotFoundException` 예외가 발생한다.
Linking
지금까지 클래스 로더가 `.class` 바이트 코드를 읽고 Method Area에 저장하는 과정을 알아봤다.
이제 Mehtod Area에 저장된 클래스를 JVM과 연결(Link)하는 과정인 Linking에 대해 알아보자.
Linking은 Verify → Prepare → Resolse 단계를 순서대로 거친다.
Verify
Linking 과정에서 가장 첫 번째로 동작하는 Verify는 읽어들인 `.class` 바이트 코드가 JVM 명세를 따르는지 검사하는 단계다.
예를 들어, 자바 파일을 컴파일 했을 때 첫 4바이트는 magic number가 `0xCAFEBABE`인지 체크한다.
수동으로 확인할 수도 있다. 대충 `Hello.java`를 작성한 뒤 `javac Hello.java` 명령어로 컴파일하면 클래스 파일(`Hello.class`)이 생성된다. 그 다음 `javap Hello.class` 명령어로 disassemble 한 뒤 `od -tx1 Hello.class` 명령어로 16진수로 확인하면 아래와 같이 `0xcafebabe`가 나오는 것을 확인할 수 있다.
이런 확인뿐만 아니라 메서드나 필드가 JVM 규칙에 맞는지 확인하고, 바이트 코드가 유효한지 검사 등을 거쳐 올바르지 않은 클래스를 실행해 시스템이 망가지는 것을 보호한다.
따라서 가장 까다롭고 복잡하며 시간이 오래 걸리는 작업이다.
Prepare
클래스와 관련된 정적(static) 필드들에 대해 메모리를 할당하고, 해당 필드들을 기본값으로 초기화한다.
기본값으로 초기화한다는 것은 명세에서 정하는 Primitive Type의 기본값으로 초기화한다는 의미다.
예를 들어, primitive 타입 중 int는 `0`으로 초기화되고, boolean은 `false`로, Object같은 reference 타입은 `null`로 초기화되는 것이다. 단, `static final` 키워드가 붙은 필드는 기본값으로도 초기화되지 않는다. (`static final` 키워드를 사용한 필드는 상수 풀에 저장된다.) 여기서 런타임 상수 풀과 심볼릭 레퍼런스에 대해 짚고 넘어가자.
Runtime Constant Pool(런타임 상수 풀)
런타임 상수 풀은 클래스 내의 상수 풀을 기반으로 클래스가 로드될 때 Prepare 단계에서 Method Area에 생성되는 불변(Immutable) 데이터 구조다.
저장되는 불변 데이터는 리터럴 상수(`static final String MESSAGE;`), 심볼릭 레퍼런스(다른 클래스, 메서드, 필드의 참조 정보)가 있다.
(참고로 상수 풀이 있기 때문에 클래스, 메서드, 필드 참조를 효율적으로 관리할 수 있다. (String 리터럴의 중복 저장을 방지하는 등))
Symbolic Reference(심볼릭 레퍼런스)
클래스, 메서드, 필드, 인터페이스 등을 문자열이나 기타 식별자로 참조하는 방식을 심볼릭 레퍼런스라고 한다.
메서드가 호출될 때 Linking의 Resolve 단계에서 심볼릭 레퍼런스를 통해 실제 메모리 주소로 변환되는데 사용된다.
다시 말하면, 심볼릭 레퍼런스는 컴파일 타임에 고정되는것이 아니라 런타임에 생성되고 참조되며, 이를 Dynamic Linking(동적 연결)이라 한다.
Prepare 단계 정리
- 정적 필드에 대해 메모리를 할당하고, 기본값으로 초기화한다.
- 클래스의 Runtime Constant Pool(런타임 상수 풀)을 Method Area에 생성한다.
primitive 타입과 reference 타입과는 달리 상수 풀에 저장되는 값들은 Initialization 이전에는 기본값으로도 초기화되지 않는다. 준비만 할 뿐이다. 초기화는 Resolve 단계까지 Linking을 성공적으로 마치고나서 Initialization 단계에서 초기화한다.
Resolve
해당 단계는 클래스(또는 인터페이스)를 참조할 때, 클래스의 메서드를 호출할 때, 클래스의 필드에 접근할 때 동작한다.
다시 말해, Prepare 단계가 끝났다고 바로 Resolve 단계가 동작하는 것이 아니다.
클래스가 로드되었다 해도 실제로 사용하지 않는 필드나 메서드 또는 참조되는 클래스가 있을 수 있기 때문에 필요한 시점에만 Resolve를 수행한다.
이 단계가 조건에 맞아 동작할 때는 이전 단계(Prepare)에서 Method Area에 생성해 놓았던 Runtime Constant Pool(런타임 상수 풀) 내의 심볼릭 레퍼런스를 실제 메모리 주소(직접 참조)로 변환한다.
(이때 착각하지 말아야할 것이 심볼릭 레퍼런스가 실제 메모리 주소로 변환된다는 것은 상수 값이 초기화되는 것과 전혀 상관없고, 말 그대로 참조(referenct)하기위해 실제 메모리 주소로 변환되는 것이다.)
이렇게 Verify → Prepare → Resolse 단계를 성공적으로 거치면 Method Area에 있는 클래스 Initialization 단계로 넘어가 실행될 준비가 완료된다.
Initialization
드디어 Class Loader SubSystem의 마지막 부분인 Initialization 단계이다.
눈치챘겠지만 Resolve 단계가 동적으로 동작하는 것처럼 Initialization 단계도 필요할 때만 동작한다.
(필요할 때란 `new` 키워드로 인스턴스를 생성하거나, 정적 필드(static field)에 접근하거나, 정적 메서드(static method)를 호출하거나, `Class.forName()`으로 클래스를 로드할 때 등을 말한다.)
이 단계는 Linking 과정을 성공적으로 마친 클래스를 사용자 정의 값으로 초기화하는 역할을 담당한다.
사용자 정의 값으로 초기화는 정적 초기화 블록(`static {}`)과 정적(static) 필드를 사용자가 정의한 값으로 초기화하는 것을 말한다.
이 단계에서 Runtime Constant Pool(런타임 상수 풀)의 리터럴 상수도 사용자가 지정한 값으로 초기화된다. (`static final String MESSAGE = "Hello";`)
(초기화된 리터럴 상수는 나중에 클래스의 인스턴스가 Heap Area에 할당될 때 Heap의 String Pool과도 연결되어 리터럴이 중복 저장되지 않도록 한다.)
이 초기화는 클래스가 처음으로 액세스될 때 호출되고, JVM은 해당 클래스가 초기화되었음을 표시한다.
Runtime Data Area
Runtime Data Area는 JVM이 런타임에 사용하는 데이터 저장소의 집합이다.
JVM 명세에는 6개의 영역으로 구분하는데, Runtime Constant Pool은 Method Area에 할당된다.
- Method Area (메서드 영역)
- Runtime Constant Pool (런타임 상수 풀)
- Heap Area (힙 영역)
- JVM Stack (JVM 스택 영역)
- PC Register
- Native Method Stack (네이티브 메서드 스택)
영역들 중 일부는 JVM이 기동될 때 생성되고, 다른 데이터 영역(Stack Frame)은 스레드의 생명주기와 함께한다.
JVM이 기동될 때 생성되는 영역은 Method Area, Heap Area, JVM Stack 세 영역이다.
각 영역들은 JVM 명세 상 논리적인 개념이고, JVM 명세는 구체적인 구현 방식을 언급하지 않는다.
Method Area
- JVM이 기동될 때 생성된다.
- 스레드 간 공유되는 영역이다.
- 클래스가 로드되면 해당 클래스를 이루는 구성 요소들이 저장되는 영역이다.
구성 요소는 아래와 같은 것들이 있다.
- Runtime Constant Pool(런타임 상수 풀)
- 클래스 이름, 부모 클래스, 인터페이스 등 클래스 구조 정보
- (생성자 포함)메서드, 필드 정보
- 정적 변수(static field)와 그 초기화된 값
- GC(Garbage Collector)에 의해 메모리 회수 대상이 되는 영역이다.
Method Area의 구현체 Metaspace
Java 7 이전까지는 Method Area는 Permanent Generation이라는 Heap Area에 구현되어 있었다.
Java 8 부터 Heap 영역에 있던 Permanent Generation 영역이 Metaspace 영역으로 대체되었다.
지금까지 설명했던 클래스의 메타정보(클래스 이름, 필드, 메서드, 상수 풀)가 Method Area에 저장된다고 했었는데, 저장되는 구현체가 바로 Metaspace이다.
대체된 Metaspace 영역은 Heap Area 바깥에서 OS가 제공하는 Native Memory(네이티브 메모리)에 존재한다. (Off-Heap, Non-Heap) 따라서 메모리를 유연하게 사용할 수 있게 되었지만, 반대로 말하면 시스템 전체가 다운되는 위험도 갖게되어 모니터링이 필요하게 된 것이다.
Runtime Constant Pool(런타임 상수 풀)
런타임 상수 풀은 Method Area에 자리잡고 있는 불변(Immutable) 데이터의 저장소다. 각 시점을 다시 한 번 정리해보자.
- 생성 시점: Linking의 Prepare 단계에서 Method Area에 처음 생성된다. 이때 기본값으로도 초기화되지 않은 리터럴 상수와 심볼릭 레퍼런스를 저장하고 있다.
- 참조 준비 시점: Resolve 단계가 동작할 때 참조하기 위해 심볼릭 레퍼런스가 실제 메모리 주소로 변환된다. (직접 참조 준비)
- 값이 초기화되는 시점: Initialization 단계에 진입하면 사용자가 지정한 값으로 초기화가 된다.
추가로 런타임 상수 풀도 Method Area의 일부이기 때문에 스레드 간 공유되는 영역이다.
Heap Area
- JVM이 기동될 때 생성된다.
- 스레드 간 공유되는 영역이다.
- 모든 클래스의 인스턴스, 배열의 메모리가 이곳 Heap에 할당된다.
- Heap은 JVM 구현과 GC(Garbage Collector)의 종류에 따라 제각각 다르게 구성될 수 있는 재량의 영역이다.
- GC(Garbage Collector)에 의해 메모리 회수 대상이 되는 영역이다.
JVM Stack
- 모든 스레드마다 고유한 JVM Stack 영역을 할당받는다.
- JVM Stack은 메서드가 호출될 때 Stack Frame(스택 프레임)을 push하고, 메서드가 종료되면 pop하는 역할만 수행하고 직접 조작되지 않는다.
Stack Frame(스택 프레임)
스택 프레임은 데이터와 결과를 저장하는 것뿐만 아니라 메서드 실행 결과를 반환하고, 예외(Exception)가 발생했을 때 하나의 스택 프레임을 보여주는데 사용된다.
- 생성과 제거: 메서드가 호출될 때마다 하나의 스택 프레임이 생성되고, 생성된 스택 프레임은 해당 스레드의 JVM Stack에 추가된다. 예외가 발생하든 안하든 메서드 실행이 종료되면 해당 스택 프레임은 JVM Stack에서 제거된다.
- 저장하는 데이터들: 스택 프레임은 아래 3가지 정보를 저장한다.
- Local Variable Array(지역 변수 배열): 인덱스가 0부터 시작하는 배열이다. 0번째 인덱스에는 메서드가 포함된 클래스의 인스턴스(this)의 참조가 들어있다. 인덱스 1번째부터는 메서드가 전달받은 매개 변수들과 메서드의 지역 변수들이 저장된다.
지역 변수 배열의 길이는 컴파일 타임에 결정되므로 고정적인 크기를 갖는다. - Operand Stack(오퍼랜드 스택; 피연산자 스택): 메서드가 작업하는 공간이다. 스택 프레임이 생성되는 처음에는 비어있다.
Local Variable Array(지역 변수 배열)나 필드에서 데이터를 가져와 연산하거나, 자신의 스택에서 피연산자를 가져와(pop) 연산을 수행한다. 연산한 결과는 다시 피연산자 스택에 추가된다.(push)
피연산자 스택의 깊이는 컴파일 타임에 결정된다. - Reference to Constant Pool(상수풀에 대한 참조): 현재 메서드를 갖는 클래스의 런타임 상수 풀에 대한 참조를 말한다. 위에서 설명한 Linking의 Prepare 단계에서 Dynamic Linking을 참조하자.
- Local Variable Array(지역 변수 배열): 인덱스가 0부터 시작하는 배열이다. 0번째 인덱스에는 메서드가 포함된 클래스의 인스턴스(this)의 참조가 들어있다. 인덱스 1번째부터는 메서드가 전달받은 매개 변수들과 메서드의 지역 변수들이 저장된다.
PC Register
Program Counter Register는 각 스레드마다 하나씩 존재한다.
스레드가 시작할 때 함께 생성되며, 현재 실행중인 JVM 명령어의 주소를 저장한다.
(만약 실행중인 메서드가 네이티브 메서드(예: C, C++로 작성된 함수)라면 저장되지 않는다.
Native Method Stack
Java가 아닌 언어(예: C, C++)로 작성된 함수를 Native Method(네이티브 메서드)라고 한다.
Native Method Stack은 이 네이티브 메서드를 위한 스택이다. 아래에서 살펴볼 JNI(Java Native Interface)에 의해 호출되면 이 영역에 스택이 생성된다.
Execution Engine
Loading → Linking → Initialization을 거친 바이트 코드는 실행 엔진에 의해 명령어 단위로 실행된다.
JVM에서 바이트 코드는 인간이 읽기 편한 형태이기 때문에 기계가 실행할 수 있는 형태로 변환해 실행해야 하는데, 실행 방식은 두 가지가 있다.
하나는 Interpreter 방식이고, 다른 하나는 JIT Compiler 방식이다.
Interpreter
바이트 코드를 한 줄씩 해석하고 실행한다.
동일한 메서드가 여러 번 호출되도 반복적으로 해석하고 실행한다는 단점이 있다.
JIT Compiler
Interpreter의 효율을 높이기 위해 사용된다.
이름 그대로 Just-In-Time(적시에) 컴파일을 수행하는데, JVM 명세에선 구현에 대한 언급은 없다.
대부분의 JVM 구현체가 적시를 판단하는 기준은 Profiler를 통해 호출되는 메서드의 빈도를 파악한다. 파악한 빈도가 임계값을 넘으면 Native Code(Assembly Code)로 컴파일한다. 이후 Interpreter가 실행하는 대신 JIT Compiler가 직접 Native Code를 실행해 빠르게 처리한다.
변환 과정은 위의 그림처럼 수행하는데, 바이트 코드를 중간 단계의 표현인 Intermediate Code로 변환하고, 최적화를 수행한 다음, Native Code로 변환한다.
Oracle Hotspot VM은 자주 사용되는 바이트 코드를 Hotspot으로 본다.
그리고 해당 Hotspot을 Native Code로 컴파일한다. 컴파일된 코드는 캐시되어 다음번에도 같은 메서드가 호출될 때 재사용해 빠르게 처리할 수 있게된다.
GC(Garbage collector)
Garbage collector(가비지 컬렉터)는 JVM이 효율적으로 메모리를 사용할 수 있도록 돕는 자동 메모리 관리 프로세스다.
가비지 컬렉터의 핵심 역할은 Heap Area에서 참조되지 않은 객체를 제거해 메모리를 회수하는 것인데, 회수하는 방법은 어떻게 구현하냐에 따라 다를 수 있다.
참조되지 않는 객체는 회수 대상이 되는 것은 알겠다. 그럼 참조되는 것과 참조되지 않는 것은 어떻게 구별할까?
Naver D2 - Java Reference와 GC 글을 참고해 정리했다.
Reachability (도달가능성)
참조되고 있음과 참조되지 않음을 판별하는 기준에 Reachability(도달 가능성) 개념을 사용한다.
유효한 참조가 있으면 reachable로 판단하고, 없으면 unreachable로 판단한다.
접근 가능성이 다시 유효성으로 설명된다. 그럼 유효성은 어떻게 판단되는가?
참조되는 객체는 다른 객체를 다시 참조할 수 있고 이런 참조들을 참조 사슬이라고 한다.
참조의 유효성을 판단하기 위해선 최초의 참조를 찾아야하는데, 이를 객체 참조의 root set이라 한다.
객체에 대한 참조의 종류는 4가지 중 하나다.
- Heap Area 내의 다른 객체가 참조하는 경우
- 스레드의 JVM Stack 내부에 있는 Stack Frame에서 참조하는 경우
- Native Method Stack에서 참조하는 경우
- Method Area의 static field에서 참조하는 경우
이 가운데 2, 3, 4번 세 종류를 통틀어 root set으로 본다.
위 그림을 보면 root set으로부터 참조 사슬이 연결된 객체들은 유효한 참조라고 보기 때문에 Reachable, 그렇지 않은 객체들은 Unreachable이다.
우하단의 Unreachable 객체가 Reachable 객체를 참조해도 root set으로부터 참조가 시작되지 않았기 때문에 Unreachable이다.
Reference
`java.lang.ref`패키지는 GC가 객체를 어떻게 처리할지 제어할 수 있는 4가지 Reference 객체를 제공한다.
- Strong Reference → 일반적인 객체 참조(`new` 키워드로 생성된 객체)
- SoftReference
- WeakReference
- PhantomReference
이 클래스들은 참조 대상 클래스를 캡슐화하는데 사용되며, 이 클래스들로 캡슐화되어 생성된 객체를 reference object라고 부른다.
캡슐화에 사용된 객체는 위의 이미지처럼 referent로 불린다.
WeakReference<Sample> wr = new WeakReference<Sample>(new Sample());
Sample ex = wr.get();
여기서 `new WeakReference()`로 생성된 객체는 reference object, `new Sample()`로 생성된 객체는 referent 이다.
Reference 도입 이후의 Reachability 상태
Java에서 제공하는 Reference(Soft, Weak, Phantom)로 감싼 객체들로 Reachability를 세분화해 GC 동작에 관여할 수 있게 됐다.
SoftReference든, WeakReference든, PhantomReference든 상관없이 root set과 직접 연결되어 있다면 해당 Reference 객체 자체는 strongly reachability다.
다만 해당 Reference 객체에 의해 참조되는 객체(referent)는 어떤 Reference 클래스에 의해 캡슐화 되었느냐에 따라 GC 여부가 달라진다.
세분화된 Reachability는 아래와 같다.
- strongly reachability (강하게 도달 가능)
- Strongly Reference(강한 참조)가 있는 객체
- GC가 절대 수집하지 않는다.
Object obj = new Object();
- softly reachability (소프트 도달 가능)
- Strongly Reference가 없으면서 SoftReference가 있을 때
- 메모리가 부족할 때 GC에 의해 수집될 수 있다.
SoftReference<Object> softRef = SoftReference<Object>(new Object());
Object obj = softRef.get();
- weak reachability (약하게 도달 가능)
- Strongly Reference나 SoftReference도 아닌 WeakReference만 있을 때
- GC가 동작할 때 수집 대상이 된다.
WeakReference<Object> weakRef = WeakReference(new Object());
Object obj = weakRef.get();
- phantom reachability (팬텀 도달 가능)
- 객체가 finalization을 마쳤고 PhantomReference만 있을 때
- 객체의 메모리는 아직 회수되지 않았지만 더 이상 접근은 불가능하다.
ReferenceQueue<Object> queue = new ReferenceQueue<>();
PhantomReference phantomRef = new PhantomReference<>(new Object(), queue);
- unreachability (도달 불가)
- 모든 참조가 끊어진 객체
- GC가 즉시 메모리를 회수한다.
ReferenceQueue와 PhantomReference
SoftReference, WeakReference 생성 시 생성자에 ReferenceQueue를 인자로 받으면 GC가 수집할 때 referent는 `null`이되지만 SoftReference 객체 자체는 ReferenceQueue에 추가된다. (GC가 자동으로 enqueue를 수행)
이후 ReferenceQueue의 `poll()`, `remove()`를 통해 reference object가 enqueue 되었는지 확인하면 referent가 GC에 수집되었는지 확인해 추가 작업을 할 수 있다. (Naver D2에서는 LRU 캐시 구현을 예시로 들었다.)
단, PhantomReference는 선택의 여지 없이 생성자에 반드시 ReferenceQueue를 인자로 받아야 한다.
PhantomReference는 referent를 `null`로 만들지 않고 phantom reachability로만 만든 다음 ReferenceQueue에 enqueue한다.
이는 referent가 finalization을 마치고 리소스 정리(예: 데이터베이스 커넥션 종료)같은 추가 작업을 처리할 수 있도록 하기 위함이다.
대신 명시적인 `clear()` 호출을 통해 referent를 `null`로 만들어야 메모리가 회수된다.
Reachability와 GC 순서
세분화된 Reachability에 따라 GC가 객체를 수집하는 순서는 항상 다음과 같다.
- SoftReference
- WeakReference
- finalize
- PhantomReference
- 메모리 회수
Native Interface(JNI)
Native Method Libraries와 상호작용을 통해 명령 실행에 필요한 Native Library(C, C++)를 제공하는 인터페이스다.
이를 통해 JVM은 C나 C++로 작성된 라이브러리를 호출해 하드웨어를 직접 조작하는 등의 작업을 할 수 있다.
Java Native Interface(JNI)는 Native Interface 역할을 하는 구현체이며, JNI에 의해 호출된 C/C++ 메서드를 호출하면 Native Method Stack에 스택이 생성된다.
Native Method Library
Native Method(C나 C++로 작성된 라이브러리)를 제공한다.
정리하며
JVM 구조를 알면 코드 한 줄을 작성하더라도 최적화를 잘 할 수 있지 않을까? 라는 생각에 JVM에 대한 복습이 이렇게 방대한 양의 글이 되었다. 구조를 깊이 안다고해서 당장 한 줄을 작성할 때 큰 도움이 될 것 같지는 않지만, 문제가 생겼을 때 적어도 JVM과 연관지어서 생각할 수 있는 시야를 얻게되었다고 생각한다.
10년도 더 전에 작성된 Naver D2 포스팅 댓글에는 2024년 11월 쯤 달린 이런 댓글이 있었다.
mysql-connenctor-j의 메모리 누수 이슈를 찾다 이 글을 발견했는데 PhantomReference를 이해하는데 많은 도움이되었습니다.
나 역시 해당 글로부터 JVM을 이해하는데 많은 도움을 받았다.
이 글도 누군가에게 대략적인 JVM 구조를 파악하는데 도움이 되었으면 좋겠다.
참고
- https://www.geeksforgeeks.org/jvm-works-jvm-architecture/
- https://docs.oracle.com/javase/specs/jvms/se17/html/index.html
- https://blogs.oracle.com/javamagazine/post/how-the-jvm-locates-loads-and-runs-libraries
- https://d2.naver.com/helloworld/1230
- https://jaemunbro.medium.com/java-metaspace에-대해-알아보자-ac363816d35e
- https://d2.naver.com/helloworld/329631
해당 글의 이미지는 모두 직접 만들었습니다. 출처만 남기시면 사용하실 수 있습니다.
'Java' 카테고리의 다른 글
Java Memory Model(JMM)과 동시성 규칙 (2) | 2025.01.22 |
---|---|
[Java] opencsv 로 CSV 읽고 저장하기 (0) | 2024.07.09 |
[Java] Optional에서 map과 flatMap의 차이점 쉽고 빠르게 이해하기 (0) | 2024.02.28 |