이번 게시글에서는 JVM을 공부하며 흥미롭게 접한 내용을 정리하고자 한다. 흔히 볼 수 있는 내용보다는 흥미로운 사실들만 모아 작성했으므로, 다소 가독성이 떨어질 수 있다.
JVM의 목표는 명확하다. 플랫폼 독립을 보장하는 것이다.
어떤 OS든 JVM(Backend)을 설치하기만 하면, Java compiler(Frontend)를 통해 변환된 class 파일을 실행할 수 있도록 해준다.
왜 이런 목표를?
Java가 처음 등장할 당시, C와 C++은 각각 하드웨어별 컴파일러가 필요했다. 이를 해결하고자 JVM은 운영체제와 무관하게 동작하도록 설계되었다.
굳이?
컴파일된 Java 프로그램은 JVM만 설치되어 있으면 어떤 환경에서든 실행할 수 있다. 예를 들어, 웹브라우저에 JVM이 설치되어 있다면 바이트코드를 넘겨 JavaScript처럼 실행할 수도 있다. 이처럼 활용 가능성이 무궁무진하다.
JavaScript와 관련이 있나?
관련은 없다. JavaScript가 등장할 당시 Java의 인기가 높아 이름을 비슷하게 지었다는 가설이 있을 정도다.
위 사진에서 Frontend에 해당하는 컴파일러에 대해 알아보자.
기본적으로 Parser와 Semantic Analyzer로 구성되어 있다.
파서의 주요 목표는 프로그램의 구조를 검사하여 문법적으로 올바른지 확인하는 것이다.
문법이 올바르면 파서는 구문 트리(syntax tree)를 생성한다. 파서는 문자열을 토큰화하여 구조만 인식할 뿐 각 요소의 의미는 판단하지 않는다.
프로그램의 의미적 측면을 분석한다. 즉, 구문 트리에 타입 정보가 추가되어 더욱 구체화된 트리가 생성된다. 참고로, Lombok은 이 트리를 기반으로 필요한 코드를 삽입한다.
자바는 동적으로 클래스를 읽어와 실행 중에 JVM과 연결하는데, 이를 수행하는 역할이 클래스 로더이다. 클래스 로더는 클래스 파일을 JVM의 메모리 영역(Runtime Data Area)으로 적재한다.
.class 파일을 가져와 JVM 메모리에 로드한다.
ClassNotFoundException?
Bootstrap ClassLoader → Extension ClassLoader → Application ClassLoader 순으로 클래스를 검색하고, 찾지 못하면 ClassNotFoundException이 발생한다. 이 예외는 Runtime Exception을 상속하지 않는 Checked Exception이다.
위임 구조로 설계된 이유는?
Spring의 ApplicationContext처럼 필요 시 대체가 가능하도록 설계된 것으로 보인다.
Verifying (검증)
.class 파일이 Java 및 JVM 명세에 부합하는지 검사한다. 컴파일 과정보다 엄격하게 수행된다.
Preparing (준비)
클래스에서 정의된 필드, 메서드에 필요한 메모리를 할당한다.
Resolving (분석)
상수 풀(Constant Pool)에 저장된 심볼릭 참조를 실제 객체 참조로 교체하는 단계이다. 참고로 이때 생성된 Constant Pool 테이블은 런타임 시점에 한 번 더 Runtime Constant Pool로 변환되며, 이때 문자열 리터럴은 별도로 String Constant Pool에 저장된다.
static 변수를 사용자가 선언한 값으로 초기화 및 static initializer 실행을 의미한다.
JVM이 운영체제로부터 할당받는 메모리 영역이다.
관계없다. 운영체제 위에 JVM이 띄어져있고 그 JVM 위에서 메모리 영역이 따로 생기는 것이라 매핑 되지 않는다.
Java 7까지 사용되던 Permanent 영역이 Java 8부터는 Metaspace로 대체되었다.
Java 7
Java 8
이유는?
PermGen은 JVM 메모리에서 관리되고 최대 크기가 제한되어 있었다. 예를 들어, Collection 객체를 static으로 유지하면 Perm 영역이 가득 차
java.lang.OutOfMemoryError: PermGen space
오류가 발생할 수 있었다. 이를 해결하기 위해 PermGen을 Metaspace로 대체하고, 클래스 메타 정보를 OS가 관리하는 Native 메모리로 이동했다. 참고로 메소드 영역은 Metaspace 안에 속한 영역이다.
문제가 없나?
Metaspace는 Native Memory를 사용하기 때문에 동일 서버의 다른 프로세스에 영향을 줄 수 있다. 특히, k8s 환경에서는 메모리 할당이 다른 Pod에 영향을 미칠 수 있다.
Stack은 프레임으로 구성되며, 각 프레임은 constant pool 참조, 지역 변수, operand stack을 포함한다.
Constant Pool 참조?
각 프레임은 클래스의 Runtime constant pool에 대한 참조를 가진다. 이는 Stack이 필요한 데이터를 가져다 쓰기 위한 것이다.
Operand Stack을 사용하는 이유?
JVM은 플랫폼 독립성을 위해 레지스터가 아닌 Operand Stack을 사용한다.
Native Method Stack은 자바가 아닌 네이티브 코드(C/C++)로 작성된 메서드를 위한 스택이다. 참고로 Frame을 push, pop하는 방식으로 동작한다.
실행 엔진는 바이트코드를 인터프리터또는 JIT 컴파일러로 기계가 실행할 수 있는 코드로 변환한다.
JIT Compiler는 인터프리터 방식으로 바이트 코드를 실행하다가 적절한 시점에 네이티브 코드로 컴파일하여 성능을 높인다.
JVM은 메서드 호출 횟수와 루프 횟수(컴파일 임계치)를 기반으로 JIT 컴파일 여부를 결정한다. 임계치를 초과하면 메서드는 큐에서 대기 후 컴파일된다.
Garbage Collector는 메모리를 자동으로 관리하여 Heap 영역의 인스턴스 중 더이상 사용되지 않는 객체에 할당된 메모리를 자동으로 해제하는 역할을 수행한다.
약한 세대 가설?
"대부분의 객체는 금방 접근 불가능한 상태가 되며, 오래된 객체가 젊은 객체를 참조하는 경우는 드물다"는 가설에 따라 GC가 최적화된다.
Root?
GC에서 Root는 메서드 영역, static 변수, stack, native method stack 등 Heap을 참조하는 요소이다.
ARC?
'ARC'는 Automatic Reference Counting의 준말이다. Java의 Mark-And-Sweep 방식의 GC와는 다르게, Swift에서 사용하는 방식이다. ARC는 객체가 참조되는 횟수(Reference Count)를 카운트하고, 참조 횟수가 0이 되면 메모리에서 해제하는 방식이다. 컴파일 시에 동작하며, 강한 순환 참조는 ARC에서 관리되지 않아 메모리 누수가 발생할 수 있다. Swift에서는 이를
weak
참조와unowned
참조로 해결하고 있다.
자바 애플리케이션이 네이티브 코드(예: C/C++ 언어)로 작성된 라이브러리를 호출할 수 있도록 지원하는 인터페이스이다. 이를 통해 시스템 고유 기능에 접근하거나 성능 최적화가 필요한 경우, 자바 코드로 재작성하지 않고도 기존의 Native Method Libraries를 활용할 수 있다.
요즘은 컨테이너 환경이 대세라서 JVM의 OS 독립성이 큰 장점으로 다가오지 않는 경우가 많다. 심지어 Docker의 buildx 기능을 통해 멀티 플랫폼 빌드를 지원하면서 하드웨어 종속성까지 해결하려는 움직임이 활발하다. 덕분에 예전과 달리, 운영체제와 하드웨어에 독립적인 애플리케이션 환경을 만드는 것이 더욱 수월해졌다.
이런 빠른 변화 속에서, Java 진영은 AoT 컴파일러을 통해 JVM 없이도 Java 애플리케이션을 실행하려는 시도도 많이 이루어지고 있다. 이렇게 시대의 흐름에 맞추어 발전하는 모습을 보면 그 노력이 놀랍고, 기술의 변화를 따라가려는 모습이 인상 깊다.
개인적으로는 객체지향에 미쳐있는 Java를 매우 좋아하고 응원하는 편이다. Java가 변화하는 시대 속에서도 도태되지 않고, 계속해서 발전하며 많은 사람들에게 사랑받기를 바란다.