JVM

진성대·2025년 2월 27일
0

JAVA

목록 보기
2/4

JVM의 개념과 역할

JVM이란 무엇인가?

JVM(Java Virtual Machine)은 자바 가상 머신으로, Java 프로그램을 실행하기 위한 가상 컴퓨터입니다. 즉, Java로 작성된 바이트코드(bytecode)를 운영체제가 이해할 수 있는 명령으로 해석하고 실행해 주는 소프트웨어 엔진입니다.
이 가상 머신이 실제 하드웨어와 운영체제 사이에서 중개자 역할을 하여, Java 프로그램이 어떤 운영체제에서든 동일하게 실행될 수 있는 실행 환경을 제공합니다. 한마디로, JVM은 Java 프로그램을 구동시키기 위한 가상의 컴퓨터라고 할 수 있습니다.

JVM의 주요 역할

JVM은 Java 프로그램 실행을 위해 여러 가지 중요한 역할을 수행합니다. 주요 역할은 다음과 같습니다:

  • 바이트코드 실행(해석/컴파일): JVM의 가장 핵심 역할은 Java 컴파일러가 생성한 바이트코드(.class 파일)를 읽어서 해당 플랫폼의 기계어로 변환하여 실행하는 것입니다. 초기에는 바이트코드를 한 줄씩 인터프리팅(Interpretation)하지만, 현대 JVM은 JIT(Just-In-Time) 컴파일러를 통해 자주 사용되는 코드를 실행 시점에 기계어로 컴파일하여 성능을 높입니다.
  • 메모리 관리 및 가비지 컬렉션: JVM은 프로그램 실행 동안 동적으로 메모리를 할당하고 관리합니다. 사용이 끝난 객체를 자동으로 회수하는 가비지 컬렉터(GC)를 통해 메모리 누수를 방지하고 효율적으로 메모리를 관리합니다. 개발자가 직접 메모리를 해제하지 않아도 되어, 메모리 관리의 안정성이 높아집니다.
  • 보안 관리: JVM은 로딩된 바이트코드를 검사하는 바이트코드 검증기(Bytecode Verifier)를 통해 프로그램이 안전한지 확인합니다. 또한 Security Manager 등을 통해 애플리케이션이 수행할 수 있는 작업(파일 접근, 네트워크 접근 등)에 제약을 걸어 자바 샌드박스 환경에서 프로그램이 실행되도록 합니다. 이로써 악의적인 코드로부터 시스템을 보호하는 역할을 합니다.
  • 운영체제 및 하드웨어 독립성 보장: JVM은 운영체제와 하드웨어에 종속되지 않는 추상 계층을 제공하여 플랫폼 독립성을 구현합니다. Java 바이트코드는 어떤 기계에서도 동일하게 동작하며, 각 플랫폼에 특화된 JVM이 이를 해당 환경에서 실행시킵니다. (플랫폼 독립성에 대한 자세한 원리는 아래에서 다룹니다.)

이처럼 JVM은 바이트코드 실행, 메모리/자원 관리, 보안, 플랫폼 중립성 보장 등 Java 프로그램 구동에 필수적인 여러 역할을 맡고 있습니다.

Java 프로그램 실행 과정 (JVM이 어떻게 Java 프로그램을 실행하는가)

Java 프로그램은 작성 -> 컴파일 -> 실행의 단계를 거쳐 JVM에서 구동됩니다. 과정을 단계별로 살펴보면 다음과 같습니다:

  1. 자바 소스 코드 작성 및 컴파일: 개발자는 .java 확장자의 소스 코드를 작성합니다. 그런 다음 Java 컴파일러(javac)를 사용하여 소스 코드를 컴파일하면, 해당 코드가 바이트코드 형태의 .class 파일로 변환됩니다. (.class 파일은 사람이 읽을 수 있는 코드는 아니며, JVM이 이해할 수 있는 이진 명령들의 모음입니다.)

  2. 클래스 로딩(Class Loading): Java 애플리케이션을 실행하면 JVM 내의 클래스 로더가 필요한 .class 파일들을 메모리에 로드합니다. 이 때 여러 클래스들이 동적으로 로딩되며, 중복 로딩을 방지하고 필요한 클래스만 로딩하도록 관리됩니다.

  3. 바이트코드 검증(Verification): JVM은 로딩된 바이트코드가 스펙을 준수하는지 검증 단계를 거칩니다. 잘못된 바이트코드나 보안에 위협이 될 수 있는 코드를 탐지하여 실행을 차단함으로써, JVM의 안정성과 보안을 확보합니다.

  4. 실행 엔진에 의한 실행: JVM의 실행 엔진(Execution Engine)이 본격적으로 바이트코드를 실행합니다. 초기에는 인터프리터가 바이트코드를 한 줄씩 읽어 해당 플랫폼의 기계어로 변환하며 실행하고, 충분한 실행 정보가 누적되면 JIT 컴파일러가 빈번히 실행되는 바이트코드 영역을 네이티브 기계어로 컴파일합니다. 이렇게 변환된 기계어 코드는 직접 하드웨어에서 실행되며, 이후에는 이미 컴파일된 코드는 재사용하여 속도를 높입니다.

  5. 실행 및 종료: 실행 엔진은 변환된 기계어 명령들을 CPU에서 수행하여 프로그램 로직을 실행합니다. 이 과정에서 JVM은 런타임 데이터 영역(Runtime Data Area)이라는 메모리 공간(메소드 영역, 힙, JVM 스택 등)을 활용하여 변수 저장, 객체 생성, 메서드 호출 등을 관리합니다. 또한 쓰레드 관리, 예외 처리 및 앞서 언급한 가비지 컬렉션 등을 수행하며 프로그램의 실행을 총괄합니다. 프로그램 실행이 끝나면 JVM은 할당했던 메모리 등을 정리하고 종료합니다.


Java 프로그램의 실행 단계: 위 다이어그램은 Java 소스 파일(.java)이 컴파일러(JDK의 javac)에 의해 바이트코드(.class 파일)로 변환되고, 그 .class 파일이 Java Runtime Environment(JRE) 내에서 실행되는 과정을 보여줍니다. JRE는 JVM과 기본 Java 클래스 라이브러리, 그리고 JIT 컴파일러 등을 포함하며, 응용 프로그램은 JRE 안에서 구동됩니다. 컴파일된 바이트코드(.class)는 어떤 컴퓨터에서도 동일한 JRE/JVM 상에서 실행되며, JVM이 해당 시스템에 맞게 바이트코드를 해석하거나 JIT 컴파일하여 최종적으로 프로그램을 동작시킵니다. 이처럼 Java는 컴파일된 코드와 실행 환경(JVM)을 분리함으로써 한 번 작성한 코드를 어디서나 실행할 수 있게 합니다.

JVM과 플랫폼 독립성

플랫폼 독립성이란 Java 프로그램이 특정 운영체제나 하드웨어에 종속되지 않고 실행될 수 있는 특성을 말합니다. Java의 플랫폼 독립성이 가능한 이유는 JVM과 바이트코드 덕분입니다. 자바 컴파일러가 생성한 바이트코드(.class)는 특정 OS의 기계어가 아닌 중간 언어이기 때문에, 이를 직접 실행할 수 있는 것은 CPU나 OS가 아니라 각 플랫폼용 JVM입니다. Java를 실행하고자 하는 각 운영체제(윈도우, 리눅스, macOS 등)에 해당 OS에 맞는 JVM 구현체가 설치되어 있기만 하면, 동일한 .class 파일을 읽어들여 그 OS에 맞는 기계어로 변환해 실행할 수 있습니다. Sun/Oracle의 표어로 흔히 알려진 “Write Once, Run Anywhere” (한 번 작성하면 어디서든 실행) 원리가 바로 이것을 가리킵니다. 예를 들어, Windows에서 개발된 Java 프로그램이라도 .class 바이트코드 파일만 있으면 Linux나 macOS 등의 환경에서 JVM만 설치되어 있으면 수정 없이 그대로 실행할 수 있습니다. 각 플랫폼마다 JVM 구현체는 다르지만, 모두 동일한 바이트코드 명령 세트를 해석하고 실행하기 때문에 Java 프로그램의 동작 결과가 어디서나 일관되게 유지됩니다. 이처럼 JVM이 중간에서 운영체제별 차이를 흡수해주기 때문에 Java는 높은 이식성을 갖는 것입니다.

JVM의 언어 중립성

JVM은 언어 중립적인 실행 플랫폼이라고 할 수 있습니다. 그 이유는 JVM이 특정 고급 언어의 문법에 의존하지 않고 이진 바이트코드 형식만 따르기만 하면 어떤 언어로 작성된 프로그램이든 실행할 수 있기 때문입니다. 사실 JVM은 Java 언어로 만들어진 프로그램뿐만 아니라, 다른 프로그래밍 언어들도 실행할 수 있도록 설계되어 있습니다. 실제로 JVM 위에서 구동되는 언어들을 가리켜 JVM 언어라고 부르는데, Java와 문법만 다르고 바이트코드로 컴파일되어 JVM상에서 동작하는 언어들이 다수 존재합니다. 예를 들어 Kotlin, Scala, Groovy, Clojure 등은 자체적인 문법과 기능을 가졌지만 컴파일 결과가 Java와 동일한 바이트코드이므로 JVM에서 실행됩니다. 심지어 Python이나 Ruby도 JVM용 구현(Jython, JRuby)을 통해 바이트코드로 변환하여 실행할 수 있습니다. 이렇게 여러 언어를 하나의 공통된 실행 플랫폼(JVM) 위에서 구동시킬 수 있는 것은, JVM이 소스 코드의 언어적 특징보다는 바이트코드 명령의 형식과 실행 규약만을 신경 쓰도록 중립적으로 설계되었기 때문입니다. 그 결과 JVM은 자바 생태계의 범위를 넓혀 다양한 언어들의 실행 기반이 될 수 있었습니다.

예제: JVM을 통한 "Write Once, Run Anywhere"

Java의 플랫폼 독립성과 JVM의 역할을 간단한 예제로 확인해보겠습니다. 아래에 매우 간단한 Java 프로그램을 작성했습니다:

// HelloWorld.java
public class HelloWorld {
    public static void main(String[] args) {
        System.out.println("Hello, JVM!");
    }
}

이 소스 파일 HelloWorld.java를 컴파일하면 HelloWorld.class라는 바이트코드 파일이 생성됩니다. 이 .class 파일은 Windows이든 Linux이든 JVM이 설치된 어떤 운영체제에서나 동일한 동작을 합니다. 예를 들어, Windows에서 위 프로그램을 컴파일했다면 생성된 HelloWorld.class를 Linux 시스템으로 옮겨서 java HelloWorld 명령으로 실행할 수 있습니다. 별도의 수정이나 재컴파일 없이도 똑같이 "Hello, JVM!"라는 출력을 얻을 수 있습니다. 이는 .class 바이트코드 파일이 플랫폼 중립적이고, 각 OS에 맞는 JVM이 이를 해당 OS의 기계어로 변환해주기 때문입니다. 이처럼 JVM 덕분에 자바 프로그램은 한 번 작성하고(.java), 한 번 컴파일하면(.class), 어디서든 실행할 수 있다는 것을 직접 확인할 수 있습니다.

즉, JVM은 가상의 컴퓨터로서 바이트코드를 해석/실행하고, 메모리를 관리하며, 안전한 실행 환경을 제공하고, Java의 "Write Once, Run Anywhere" 철학을 가능케 하는 핵심 런타임 엔진입니다. 이를 통해 Java를 비롯한 다양한 언어의 프로그램들이 실제 컴퓨터에서 동작하도록 해주는 보이지 않는 기반이 바로 JVM입니다.


JVM 아키텍처 개요 (JVM의 구성 요소)

Java Virtual Machine(JVM)은 자바 바이트코드를 실행하기 위한 가상 컴퓨터입니다. JVM은 크게 클래스 로더 서브시스템(Class Loader Subsystem), 런타임 데이터 영역(Runtime Data Areas), 그리고 실행 엔진(Execution Engine)의 세 부분으로 구성됩니다. 각 구성 요소는 자바 프로그램을 로딩하고 메모리에 배치한 후, 바이트코드를 기계어로 해석/컴파일하여 실행하는 역할을 담당합니다. 아래 다이어그램은 JVM의 주요 구성 요소와 그 관계를 나타낸 것입니다.

JVM의 주요 구성 요소와 아키텍처: Class Loader는 .class파일을 로드하고(Linking/Initialization 포함), Runtime Data Area는 메모리 구조(메서드 영역, 힙, 스택 등)를 제공하며, Execution Engine은 바이트코드를 해석(Interpreter)하거나 JIT 컴파일하여 실행한다. 또한 JNI를 통해 네이티브 라이브러리와 연동한다.

이제 각 구성 요소별로 역할과 작동 방식을 자세히 살펴보겠습니다. 또한 클래스 로더의 로딩 단계, 메모리 영역의 세부 구조, 실행 엔진의 동작(인터프리터 vs JIT 컴파일러)과 JVM 전체 흐름을 예제와 함께 설명하겠습니다.


1. 클래스 로더 서브시스템 (Class Loader Subsystem)

클래스 로더(Class Loader)는 JVM이 동적으로 클래스들을 로드하는 역할을 담당하는 구성 요소입니다. 자바는 “런타임에 필요한 클래스만 그때그때 메모리에 적재”하는 동적 클래스 로딩을 특징으로 하며, 이는 클래스 로더 서브시스템에 의해 구현됩니다. 자바 컴파일러(javac)가 생성한 .class 파일(바이트코드)은 JVM 실행 시점에 클래스 로더에 의해 읽혀져 메모리에 로딩됩니다.

  • 역할 및 기능: 클래스 로더는 특정 클래스가 처음 참조될 때(사용될 때) 해당 클래스의 바이트코드를 찾아 메모리(Method Area)에 적재하고, 링크(Link)와 초기화(Initialization)를 수행합니다. 이 과정에서 동적 로딩뿐 아니라 격리와 네임스페이스 관리 기능도 제공합니다. 서로 다른 클래스 로더는 동일한 이름의 클래스라도 별도로 로딩하여 독립적인 네임스페이스를 갖게 할 수 있습니다. 또한 한 번 로드된 클래스는 언로드(unload)되지 않으며(JVM 종료 전까지 메모리에 유지)
    , 클래스를 언로드하려면 해당 클래스 로더 자체를 폐기해야 합니다.

  • 클래스 로더의 종류와 계층: JVM에는 기본적으로 세 종류의 클래스 로더가 계층적으로 동작합니다 (Java 8 기준):

    1. 부트스트랩 클래스 로더(Bootstrap ClassLoader) – 최상위 부모 클래스로더로, JVM 구동 시 가장 먼저 실행됩니다. JRE의 핵심 클래스들을 로드하며, 예를 들어 <JAVA_HOME>/jre/lib/rt.jar 안의 자바 표준 라이브러리 클래스들이 여기서 로딩됩니다. 이 로더는 자바 코드가 아니라 네이티브 코드(C++)로 구현되어 있다는 특징이 있습니다.

    2. 익스텐션 클래스 로더(Extension ClassLoader) – 부트스트랩의 자식 로더로, JDK 8까지는 <JAVA_HOME>/lib/ext/ 디렉토리의 확장 라이브러리(JAR)들을 로딩합니다. (Java 9부터는 Platform ClassLoader로 대체됨) 주로 보안 확장 기능 등의 클래스를 로드하며, 부트스트랩 로더와 애플리케이션 로더 사이에서 동작합니다.

    3. 애플리케이션 클래스 로더(Application ClassLoader) – 익스텐션 로더의 자식 로더로, 일반 자바 애플리케이션의 클래스 경로(CLASSPATH)에 있는 클래스들을 로딩합니다. 예컨대 애플리케이션의 .jar나 bin 디렉토리에 있는 사용자 코드들이 이 로더를 통해 메모리에 올라갑니다. 이것을 시스템 클래스 로더(System ClassLoader)라고도 부르며, 우리가 별도 로더를 만들지 않으면 기본적으로 이 로더가 애플리케이션 클래스를 읽어들입니다.
      (+) 사용자 정의 클래스 로더(User-Defined ClassLoader): 위 계층 구조 밖에서, 애플리케이션 클래스 로더 이후에 동작할 수 있도록 사용자가 직접 작성하는 커스텀 클래스 로더도 존재합니다. 이는 특수한 클래스 로딩 동작(예: 네트워크 통해 클래스 로딩 등)이 필요할 때 활용됩니다.

  • 위임 메커니즘 (Delegation): 클래스 로더들은 위임(delegation) 기반의 계층적 모델로 동작합니다. 즉, 어떤 클래스 로더가 클래스를 로딩할 때 먼저 부모 로더에 로딩을 위임하고, 부모가 해당 클래스를 찾지 못한 경우에만 자신이 로딩을 수행합니다. 이러한 위임 계층 알고리즘(Delegation Hierarchy Algorithm) 덕분에, 기본 Java 라이브러리의 클래스들은 언제나 부트스트랩 로더가 우선 로딩하게 되어 일관성을 유지하고 중복 로딩을 피할 수 있습니다. 예를 들어, 애플리케이션 로더가 java.lang.String 클래스를 로딩하려 하면 먼저 부모인 부트스트랩 로더에게 위임하고, 부트스트랩 로더가 이미 String 클래스를 로드했으므로 재사용하는 식입니다. 반대로, 부모 로더가 로딩하지 못한 클래스를 자식이 대신 로드할 수는 있지만, 부모가 이미 로딩한 클래스는 자식이 다시 로드할 수 없습니다. 이런 규칙을 가시성 규약(Visibility Constraint)이라고도 하며, 하위 로더는 상위 로더의 클래스를 볼 수 있지만 그 반대는 불가능합니다.

클래스 로딩 과정 (Loading, Linking, Initialization)

클래스 로더는 클래스 로딩 과정에서 세 단계를 수행합니다:

  • 로딩 (Loading): .class 파일을 찾아서 바이트코드를 읽어들이고, JVM 메모리(Method Area)에 해당 클래스의 내용을 로드합니다. 이 때 앞서 언급한 Bootstrap → Extension → Application 클래스로더 순으로 위임하여 클래스를 찾습니다. 가장 우선순위 높은 부트스트랩 로더부터 탐색하고, 해당 클래스가 발견되면 로딩을 완료하며 단계를 마칩니다.

  • 링크 (Linking): 로드된 클래스가 실행되기 전에 검증 및 준비 작업을 수행합니다. 링크 단계는 다시 세 가지로 나뉩니다:

    1. 검증 (Verification): 메모리에 로드된 바이트코드가 JVM 명세에 맞는 유효한 코드인지 검사합니다. 바이트코드 검증기를 통해 잘못된 바이트코드나 보안에 위배되는 코드가 없는지 확인하며, 검증 실패 시 VerifyError가 발생합니다.

    2. 준비 (Preparation): 로드된 클래스의 메타데이터를 초기화하는 단계입니다. 주로 클래스에 선언된 정적 변수(static fields)를 위한 메모리를 할당하고 기본값으로 초기화합니다. (예를 들어 static int x = 10; 이라면 이 단계에서는 x의 메모리를 확보하고 타입의 기본값인 0으로 세팅해둡니다. 실제 10으로 설정하는 것은 초기화 단계에서 수행됨)

    3. 해결 (Resolution): 클래스 내부에서 참조하는 다른 클래스, 메소드, 필드 등의 심볼릭 참조(symbolic reference)를 실제 메모리 주소 등의 직접 참조로 치환합니다. 예를 들어 이 클래스가 java.lang.String을 참조하고 있다면, 심볼 형태("java/lang/String")로 되어있는 것을 이미 로드된 String 클래스 객체를 가리키는 레퍼런스로 변경합니다. 이 과정으로 이후 실행 시 빠르게 해당 참조들을 사용할 수 있게 됩니다.

  • 초기화 (Initialization): 클래스의 정적 초기화 단계입니다. 앞선 준비 단계에서 기본값이 할당된 정적 변수들에 원래의 초기값을 할당하고, 정적 초기화 블록(static initializer blocks)이 있다면 이 시점에 실행됩니다. 예를 들어 static int x = 10;은 이제 x에 10이 할당되고, 클래스에 static { ... } 블록이 있다면 그 코드가 실행됩니다. 초기화 단계까지 완료되면 해당 클래스는 사용할 준비가 완료된 것이며, 이 시점 이후에야 실제로 클래스의 인스턴스를 생성하거나 static 메서드를 사용할 수 있습니다.

JVM 클래스 로딩의 전체 흐름이며, 클래스 로더는 이처럼 로딩 → 링크 → 초기화 순서로 동작합니다. 이러한 동적 클래스 로딩 방식 덕분에, 자바는 필요할 때까지 클래스를 로드하지 않으므로 메모리 효율을 높이고 (사용하지 않는 클래스는 로드되지 않음), 동적 확장(런타임에 새로운 클래스 로딩)도 가능하게 합니다.

| 예시: 간단한 자바 프로그램을 예로 클래스 로더의 역할을 보겠습니다. 아래는 Test라는 클래스의 일부입니다:

public class Test {
    static int s = 42;        // 정적 변수
    public static void main(String[] args) {
        Test obj = new Test();   // Test 클래스 로드 및 객체 생성
        int result = obj.calc(5);
        System.out.println(result);
    }
    public int calc(int x) {
        int y = 2;
        return x + y;
    }
}

이 프로그램을 컴파일하면 Test.class가 생성되고, 실행 시 JVM은 먼저 Test 클래스를 Application ClassLoader로 로드합니다. 로딩 단계에서 Test.class 파일이 읽혀 메모리에 올라가고, 링크의 검증을 통해 바이트코드를 확인한 뒤, 준비 과정에서 정적 변수 s를 위한 메모리를 확보하여 기본값 0을 넣어둡니다. 해결 단계에서는 Test 클래스가 참조하는 java.lang.Object 등의 심볼릭 참조를 실제 클래스 객체로 연결합니다. 이어서 초기화 단계에서 s에 42를 대입합니다 (또 클래스 초기화 블록이 있었다면 실행). 이로써 Test 클래스 로딩이 완료되어 사용할 준비가 되었습니다.

그런 다음 JVM은 main 메서드를 찾아 실행을 시작합니다. 이 때 런타임 데이터 영역에 본격적으로 스레드 스택과 힙 등이 활용되는데, 이에 대해서는 아래에서 설명하겠습니다.클래스 로더는 이후에도 Test 클래스 실행 도중 다른 클래스 (예: System 클래스 또는 사용자 정의 다른 클래스)를 처음 참조할 때마다 같은 방식으로 해당 클래스를 동적으로 로드하고 링크/초기화를 수행합니다.


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

런타임 데이터 영역은 JVM이 프로그램 실행을 위해 사용하는 메모리 영역들을 가리킵니다. JVM이 실행되면 운영체제로부터 할당받은 메모리를 논리적으로 여러 영역으로 나누어 관리하는데, Java 명세에 따르면 5가지 주요 영역으로 구성됩니다:

  1. 메서드 영역 (Method Area) – “메타스페이스(Metaspace)”라고도 불리며, 클래스 수준의 정보를 저장하는 영역입니다. JVM당 한 개만 존재하며, 모든 스레드가 공유합니다. 클래스 로더가 적재한 클래스의 바이트코드가 이곳에 저장되고, 클래스별로 런타임 상수 풀(Runtime Constant Pool), 필드와 메서드 정보, 메서드의 바이트코드 등이 포함됩니다. 또한 정적 변수(static fields)와 정적 메서드도 메서드 영역에 저장됩니다. (Java 8부터는 PermGen이 폐지되고 메타스페이스로 구현되었지만, 논리적인 역할은 Method Area와 같습니다.)

    • 메서드 영역은 JVM 시작 시 생성되며, 여러 클래스에 대한 정보를 함께 저장하기 때문에 멀티스레드 환경에서 공유되지만 비(非)스레드 안전합니다. 따라서 여러 스레드가 클래스/정적 데이터를 변경할 때는 동기화에 주의해야 합니다.
    • (상수 풀: 각 클래스의 .class 파일에 있는 constant pool 테이블이 런타임 상수 풀로서 메서드 영역에 로드됩니다. 문자열 리터럴 등의 상수 객체도 이 영역에 저장되며, String interning 등이 여기서 이루어집니다.)
  2. 힙 영역 (Heap Area) – 객체를 저장하는 영역입니다. new 연산으로 생성된 모든 인스턴스 객체와 배열은 힙에 할당됩니다. 힙도 메서드 영역처럼 JVM 전체에 하나만 존재하며 스레드 모두가 공유하는 공간입니다.

    • 애플리케이션 실행 중 동적으로 생성되는 객체들이 계속 힙을 채우게 되므로, JVM은 가비지 컬렉터(Garbage Collector)를 통해 더 이상 참조되지 않는 객체를 힙에서 자동으로 회수합니다. GC는 주로 이 힙 영역을 대상으로 동작하며, 메모리 누수를 방지합니다.
    • JVM 옵션으로 -Xmx, -Xms 등을 지정하여 힙 크기를 조절할 수 있습니다. 만약 힙에 더 이상 객체를 할당할 여유가 없고 GC로도 회수가 안 되면 OutOfMemoryError가 발생합니다.
    • 메서드 영역과 힙은 둘 다 공유 영역이기 때문에, 여러 스레드가 동시에 객체에 접근/수정할 경우 적절한 동기화가 필요합니다.
  3. 자바 스택 영역 (Java Stack Area) – 각 스레드마다 개별적으로 할당되는 스택으로, 메서드 호출 시 생성되는 스택 프레임(Stack Frame)을 저장합니다.

    • JVM에서 스레드가 시작될 때 해당 스레드를 위한 스택 메모리가 생성되며, 스레드가 종료되면 함께 소멸합니다. 따라서 스택은 각 스레드 전용(private) 영역이어서 스레드 안전합니다 (다른 스레드가 접근하지 않음).
    • 스택 프레임은 메서드 호출마다 하나씩 생성되는 데이터 구조로, 메서드 실행에 필요한 정보를 담고 있습니다. 각 프레임에는 세 가지 주요 부분이 있습니다:
      a. 로컬 변수 배열 (Local Variables Array) – 해당 메서드의 지역 변수들과 매개변수를 저장합니다. 배열 형태로 인덱스로 접근하며, 각 슬롯은 32비트 크기(64비트 값은 두 슬롯 사용)입니다. 예를 들어, 정수 변수나 객체 참조 등이 여기에 저장됩니다. (인스턴스 메서드의 경우 인덱스 0은 this 참조를 차지합니다.)
      b. 피연산자 스택 (Operand Stack) – JVM이 스택 기반 가상머신이므로 연산을 수행할 때 피연산자나 중간 계산 결과를 임시로 여기에 쌓습니다. 예를 들어 두 수를 더하는 바이트코드 명령을 수행하려면, 먼저 두 숫자를 오퍼렌드 스택에 넣고 iadd 명령을 실행하면 스택에서 두 숫자를 꺼내 합산 후 결과를 다시 스택에 넣는 식으로 동작합니다.
      c. 프레임 데이터 (Frame Data) – 해당 메서드에 대한 추가 메타정보를 포함합니다. 예외 처리 테이블(try-catch 블록 정보)이나 상위 클래스 참조, Runtime Constant Pool에 대한 참조 등이 여기에 속합니다. 메서드 실행 중 예외가 발생하면, JVM은 이 프레임에 있는 예외 처리 정보를 확인하여 적절한 catch 블록으로 흐름을 옮깁니다.
    • 메서드가 호출되면 새로운 프레임이 스택의 가장 위에 쌓이고(push) 실행이 진행됩니다. 메서드 실행이 끝나면 해당 프레임을 스택에서 제거(pop)하여 메모리를 해제합니다. 이러한 LIFO(후입선출) 구조로 메서드 호출/복귀가 관리됩니다.
    • 스택 오버플로우(StackOverflowError): 재귀 호출의 무한 반복 등으로 인해 하나의 스레드 스택에 너무 많은 프레임이 쌓이면, 스택 메모리 한계를 넘어서서 StackOverflowError가 발생합니다. 반대로, JVM이 스택 프레임을 할당하기 위해 메모리를 얻지 못하면 OutOfMemoryError를 일으킬 수 있습니다.
  4. PC 레지스터 (Program Counter Register) – 각 스레드마다 하나씩 존재하는 레지스터로, 현재 실행 중인 JVM 명령의 주소(위치)를 가리키는 값을 보관합니다. CPU의 프로그램 카운터와 유사한 역할을 하지만, JVM 명령어의 주소를 가집니다.

    • 자바 바이트코드는 스레드별로 독립적인 실행 흐름을 가지므로, JVM은 스레드마다 별도의 PC 레지스터로 해당 스레드의 실행 위치를 관리합니다.
    • 한 스레드 내에서 메서드 호출 시 새로운 스택 프레임이 활성화되면, PC 레지스터도 그 메서드의 첫 번째 명령어 주소로 변경됩니다. 명령어 실행이 진행될 때마다 PC 레지스터의 값도 다음 명령어 주소로 갱신됩니다.
    • 네이티브 메서드(native method)를 실행하는 경우엔 JVM 명령어가 아니므로, 해당 스레드의 PC 레지스터 값은 정의되지 않습니다 (그 시점에는 의미가 없기 때문입니다).
  5. 네이티브 메서드 스택 (Native Method Stack) – 자바가 아닌 네이티브 언어(C/C++ 등)로 작성된 코드를 위한 스택입니다. 각 스레드마다 하나씩 존재하며, 자바에서 native 키워드가 붙은 메서드를 호출할 때 해당 네이티브 코드 실행을 위한 스택 프레임을 제공합니다.

    • 일반적으로 JVM 구현은 C 스택 등을 이용하여 이 영역을 지원하며, JNI(Java Native Interface)를 통해 호출된 네이티브 함수를 수행합니다.
    • 네이티브 메서드도 호출되면 새로운 프레임(또는 C 스택 프레임)이 쌓이고, 종료되면 제거되는 방식으로 관리됩니다.

이상 5가지 영역이 JVM의 런타임 데이터 영역을 이루며, JVM이 시작될 때 메서드 영역과 힙이 생성되고, 각 스레드가 생성될 때마다 그 스레드 전용의 JVM 스택, PC 레지스터, 네이티브 스택이 생성됩니다.

  • 메서드 영역과 힙처럼 공유되는 영역은 JVM 전역에서 하나만 존재하고, 스레드 전용 영역(스택, PC, 네이티브 스택)은 스레드마다 별개로 존재합니다.

  • 프로그램 실행 중에는 클래스 로더를 통해 새 클래스와 객체가 메모리에 추가되고, 실행이 진행되며 스택 프레임이 추가/제거되고, PC 레지스터가 변경되는 등의 일이 끊임없이 일어납니다. 실행이 끝나면 사용한 메모리 영역들은 정리되며 JVM이 종료됩니다.

예시 (스택과 힙의 동작): 앞서 예로 든 Test 프로그램의 실행 흐름을 메모리 관점에서 살펴보겠습니다. 클래스 로더에 의해 Test 클래스가 메서드 영역에 로드되고 나면, JVM은 main 메서드를 호출하여 실행을 시작합니다. 이때 메인 스레드가 시작되며, 메인 스레드 전용 스택이 생성되고 PC 레지스터가 main 메서드의 첫 바이트코드 명령을 가리키도록 설정됩니다.

  1. main 메서드 진입: 메인 스레드의 스택에 main 메서드에 대한 프레임이 쌓입니다. 이 프레임의 로컬 변수 배열에는 args 배열과 지역변수들이 놓입니다 (예에서는 obj, result 변수가 생성됨). 이제 PC 레지스터를 따라 main의 바이트코드를 한 줄씩 인터프리트(또는 JIT 컴파일)하며 실행합니다.
  2. 객체 생성 (new Test()): new 연산을 만나면 JVM은 힙 영역에 Test 클래스의 인스턴스를 할당합니다. 이 객체의 필드들은 힙에 저장되고, 객체 참조가 obj 지역변수 슬롯에 저장됩니다 (힙에 생성된 객체 주소를 참조). 만약 Test 클래스의 생성자( 메서드)가 있다면 새로운 프레임이 스택에 추가되어 실행되고, 완료 후 제거됩니다.
  3. 메서드 호출 (obj.calc(5)): JVM은 calc 메서드를 호출하기 위해 새로운 스택 프레임을 생성하여 메인 스레드의 스택에 쌓습니다. 이 프레임의 로컬 변수 배열에는 this (즉 obj)와 매개변수 x(값 5)가 저장됩니다. 이어서 calc 메서드의 바이트코드를 실행하면서, y와 sum 같은 지역변수도 이 프레임의 로컬 변수 배열에 저장됩니다. x + y 연산은 오퍼렌드 스택을 사용하여 수행되고, 결과가 sum에 저장됩니다.
  4. 메서드 반환: calc 메서드가 return을 만나면, 반환값(7)이 상위 호출자에게 전달되고 calc의 스택 프레임이 제거(pop)되어 메모리에서 사라집니다. 이제 다시 main 메서드의 프레임이 top이 되어 실행이 계속됩니다. result 변수에 7이 대입되지요.
  5. 표준 라이브러리 호출 (System.out.println): println 메서드를 호출할 때, 만약 해당 클래스(java.io.PrintStream 등)가 아직 로드되지 않았다면 클래스 로더가 로드하고 초기화합니다. 그리고 새로운 스택 프레임을 생성하여 실행합니다. (이 때 println은 native 메서드를 내부적으로 사용할 수 있는데, 필요한 경우 JVM은 네이티브 메서드 스택을 이용합니다.) println 실행 중에는 메인 스레드의 PC 레지스터가 해당 메서드의 바이트코드를 가리키고 있게 됩니다. 실행이 완료되어 println이 복귀하면 그 프레임이 사라지고, 다시 main 프레임으로 돌아옵니다.
  6. main 종료: main 메서드의 마지막 명령까지 실행하고 반환하면, 메인 스레드의 스택에서 main 프레임이 제거됩니다. 더 이상 실행할 자바 코드가 없으므로 메인 스레드가 종료되고, 이에 따라 해당 스레드의 JVM 스택과 PC 레지스터, 네이티브 스택도 소멸합니다.
  7. JVM 종료 및 정리: 모든 (non-daemon) 스레드가 종료되면 JVM도 종료됩니다. 사용되던 메서드 영역과 힙 등은 운영체제가 회수합니다. (또는 필요한 경우 System.gc() 호출로 GC를 유도할 수도 있지만, 즉각적 실행은 보장되지 않습니다.)
    이 예에서 보듯이, 클래스 로더→메모리 적재(메서드 영역/힙)→실행 엔진에 의한 실행→스택 프레임 증감→PC 레지스터 이동 등의 과정이 유기적으로 맞물려 JVM 상에서 프로그램이 실행됩니다.

3. 실행 엔진 (Execution Engine)

실행 엔진은 메모리에 로드된 바이트코드를 실제 CPU가 실행할 수 있는 기계어로 변환하여 실행하는 JVM 구성 요소입니다. 클래스 로더가 적재한 바이트코드는 이 실행 엔진에 의해 한 명령어씩 처리됩니다. 실행 엔진은 크게 인터프리터(Interpreter)와 JIT(Just-In-Time) 컴파일러, 그리고 가비지 컬렉터(Garbage Collector) 등을 포함합니다.

  • 인터프리터 (Interpreter): 인터프리터는 바이트코드를 한 줄 한 줄 읽어서 즉시 실행하는 방식으로 동작합니다. 바이트코드 명령을 한 개 가져와서 해석하고 해당하는 기계 동작을 수행한 뒤, 다시 다음 명령을 가져오는 식입니다.

    • 장점: 인터프리터 자체는 구현이 단순하여 시작 시 빠르게 동작합니다. 클래스가 로드되면 별다른 추가 작업 없이 바로 인터프리팅을 시작할 수 있으므로 프로그램 초기 구동이 빠릅니다.
    • 단점: 한 메서드(또는 코드 블록)가 여러 번 호출될 때마다 매번 처음부터 바이트코드를 해석해야 한다는 점입니다. 즉, 반복 실행되는 코드도 매번 해석하므로 누적 실행 속도가 느려질 수 있습니다. 예를 들어 루프 안의 코드를 1000번 실행하면, 동일한 바이트코드 부분을 1000번 해석하게 됩니다. 이로 인해 인터프리터만으로 동작하는 JVM은 성능상 느리다는 단점이 있었습니다.
  • JIT 컴파일러 (Just-In-Time Compiler): JIT 컴파일러는 이러한 인터프리터의 단점을 보완하기 위해 JVM에 탑재된 동적 컴파일러입니다. 인터프리터와 함께 동작하면서, **반복 실행되는 바이트코드 조각을 기계어로 한번에 컴파일하여 변환합니다. 이렇게 컴파일된 네이티브 코드를 캐시해두고 이후에는 재사용하므로, 같은 바이트코드를 다시 해석할 필요 없이 바로 기계어로 실행하게 되어 속도가 크게 향상됩니다.

    • 동작 원리: 일반적으로 HotSpot JVM의 JIT는 프로파일러(Profiler)를 통해 핫스팟(hotspot)을 찾습니다. 여기서 hotspot이란 자주 실행되는 핫 코드 경로(예: 빈번히 호출되는 메서드나 루프)를 의미합니다. 인터프리터가 바이트코드를 실행하면서 메서드 호출 횟수 등을 모니터링하고, 일정 임계치를 넘으면 JIT 컴파일러가 개입합니다.
      1. 바이트코드 수집: JIT 컴파일러는 해당 메서드의 전체 바이트코드를 가져와서 기계어 코드로 변환 준비를 합니다 (인터프리터는 한 줄씩 처리하지만, JIT는 일괄 처리).
      2. 중간 코드 생성 및 최적화: JIT는 먼저 바이트코드를 분석해 중간 표현(Intermediate Representation)으로 만들고, 다양한 최적화(Optimization)를 수행합니다. 예를 들어 불필요한 연산을 제거하거나, 레지스터 활용을 높이는 등 컴파일러 최적화를 적용합니다.
      3. 네이티브 코드 생성: 최적화된 중간 코드를 목표 플랫폼의 기계어(Machine Code)로 생성합니다. 이렇게 생성된 네이티브 코드는 해당 메서드에 대응되는 실제 CPU 명령어 시퀀스입니다.
      4. 캐싱 및 재사용: 생성된 네이티브 코드를 JVM 내에 캐시해두고, 이후 그 메서드가 다시 호출되면 바이트코드를 해석하지 않고 곧바로 네이티브 코드로 실행합니다. 이로써 반복 호출되는 메서드의 실행 속도가 대폭 향상됩니다.
    • 효과: JIT 컴파일을 거친 코드는 네이티브 바이너리처럼 직접 CPU에서 실행되므로 인터프리팅에 비해 매우 빠릅니다. 현대 JVM의 성능이 초창기보다 크게 향상된 이유가 바로 이 JIT 기술 덕분입니다. 특히 HotSpot JVM은 메서드를 즉시 컴파일하는 것이 아니라, 실행 중 가장 자주 사용되는 부분만 골라 컴파일하기 때문에 초기 구동은 빠르고, 장기 실행 성능도 좋은 균형 잡힌 성능을 보여줍니다.
    • 참고로 JIT 컴파일러에는 C1, C2 컴파일러 등 여러 레벨이 있고, Java 9부터는 새로운 JIT 컴파일러(Graal)도 도입되는 등 발전이 계속되고 있습니다. JIT는 또한 런타임 최적화(실행 중에 코드 최적화를 재적용)와 디옵티마이즈(de-optimize) 같은 기법으로 동적으로 최상의 성능을 추구합니다.
  • 가비지 컬렉터 (Garbage Collector): 실행 엔진의 또 다른 핵심 구성 요소로, 힙 메모리를 관리하는 역할을 합니다. GC는 주기적으로 힙을 스캔하여 사용되지 않는 객체를 탐지하고 메모리를 해제함으로써, 메모리 누수를 방지하고 프로그램이 장기간 실행되어도 메모리가 고갈되지 않도록 합니다. Java의 GC는 Mark-and-Sweep, Generational GC 등 다양한 알고리즘으로 진화해왔으며, JVM 옵션으로 GC 방식을 선택할 수도 있습니다. (예: Serial, Parallel, G1, ZGC 등)

    • GC는 실행 엔진과 병행하여 돌아가며, 필요시 JVM 실행을 잠시 멈추고(“Stop-the-world”) 수집 작업을 하기도 합니다. 이러한 메모리 관리가 자동으로 이루어지기 때문에 Java 개발자는 C/C++처럼 명시적으로 free를 호출하지 않아도 됩니다.
  • JNI (Java Native Interface)와 네이티브 메서드 지원: JVM 실행 엔진은 또한 JNI를 통해 네이티브 라이브러리와 연동할 수 있습니다. 즉, 자바 코드에서 C/C++ 등의 네이티브 함수를 호출하거나 반대로 호출될 수 있습니다. 실행 엔진은 네이티브 메서드를 호출하면 해당 라이브러리를 로드하고, 네이티브 스택을 활용하여 외부 코드 실행을 관리합니다. 이때 JNI는 자바 객체를 네이티브 코드에서 다룰 수 있도록 포인터를 제공하거나, 반대로 네이티브 데이터를 자바 쪽으로 변환해주는 등의 역할을 합니다.

정리하면, 실행 엔진은 로드된 바이트코드를 가져와 실제 실행을 담당하며, 인터프리터로 즉각적 실행을, JIT 컴파일러로 반복 코드의 최적화를, GC로 메모리 관리를 수행함으로써 JVM의 핵심 기능인 "한 번 작성한 코드(.class)를 어디서나 효율적으로 실행"하는 작업을 구현합니다.


4. JVM 전체 동작 흐름과 구성 요소 상호작용

지금까지 각 부분별로 JVM 내부 동작을 살펴보았는데, 이것들을 종합하여 JVM이 자바 코드를 실행하는 흐름을 단계별로 정리해 보겠습니다:

  1. 컴파일 및 클래스 로딩: 개발자가 작성한 자바 소스(.java)는 컴파일러(javac)에 의해 바이트코드(.class)로 변환됩니다. 프로그램을 실행하면 (java 명령), JVM이 시작되고 우선 클래스 로더가 엔트리 포인트인 메인 클래스를 찾아서 메모리에 로드합니다. 필요하면 의존하는 다른 클래스들도 위임 메커니즘에 따라 차례로 로드됩니다.
    • 이때 각 클래스마다 검증→준비→해결→초기화 과정이 수행되어, 해당 클래스의 바이트코드와 메타데이터가 메서드 영역에 적재되고, 정적 변수들이 초기화됩니다. 예를 들어 public static void main이 있는 MainClass를 실행하면, MainClass.class를 Application ClassLoader가 읽어 들여 JVM 메서드 영역에 로드하고, 정적 초기화를 마칩니다. 만약 MainClass가 Util 클래스 등을 사용하는 경우, MainClass 바이트코드 안의 심볼릭 참조를 따라 Util 클래스도 로드하게 됩니다.
  2. 실행 환경 구성: 클래스 로딩이 완료되면, JVM은 main 메서드 실행을 위해 새로운 스레드(메인 스레드)를 시작합니다. 이 스레드는 자신만의 JVM 스택(스택 영역), PC 레지스터, 네이티브 메서드 스택을 갖게 됩니다. 그리고 main 메서드 호출로 인해 메인 스택에 첫 번째 스택 프레임이 생성됩니다. 이제 실행 준비가 되었으므로 PC 레지스터를 main 메서드의 첫 명령으로 맞추고, 실행 엔진이 바이트코드 해석을 시작합니다.
  3. 바이트코드 실행 (인터프리터 ↔ JIT): 실행 엔진의 인터프리터는 main 메서드의 바이트코드를 한 줄씩 읽어 해당 동작을 수행합니다. 예를 들어 객체 생성, 메서드 호출, 연산 수행 등의 JVM 명령들을 차례로 처리합니다. 이 과정에서 메모리 영역과 상호작용이 일어납니다: 새 객체를 만들면 힙에 할당되고, 새 프레임이 필요하면 스택에 쌓이고, 분기나 호출 시 PC 레지스터 값이 변경됩니다.
    • 만약 실행 중 자주 등장하는 핫스팟 코드를 만나면, JIT 컴파일러가 해당 부분을 컴파일하여 네이티브 코드로 변환합니다. 이후 그 코드 블록은 인터프리트 대신 네이티브 코드로 직접 실행되므로 실행 속도가 빨라집니다. 인터프리터와 JIT는 이렇게 협업하여, 초기에는 빠르게 인터프리터로 진행하고 점차 성능을 높여나갑니다.
    • 예를 들어 main에서 루프가 1만 번 돈다면, 처음 몇 백 번은 인터프리터가 해석하다가 그 루프의 바이트코드가 매우 빈번히 실행됨을 감지하면 JIT가 개입하여 해당 루프 바디를 네이티브 코드로 컴파일해 둡니다. 이후 남은 반복에 대해서는 더 이상 해석 없이 컴파일된 코드가 바로 실행됩니다.
  4. 동적 클래스 로딩: 실행 중 새로운 클래스 참조를 만나면 (예: 첫 번째 new SomeClass() 호출), 클래스 로더를 통해 그 클래스가 동적으로 로딩됩니다. 이미 로드된 클래스라면 캐시된 것을 사용하고, 처음 보는 클래스라면 위임 순서대로 찾아 로드합니다. 이렇게 JVM은 실행 중에도 필요한 클래스를 계속 메모리에 올리며 확장됩니다 (동적 로딩).
  5. 네이티브 메서드 호출: 만약 자바 코드가 네이티브 메서드를 호출하면(JNI를 통해 C/C++ 라이브러리 사용 등), JNI를 통해 네이티브 라이브러리를 로드하고 네이티브 메서드 스택을 이용하여 해당 코드를 실행합니다. 실행 엔진은 자바 스택 대신 네이티브 스택에 프레임을 쌓고, PC 레지스터는 정의되지 않은 상태로 둔 채 네이티브 코드로 제어를 넘깁니다. 네이티브 메서드가 완료되면 다시 자바 코드로 제어가 돌아와 이어서 실행됩니다.
  6. 메모리 관리 (GC): 프로그램이 실행됨에 따라 힙에는 많은 객체가 생성됩니다. 주기적으로 GC(가비지 컬렉터)가 실행되어 참조가 끊어진 객체들을 힙에서 제거하고 메모리를 회수합니다. 개발자는 명시적으로 신경쓰지 않아도, JVM이 배경에서 메모리를 관리해 줍니다. GC가 실행될 때 일시적으로 모든 스레드를 멈추는 경우도 있지만 (stop-the-world), 현대 JVM은 병렬/병행 GC 등으로 지연을 최소화합니다.
  7. 프로그램 종료: main 메서드를 비롯해 모든 애플리케이션 스레드의 실행이 완료되면 (System.exit() 호출 혹은 정상적으로 코드 끝까지 실행), JVM은 각 스레드를 종료시키고 남아있는 리소스를 정리합니다. 메서드 영역의 클래스들이나 힙의 객체들은 JVM 종료와 함께 모두 운영체제에 의해 해제됩니다.

위의 흐름에서 볼 수 있듯이, JVM의 각 구성 요소들은 긴밀하게 상호작용합니다. 클래스 로더가 코드와 데이터를 메모리에 올리면, 런타임 데이터 영역이 저장소가 되고, 실행 엔진이 그것을 이용하여 실행합니다. 실행 중에는 다시 클래스 로더를 호출하기도 하고, 메모리 영역에 변화를 주기도 합니다. 이러한 구조 덕분에 Java는 한 번 컴파일된 바이트코드를 어디서나 실행할 수 있고 (JVM이 추상화 계층 역할), 동시에 JIT 컴파일 등으로 높은 성능도 확보할 수 있습니다.

요약하면, JVM은 “클래스 로더 → 메모리 적재 → 바이트코드 실행(인터프리터/JIT) → 메모리 관리”의 사이클로 동작하며, 각각의 구성 요소가 맡은 역할을 수행함으로써 Java 프로그램이 안전하고 효율적으로 실행됩니다. 이러한 JVM 아키텍처를 잘 이해하면 Java의 메모리 관리나 성능 튜닝, 클래스 로딩 이슈 등을 더 깊이 있게 다룰 수 있게 됩니다.

profile
주니어 개발자

0개의 댓글