들어가며

예전에 남궁성 저자의 “자바의 정석” 책을 통해 공부하며 기본기를 다졌지만, 실무를 하다 보니 더 탄탄한 베이스가 필요함을 느꼈다. 처음 정독할 당시에는 책이 워낙에 두꺼웠기에 jvm, 스레드, 람다, 스트림, 네트워크 등 후반부 챕터를 빠르게 훑고 지나갔던 것이 아쉬움으로 남았다.

이번 기회에 다시 자바의 정석 책을 펼쳐보며 앞서 말한 챕터들과, 개인적으로 헷갈리거나 잊어버린 지식들에 대해서도 복습해보며 차근차근 포스팅을 해 볼 예정이다. 하지만 책에서는 자세하게 나와있지 않을 부분도 존재할 수 있어 각 키워드별로 공식문서도 참고할 생각이다.

JVM 이란?

JVM (Java Virtual Machine)이란 자바로 작성된 코드를 OS가 이해할 수 있는 기계어로 변환하고 실행해주는 가상머신이다. 자바 소스코드를 javac로 컴파일하고 바이트코드(.class) 로 만든 뒤, OS/CPU 별로 제공되는 JVM이 이를 인터프리트하거나 JIT컴파일로 네이티크 보드로 변환해 실행하도록 해준다.

다른 프로그래밍 언어의 경우 코드를 OS에 맞는 기계어로 직접 변환(컴파일러 -> OS) 하여 실행 속도가 빠르지만, OS마다 다른 실행 파일을 만들어야 하는 종속성이 있다.
반면 자바의 경우에는 OS 앞단에 JVM을 두어 컴파일러(javac) -> 바이트코드(.class) -> JVM -> OS 의 과정을 거치게 된다.

JVM이 OS와 애플리케이션 사이에서 중개자 역할을 수행해주기 때문에, 개발자는 OS에 구애받지 않고 “Write Once, Run Anywhere” 를 실현할 수 있다. JVM 내부에는 JIT(Just-In-Time) 컴파일러가 있어, 반복되는 코드를 런타임에 네이티브 코드로 변환해 캐싱함으로써 인터프리터 방식의 성능저하를 극복하고 있다.

JVM의 전체 구조


메모리 구조를 자세히 살펴보기 전에, JVM이 전체적으로 어떻게 구성되어 있는지 큰 그림을 먼저 이해할 필요가 있다. JVM은 크게 세 가지 핵심 서브 시스템으로 나뉜다.

  • 클래스 로더 시스템 (Class Loader System): 컴파일된 .class 파일들을 엮어서 JVM이 OS로부터 할당받은 메모리 영역인 Runtime Data Areas로 적재하는 역할을 한다. (동적 로딩)

  • 메모리 영역 (Runtime Data Areas): JVM이 프로그램을 실행하는 동안 사용하는 메모리 공간이다.

  • 실행 엔진 (Execution Engine): 메모리에 적재된 바이트코드를 읽어 기계어로 변경하여 명령어 단위로 실행한다.
    • 인터프리터: 바이트코드를 한 줄씩 해석하여 실행한다.
    • JIT (Just-In-Time) 컴파일러: 인터프리터의 느린 속도를 보완하기 위해, 반복되는 코드를 네이티브 코드로 컴파일하여 캐싱한다.
    • GC (Garbage Collector): 힙 영역에서 더 이상 사용되지 않는 객체를 찾아 메모리를 회수한다.

이제 이 세 가지 핵심 요소를 순서대로 상세히 살펴보겠다.



클래스 로더 시스템 (Class Loader System)

자바는 동적 로딩을 지원하기 때문에 컴파일 타임이 아니라 런타임(바이트 코드를 실행할 때) 에 클래스를 처음으로 참조할 때 해당 클래스 파일을 로드하고 링크하여 초기화한다.

클래스 로더는 크게 로딩, 링크, 초기화의 3단계 과정을 거친다.

  • 로딩 (Loading): 클래스 파일(.class)을 읽어와서 바이트 코드를 바이너리 데이터로 만들고 이를 메서드 영역에 저장한다. 로딩이 끝나면 해당 클래스 타입의 Class 객체를 생성하여 힙 영역에 저장한다.

  • 링크 (Linking): 로드된 클래스 파일이 유효한지 검증하고, 필요한 메모리를 준비하는 과정이다.
    • 검증(Verification): .class 파일 형식이 유효한지 체크한다.
    • 준비(Preparation): static 변수와 기본값에 필요한 메모리를 준비한다.
    • 해석(Resolution): 심볼릭 메모리 레퍼런스를 메서드 영역에 있는 실제 메모리 주소(Direct Reference)로 교체한다.

  • 초기화 (Initialization): 클래스 변수들을 적절한 값으로 초기화한다. (static 블록이 있다면 이때 실행된다.)

또한 클래스 로더는 계층 구조를 이루고 있다.

  • 부트스트랩 클래스 로더 (Bootstrap Class Loader): JVM 기동 시 생성되며, Object 등 자바의 핵심 API를 로드한다.
  • 플랫폼 클래스 로더 (Platform Class Loader): 기본 자바 API를 제외한 확장 클래스들을 로드한다.
  • 애플리케이션 클래스 로더 (Application Class Loader): 사용자가 지정한 클래스 패스(ClassPath)의 클래스들을 로드한다.

실행 엔진 (Execution Engine)

클래스 로더를 통해 메모리에 배치된 바이트코드는 실행 엔진에 의해 실행된다. 실행 엔진은 바이트코드를 명령어 단위로 읽어서 실행하는데, 이때 두 가지 방식을 혼합하여 사용한다.

  • 인터프리터 (Interpreter)
    • 바이트코드를 한 줄씩 읽어서 해석하고 실행한다.
    • 초기 실행 속도는 빠르지만, 같은 코드를 매번 해석해야 하므로 전체적인 속도는 느리다.

  • JIT (Just-In-Time) 컴파일러
    • 인터프리터의 느린 속도를 보완하기 위해 도입되었다.
    • 반복되는 코드(Hot Spot)가 발견되면, JIT 컴파일러가 해당 코드를 네이티브 코드(기계어)로 컴파일하여 캐싱해둔다. 이후에는 인터프리터를 거치지 않고 바로 실행하므로 속도가 매우 빠르다.

  • GC (Garbage Collector)
    • 힙 영역에서 더 이상 사용되지 않는 객체를 찾아 메모리를 회수한다. (다음 포스팅에서 상세히 다룰 예정)



런타임 데이터 영역 (Runtime Data Areas)

JVM이 프로그램을 실행할 때 사용하는 메모리 공간은 크게 스레드마다 생성되는 영역모든 스레드가 공유하는 영역 으로 나뉜다.

모든 스레드가 공유하는 영역 (Shared Areas)

이 영역들은 JVM이 시작될 때 생성되고 종료될 때 소멸하며, 멀티 스레드 환경에서 동기화 문제가 발생할 수 있는 영역이다.

  • 힙 (Heap)
    • new 키워드로 생성된 모든 클래스 인스턴스와 배열 이 할당되는 곳이다.
    • 개발자가 명시적으로 해제할 수 없으며, GC(가비지 컬렉터)가 자동으로 관리한다.
    • 메모리가 부족하면 OOM이 발생한다.

  • 메서드 영역 (Method Area)
    • 클래스 구조, 필드/메서드 데이터, 생성자 코드, 런타임 상수 풀(Constant Pool) 등을 저장한다.
    • 논리적으로는 힙의 일부로 취급되지만, 구현에 따라 GC가 일어나지 않을 수도 있다.

  • 런타임 상수 풀 (Run-Time Constant Pool)
    • 각 클래스나 인터페이스마다 존재하는 테이블로, 컴파일 타임에 알 수 있는 숫자 리터럴부터 런타임에 해석되어야 하는 메서드/필드 참조까지 다양한 상수를 담고 있다.

스레드마다 생성되는 영역 (Per-Thread Areas)

스레드가 생성될 때 만들어지고 스레드가 종료되면 사라지는 영역이다. 스레드 간 침범할 수 없어 Thread-Safe 하다.

  • PC 레지스터 (pc Register)
    • 각 스레드는 자신만의 PC(Program Counter) 레지스터를 가진다. 현재 실행중인 메서드가 네이티브 메서드가 아니라면, PC 레지스터는 현재 실행 중인 JVM 명령어의 주소를 담고 있다.
    • 만약 스레드가 네이티브 메서드를 실행 중이라면, PC 레지스터의 값은 정의되지 않음(undefined) 상태가 된다.

  • JVM 스택 (Java Virtual Machine Stacks) 또는 호출 스택 (Call Stacks/Execution Stacks)
    • 메서드가 호출될 때마다 프레임 이라는 블록을 쌓는다(push). 메서드가 끝나면 제거(pop) 된다.
    • C언어와 같은 기존언어의 스택과 유사하게 지역 변수와 부분 연산 결과를 저장하며, 메서드 호출 및 리턴 관리에 관여한다.
    • 스택 크기가 허용치보다 커지면 StackOverflowError가 발생하고, 스택을 확장할 메모리가 부족하면 OOM을 발생한다.
    • 스택의 크기는 고정될 수도 있고, 계산 필요에 따라 동적으로 확장/축소될 수도 있다.

  • 네이티브 메서드 스택 (Native Method Stacks)
    • 자바가 아닌 언어(보통 C/C++)로 작성된 네이티브 메서드를 지원하기 위한 스택이다.

프레임과 메서드 실행 원리

메서드 호출 시 JVM 스택에 생성되는 ‘프레임’의 내부 구조를 이해하면 JVM의 동작 방식이 더 명확해진다.

  • 지역 변수 배열 (Local Variables)
    • 메서드의 파라미터와 지역 변수들이 저장된다.
    • 인스턴스 메서드의 경우, 0번 인덱스에는 항상 호출된 객체 자신인 this참조가 저장되고 파라미터는 1번부터 저장된다.

  • 피연산자 스택 (Operand Stack)
    • JVM은 레지스터 기반이 아니라 스택 기반으로 계산을 수행한다.
    • 예를 들어, 덧셈을 하려면 지역 변수에서 값을 불러와 피연산자 스택에 넣고(push), 명령어(iadd) 가 스택에서 두 값을 꺼내(pop) 더한 뒤 결과를 다시 스택에 넣는다.

  • 동적 링킹 (Dynamic Linking)
    • 프레임은 런타임 상수 풀에 대한 참조를 가지고 있어, 메서드 실행 중에 다른 메서드나 변수의 심볼릭 참조를 실제 메모리 주소로 변환할 수 있게 해준다.



데이터 타입과 명령어 집합의 특징

데이터 타입의 처리

JVM은 byte, short, int, long, char, float, double, boolean, returnAddress 등의 기본 타입을 지원한다. 하지만 명령어 집합은 모든 타입을 대등하게 지원하지 않는다.

  1. JVM은 boolean 값을 연산하기 위한 전용 명령어를 가지고 있지 않다. 자바 소스 코드의 boolean 연산은 컴파일 시 **JVM의 int 데이터 타입(0또는 1)**을 사용하는 명령어로 변환된다.

  2. boolean 배열은 newarray 명령어로 생성되지만, 실제 접근과 수정은 byte 배열용 명령어(baload, bastore)를 사용한다. 마찬가지로 true는 1, false는 0으로 인코딩된다.

  3. byte, char, short 타입 역시 전용 연산 명령어가 거의 없으며, 대부분 int 타입으로 변환되어 (sign-extend 또는 zero-extend) int 명령어로 처리된다.


명령어 집합의 특징

JVM 명령어는 1바이트 크기의 Opcode(작업 코드)와 0개 이상의 Operand(인자)로 구성된다.

  • 타입병 명령어
    • 타입별 명령어는 iload(int 로드), fload(fload 로드), astore(참조 저장) 등 타입에 따라 접두사가 붙는다. (i, l, f, d, a 등)

  • 메서드 호출 명령어
    • invokestatic: 정적 메서드 호출
    • invokevirtual: 일반적인 인스턴스 메서드 호출 (다형성 적용)
    • invokespecial: 생성자(), private 메서드, 슈퍼클래스 메서드 호출
    • invokeinterface: 인터페이스 메서드 호출
    • invokedynamic: 런타임에 호출 대상이 결정되는 동적 호출 (Java 8 람다 등에서 사용)

  • 객체 생성
    • 클래스 인스턴스는 new, 배열은 newarray 등으로 생성한다.

마치며

이번 포스팅에서는 막연하게만 알고 있었던 JVM의 전체 구조를 파악하고, 그중에서도 데이터가 저장되는 Runtime Data Areas와 실행 흐름을 제어하는 프레임의 구조를 깊이 있게 살펴보았다. 다음 포스팅에서는 실행 엔진의 핵심 요소이자 자바의 메모리 관리자인 GC 의 동작 원리와 알고리즘에 대해 자세히 다뤄보도록 하겠다.

참고
The Java® Virtual Machine Specification