본문 바로가기

Backend/Java

[Inside Java] 01. Java는 왜 바로 기계어로 컴파일하지 않을까? — 바이트코드부터 JVM 메모리까지...

 

Swift로 iOS 앱을 만들 때는 보통 컴파일 → 빌드 → 실행 파일 생성 → CPU 실행이라는 흐름이 비교적 자연스럽게 느껴졌습니다.

C++도 비슷합니다. 소스 코드를 컴파일하면 특정 운영체제와 CPU가 이해할 수 있는 기계어 실행 파일이 만들어집니다.

그런데 Java는 조금 이상하게 느껴졌습니다.

 

.java 파일 → javac로 컴파일 → .class 파일 생성 → JVM이 다시 실행

 

띠로리… 분명 컴파일을 했는데, 실행할 때는 JVM이 바이트코드를 해석한다고 합니다. 게다가 자주 실행되는 코드는 JIT 컴파일러가 다시 기계어로 컴파일한다고 합니다. 그렇다면 Java는 컴파일 언어일까요, 인터프리터 언어일까요?

이번 글에서는 위 질문에서 출발해 Swift,C++과 Java의 실행 구조를 비교하고, Java 프로그램이 JVM 안에서 어떤 메모리 구조로 관리되는지까지 연결해보려고 합니다.

Swift와 C++은 어떻게 실행될까?

Swift나 C++은 일반적으로 소스 코드를 특정 플랫폼의 기계어로 미리 컴파일합니다.

Swift / C++ 소스 코드
        ↓ 컴파일
ARM64 또는 x86-64 기계어
        ↓ 링크
실행 파일
        ↓ OS가 프로세스로 실행
CPU가 기계어 실행

 

좀 쉽게 비유하면은 아래처럼!!

개발자가 레고 설명서(소스 코드)를 작성
→ 레고 부품 만들기(컴파일)
→ 부품들을 하나의 완성품으로 조립하기(링킹)
→ 완성된 레고를 박스에 담기(실행 파일 생성)
→ 박스를 열고 꺼내기(앱 딸깍! -> 프로그램 실행)
→ OS가 책상 위에 올려주기(메모리에 적재)
→ CPU가 레고를 가지고 일하기 시작!(기계어 실행)

 

개념적으로 프로세스 메모리는 다음과 같이 설명할 수 있습니다.

 

iOS / macOS Process
├── Text / Code
│   └── 실행할 기계어 명령
├── Data
│   └── 초기값이 있는 전역,정적 데이터
├── BSS
│   └── 초기값이 없거나 0으로 초기화되는 전역,정적 데이터
├── Heap
│   └── 동적으로 생성되는 객체
└── Stack
    └── 함수 호출 정보와 지역 변수

 

정확한 세부 배치는 운영체제, 컴파일러, 링커, 런타임에 따라 달라질 수 있습니다.

하지만 중요한 흐름은 비교적 단순합니다.

컴파일이 끝난 시점에 CPU가 실행할 기계어가 이미 만들어져 있습니다.

 

CPU는 실행 파일의 Code 영역에 올라온 기계어를 직접 실행합니다.

그런데 Java는 왜 바로 기계어를 만들지 않을까?

Java도 컴파일을 합니다.

javac Main.java

 

이 명령을 실행하면 Main.class가 생성됩니다.

하지만 .class 파일 안에 들어 있는 것은 ARM64나 x86-64 기계어가 아닙니다. JVM이 이해하도록 설계된 바이트코드(Bytecode)입니다.

 

Main.java
    ↓ javac
Main.class
    ↓
JVM Bytecode

 

예를 들어 다음과 같은 Java 코드가 있다고 해보겠습니다.

int result = 10 + 20;
System.out.println(result);

 

컴파일 결과에는 개념적으로 다음과 같은 JVM 명령이 들어갈 수 있습니다.

 

// 바이트 코드
bipush 10
bipush 20
iadd
istore
getstatic
invokevirtual

 

이 명령들은 특정 CPU가 바로 실행하는 기계어가 아닙니다.

  • iadd: JVM의 정수 덧셈 명령
  • istore: 값을 Local Variable Table에 저장하는 명령
  • invokevirtual: 인스턴스 메서드를 호출하는 명령

즉, Java 컴파일러는 소스 코드를 CPU 명령어가 아니라 JVM 명령어로 변환합니다.

위 바이트코드는 이해를 위해 단순화한 예시입니다.

우리는 10 + 20을 보면 한 번에 30!이라고 계산하지만, JVM은 스택(Stack)을 사용하는 기계라 명령을 하나씩 실행합니다.

 

bipush 10   // Push
bipush 20   // Push
iadd        // Pop, Pop → 더하기 → Push
istore      // 변수에 저장

 

즉, Push → Push → Pop해서 연산 → 다시 Push를 반복하며 프로그램을 실행합니다.

혹시,,, 알고리즘 시간에 후위 표기식 계산을 배웠다면 딱 그 스택(Push/Pop) 방식과 비슷하다고 생각하면 됩니다.

## .class가 만들어진 이유

Java는 OS에 의존적인 기계어 대신, JVM이 이해하는 중간 형식인 .class(바이트코드) 를 생성합니다.

 

한 번 컴파일한 .class는 운영체제마다 다시 빌드할 필요 없이, 각 플랫폼의 JVM이 실행해주기 때문!

그러면 Java는 컴파일 언어일까, 인터프리터 언어일까?

둘 중 하나로만 나누기는 어렵습니다.

Java는 먼저 컴파일되고, 이후 실행 과정에서는 인터프리터와 JIT 컴파일러를 함께 사용할 수 있습니다.

.java
  ↓ javac 컴파일
.class Bytecode
  ↓ ClassLoader가 로딩
Interpreter가 바이트코드 실행
  ↓ 실행 정보 수집
자주 실행되는 코드 감지
  ↓ JIT 컴파일
Native Machine Code
  ↓
CPU가 직접 실행

 

흐름은 지금 바로 이해하지 않으셔도 됩니다.

그래서…..

Java는 바이트코드까지는 미리 컴파일하고,
JVM 실행 중에는 인터프리팅 + JIT 컴파일 함께 사용하는 언어!!

그렇다면은.. 인터프리터는 무엇을 할까?

앞에서 살펴본 iload, iadd, istore 같은 바이트코드는 JVM 인터프리터가 하나씩 읽고 실행합니다.

하지만 같은 코드가 반복해서 실행되면 인터프리터가 동일한 바이트코드를 계속 해석해야 하므로 비효율적일 수 있습니다.

이때 반복해서 실행되는 코드를 기계어로 바꾸어 재사용하기 위해 JIT 컴파일러가 등장합니다.

그리구 JIT는 Just-In-Time Compilation의 약자입니다.

  • 인터프리터는 외국어(바이트코드)를 실시간으로 동시 통역하는 사람
  • JIT은 바이트 코드에서 자주 쓰는 문장, 문단들을 미리 번역해서 저장 해두는 사람

프로그램을 실행하기 전에 모든 코드를 기계어로 바꾸는 것이 아니라, 실행 중 필요한 시점에 자주 사용되는 코드를 기계어로 컴파일합니다.

JVM도 결국 운영체제 위의 프로세스입니다.

JVM이라는 이름 때문에 Java 프로그램이 운영체제와 분리된 특별한 공간에서 실행되는 것처럼 느껴질 수 있습니다.

하지만 JVM도 결국 운영체제 위에서 실행되는 네이티브 프로그램입니다.

Operating System
└── Java Process
    └── JVM
        └── Java Application

예를 들어 터미널에서 java Main 명령어를 실행한다면, 운영체제는 java 실행 파일을 실행하고 Java 프로세스를 생성합니다. JVM은 그 프로세스 안에서 아래와 같은 같은 작업을 수행합니다.

  • 필요한 .class 파일 로딩
  • 바이트코드 검증
  • Heap과 Thread Stack 관리
  • 객체 할당과 Garbage Collection
  • 바이트코드 실행
  • JIT 컴파일
  • 네이티브 라이브러리 호출

결국 JVM도 운영체제로부터 가상 메모리를 할당받고, 그 메모리를 자신의 실행 목적에 맞게 다시 관리합니다.

OS가 바라보는 Java 프로세스 메모리

운영체제 관점에서 Java 프로세스에는 Java Heap만 존재하는 것이 아닙니다.

개념적으로는 여러 메모리 영역이 사용됩니다.

여기서 중요한 점은..

운영체제의 프로세스 메모리 구분과 “JVM”의 Runtime Data Areas는 같은 기준으로 나눈 구조가 아닙니다.

 

운영체제는 하나의 Java 프로세스가 사용하는 전체 가상 메모리를 관리합니다.

예를 들어 쇼핑몰 CRUD 서비스를 실행하면 운영체제는 하나의 Java 프로세스를 만들고, 그 프로세스가 사용할 메모리 공간을 제공합니다.

운영체제
└── 쇼핑몰 CRUD 서비스 Java 프로세스
    ├── 상품, 회원, 주문 객체가 저장되는 Java Heap
    ├── 각 Java 스레드가 사용하는 Thread Stack
    │   ├── main() Stack Frame
    │   │   ├── Local Variable Table
    │   │   ├── Operand Stack
    │   │   └── 반환 관련 정보
    │   └── createOrder() Stack Frame
    │       ├── Local Variable Table
    │       ├── Operand Stack
    │       └── 반환 관련 정보
    ├── 클래스 메타데이터가 저장되는 Metaspace
    ├── JIT가 만든 기계어가 저장되는 Code Cache
    └── JVM과 GC가 사용하는 기타 Native Memory

 

여기서 Thread Stack은 스레드 하나가 사용할 수 있도록 실제로 확보된 스택 메모리이고, Stack Frame은 그 안에 메서드가 호출될 때마다 생성되는 실행 단위입니다.

 

main()에서 createOrder()를 호출하면 같은 스레드의 Thread Stack 안에 main() Frame 위로 createOrder() Frame이 추가됩니다. 메서드 실행이 끝나면 해당 Frame은 제거됩니다.

 

하지만 위 구조는 운영체제가 바라보는 Java 프로세스 메모리와 JVM 내부의 메모리 사용 방식을 이해하기 쉽게 단순화한 그림입니다.

반면 JVM 명세는 특정 메모리 주소나 구현 이름을 강제하지 않고, Java 바이트코드를 실행하기 위해 필요한 논리적인 런타임 영역과 역할을 정의합니다.

JVM Runtime Data Areas

JVM 명세는 프로그램 실행에 필요한 런타임 데이터 영역을 다음과 같이 정의합니다.

JVM Runtime Data Areas
├── Heap
├── Method Area
├── JVM Stack
├── PC Register
└── Native Method Stack ( 자바에서 C/C++) 메서드 실행할 때 사용됨

 

이 영역들은 모든 JVM 구현체가 반드시 같은 메모리 주소 구조로 구현해야 한다는 뜻은 아닙니다.

JVM 명세는 각 영역이 어떤 역할과 생명주기를 가져야 하는지를 정의하고, 실제 구현 방법은 각 JVM 구현체가 결정합니다.

Heap

Heap은 Java 객체와 배열이 생성되는 런타임 영역입니다.

Person person = new Person();
int[] numbers = new int[10];

 

개념적으로 new Person() 객체와 new int[10] 배열은 Heap에 생성됩니다.

Java Heap
├── Person Object
└── int[10] Array Object

 

Heap은 일반적으로 여러 스레드가 공유하는 영역입니다.
객체는 Heap에 동적으로 생성되며, Stack 영역과 달리 메서드 호출이 끝났다고 바로 제거되지 않습니다.

Stack은 함수 호출 시 Stack Frame이 생성되고, 함수 종료 시 해당 Stack Frame이 pop되면서 지역 변수와 함께 메모리가 해제됩니다.
반면 Heap 객체는 여러 곳에서 참조될 수 있기 때문에 생명주기가 더 길며, GC Root부터 더 이상 도달할 수 없게 되었을 때 Garbage Collector의 회수 대상이 됩니다.

 

Garbage Collector는 이러한 객체를 회수하기 위해 다양한 알고리즘을 사용하며, 대표적인 방식으로 Mark and Sweep이 있습니다

JVM Stack

각 Java 스레드는 자신만의 JVM Stack을 가집니다.

main Thread
└── JVM Stack

worker Thread
└── JVM Stack

 

메서드가 호출되면 해당 스레드의 JVM Stack에 Stack Frame이 생성됩니다.

void calculate(int value) {
    int result = value * 2;
}

 

개념적으로 이 메서드의 Stack Frame에는 다음과 같은 정보가 들어갑니다.

 

 

Swift/C++의 함수 호출 스택과 비슷한 역할이 있지만, JVM Stack Frame은 JVM 바이트코드 실행 모델에 맞춰 정의되어 있습니다.

Stack Frame, Local Variable Table, Operand Stack은 뒤에서 Parent p = new Child() 한 줄 실행 과정을 탐구해보려 햅니다.

참고,,,, PC Register

각 JVM 스레드는 자신만의 PC Register를 가집니다.

개념적으로 현재 스레드가 어떤 JVM 명령을 실행하고 있는지 나타내는 역할을 합니다. 운영체제가 여러 스레드를 번갈아 실행할 때, JVM은 각 스레드가 어디까지 실행했는지 추적할 수 있어야 합니다.

Thread A PC → invokevirtual 명령 위치
Thread B PC → iadd 명령 위치

 

Method Area

Method Area는 클래스 단위의 정보를 저장하기 위한 JVM 명세상의 논리적 영역입니다.

개념적으로 다음과 같은 정보가 관리됩니다.

Method Area
├── 클래스와 인터페이스 정보
├── Runtime Constant Pool
├── 필드 정보
├── 메서드 정보
├── 메서드 바이트코드
└── static 필드 관련 정보

 

예를 들어 다음 클래스가 있다고 해보겠습니다.

class Person {
    static int population;
    String name;

    void introduce() {
        System.out.println(name);
    }
}

 

 

 

Person 객체를 여러 개 만들더라도 introduce()의 메서드 로직이 각 객체 안에 반복 복사되지는 않습니다.

 

Heap
├── Person Object 1
│   └── name = "A"
├── Person Object 2
│   └── name = "B"
└── Person Object 3
    └── name = "C"

Class-level Metadata
└── Person
    └── introduce() Method Metadata / Bytecode

 

객체마다 달라지는 인스턴스 상태는 Heap 객체에 저장되고, 공통으로 사용하는 클래스 구조와 메서드 정보는 클래스 단위로 관리됩니다.

 

쉽게 생각하면은,,,

A 클래스에 선언된 함수가 77개 있다고 가정해봅시다.
Heap 영역에 A 클래스의 인스턴스가 100개 생성되어 있고, 각각 다른 데이터를 가지고 있습니다.
그렇다면 77개의 함수가 있으니 Heap 영역에는 100 × 77개의 함수 코드가 저장될까요?

 

아닙니다. 

 

각 인스턴스가 가지는 데이터(상태)는 다르지만, 함수 로직 자체는 동일합니다.
따라서 객체마다 메서드 코드를 복사해서 저장하지 않고, 모든 인스턴스가 공통으로 사용하는 클래스 정보와 메서드 정보는 Method Area에서 클래스 단위로 관리됩니다.

자세한 내용은 이후 포스트에서 살펴보겠습니다.

cf. Method Area와 Metaspace는 같은 것일까?

이 부분은 자주 혼동됩니다. 완전히 같은 개념은 아니지만, 서로 관련된 개념입니다.

Method Area는 JVM 명세가 정의한 논리적 영역이고, Metaspace는 실제 JVM이 그 역할의 일부를 구현하기 위해 사용하는 메모리 영역입니다.

cf. Runtime Constant Pool은 무엇일까?

.class 파일의 Constant Pool에는 문자열과 숫자뿐 아니라 클래스, 필드, 메서드를 가리키는 심볼릭 참조도 저장됩니다.

클래스가 로딩되면 이 정보는 Runtime Constant Pool로 준비되어, 메서드 호출이나 필드 접근을 실제 실행 대상으로 연결하는 데 사용됩니다.

그래서 컴파일했는데 왜 또 실행 과정이 필요할까?

처음의 질문으로 돌아가 보겠습니다.

Java는 분명 컴파일했는데, 왜 JVM이 다시 해석해야 할까?

 

Java 컴파일러가 만드는 결과물이 CPU 기계어가 아니라 JVM 바이트코드이기 때문입니다.

Swift / C++
소스 코드
→ 플랫폼 기계어
→ CPU 실행

Java
소스 코드
→ JVM 바이트코드 !!!!!!!!!!!!!!!
→ JVM이 해석 또는 JIT 컴파일
→ CPU 실행

 

이 중간 계층 덕분에 Java는 동일한 바이트코드를 여러 플랫폼에서 실행할 수 있고, JVM은 실제 런타임 정보를 바탕으로 코드를 최적화할 수 있습니다.

 

반대로 JVM 실행 환경이 필요하고, 초기 실행 과정에서 클래스 로딩과 인터프리팅,JIT 컴파일 비용이 발생할 수 있다는 특성도 생깁니다.

결국 Java의 실행 구조는 단순히 컴파일 방식이 특이한 것이 아니라, 플랫폼 독립성과 런타임 최적화를 위해 JVM이라는 실행 계층을 둔 구조라고 이해할 수 있습니다.

다음 글로 이어지는 질문

이제 Java 코드가 .class 바이트코드가 되고, JVM의 Heap,Stack,Method Area 같은 영역에서 실행된다는 큰 흐름은 잡혔습니다.

그런데 다음 코드를 보면 또 다른 질문이 생깁니다.

Parent p = new Child();
  • 변수의 타입은 Parent인데 왜 Child 객체를 넣을 수 있을까?
  • Heap에는 Parent 객체와 Child 객체가 각각 생길까?
  • p는 Stack에 무엇을 저장할까?
  • p.method()를 호출하면 부모와 자식 중 어떤 메서드가 실행될까?

다음 글에서는 우선 업캐스팅과 다형성의 최소 개념만 정리하고, 이후 핵심 편에서 이 한 줄이 Stack Frame, Local Variable Table, Heap, Method Metadata, vtable을 거쳐 실행되는 과정을 탐구해보려 합니닷!

핵심 정리

  • Swift와 C++은 일반적으로 소스 코드를 플랫폼 기계어로 컴파일한 뒤 CPU가 직접 실행합니다.
  • Java는 소스 코드를 JVM이 이해하는 .class 바이트코드로 컴파일합니다.
  • JVM은 바이트코드를 실행하고, 반복해서 실행되는 일부 코드는 JIT 컴파일러가 기계어로 바꿀 수 있습니다.
  • Java 프로그램도 운영체제 위의 하나의 프로세스로 실행되며, JVM이 그 안의 메모리를 관리합니다.
  • JVM 명세는 Heap, Method Area, JVM Stack, PC Register, Native Method Stack 같은 논리적 런타임 영역을 정의합니다.
  • Method Area는 JVM 명세의 논리적 개념이고, Metaspace는 실제 JVM에서 클래스 메타데이터를 저장하는 메모리 영역입니다.
  • 객체와 배열은 Heap에 생성되고, 메서드 호출 정보는 각 스레드의 Stack Frame에서 관리됩니다.

[Inside Java] 몰아보기

 

 

[Inside Java] 02. 업캐스팅?! Java 컴파일러는 무엇을 알고, JVM은 무엇을 결정할까? — Dynamic Dispatch로

Parent p = new Child(); Java를 공부하다 보면 이 한 줄을 아주 자주 만나게 됩니다.보통은 이렇게 설명합니다.부모 타입 변수에 자식 객체를 담는 업캐스팅이다. 문법적으로는 맞는 설명입니다.그런데

dev-with-precious-dreams.tistory.com

 

[Inside Java] 03. Heap 객체에서 method Area까지! - Klass Pointer와 Dynamic vs Static Dispatch 살펴보기

이전 포스트(누르면 바로가져요) 에서는 다음 코드가 왜 문법적으로 가능한지 살펴봤습니다.Parent p = new Child(); 컴파일러는 표현식 p의 정적 타입을 Parent로 보고, 실제 생성되는 객체는 Child입니

dev-with-precious-dreams.tistory.com

 

[Inside Java] 04. JVM은 main()을 바로 실행하지 않는다?! #1 -ClassLoader, 클래스 생명주기 #Loading #Linking #In

1편에서는 Java 코드가 바이트코드로 변환되고 JVM 위에서 실행되는 전체 흐름을 살펴봤습니다. 2편에서는 컴파일러가 정적 타입을 기준으로 무엇을 검사하고, 런타임의 JVM이 실제 객체를 기준으

dev-with-precious-dreams.tistory.com

 

[Inside Java] 05. JVM은 main()을 바로 실행하지 않는다 #2 — class metadata는 언제 초기화 될까? #Active Use,

앞선 4장에서는 .class 파일이 JVM 내부에서 Loading, Linking, Initialization을 거쳐 실행 가능한 타입이 되는 과정을 살펴봤습니다.하지만 여기서 한 가지 궁금증이 생깁니다. “클래스가 메모리에 로드

dev-with-precious-dreams.tistory.com

 

[Inside Java] 06. 같은 Java 코드가 실행될수록 빨라지는 이유 — JIT 컴파일과 HotSpot JVM 최적화 #Inline #

[Inside Java] 1~5편 까지는 JVM이 메모리를 어떻게 관리하는지를 중심으로 살펴보았습니다. 그런데 지금까지 살펴본 것처럼 JVM이 런타임마다 바이트코드를 하나씩 해석하고, Operand Stack을 이용해 연

dev-with-precious-dreams.tistory.com

 

[Inside Java] 07. interface는 JVM 메모리에서 어떻게 관리될까? — default 메서드와 enum 객체까지!!

이전 [Inside Java] 시리즈에서는 class로 만든 객체와 메서드 호출이 JVM 안에서 어떻게 연결되는지 살펴봤습니다.그런데 Java에는 class만 있는 것이 아니죠.....interface Animal { void speak();}enum Direction { LEF

dev-with-precious-dreams.tistory.com

 

[Inside Java] 08. 마무리.. Java 실행 구조 핵심 위키!

앞선 1장부터 7장까지 다룬 내용을 다시 찾기 쉽도록 핵심 개념만 두줄로 압축했습니다.자세한 동작 원리와 예시는 각 챕터에서 확인하고, 이 글에서는 개념의 연결 관계만 빠르게 정리합니다.

dev-with-precious-dreams.tistory.com