자바 언어·플랫폼 학습 노트 목차

JVM은 내 코드를 어떻게 실행하나 — 처음부터 끝까지

java App 한 줄을 치면 그 뒤에서 무슨 일이 벌어질까? 우리가 쓴 소스가 실제로 CPU에서 도는 기계어가 되기까지의 여정을, 컴퓨터구조와 엮어 차근차근 따라가 보자. 이 페이지는 순서대로 읽으면 하나의 이야기가 된다.


1. 출발점 — 자바는 왜 "한 번 짜면 어디서나" 도는가

C로 짠 프로그램은 윈도우에서 컴파일하면 윈도우에서만 돈다. CPU가 이해하는 기계어로 바로 번역되기 때문이다. 그런데 CPU의 명령어 집합(ISA)은 제각각이다 — 인텔(x86)과 폰의 칩(ARM)은 전혀 다른 언어를 쓴다.

자바는 여기서 한 단계를 더 둔다. javac.java를 번역하긴 하는데, 진짜 CPU의 기계어가 아니라 바이트코드라는 가상의 명령어로 번역한다.

App.java  --javac-->  App.class   (바이트코드 — 어느 CPU의 것도 아님)

그리고 플랫폼마다 JVM이라는 "가상 CPU"를 깔아 둔다. 이 JVM이 바이트코드를 자기 컴퓨터의 기계어로 바꿔 실행한다. 그래서 같은 App.class 하나가 윈도우·맥·리눅스 어디서나 돈다 — 이것이 **WORA(Write Once, Run Anywhere)**의 정체다. "한 번 번역해 두고, 실행은 각자 JVM이 알아서."

이 가상 CPU는 생김새가 좀 특이하다. x86·ARM 같은 진짜 CPU는 레지스터라는 작은 저장 칸에 값을 넣고 계산하는 레지스터 머신이다. 반면 JVM은 접시 쌓듯 값을 쌓았다 꺼내는 스택 머신이다. a + b를 시키면:

iload_1   // 지역변수 a 를 "피연산자 스택"에 올린다
iload_2   // b 를 올린다
iadd      // 위의 둘을 꺼내 더하고, 결과를 다시 스택에 올린다

왜 하필 스택 머신일까? 레지스터 개수는 CPU마다 다르지만 "스택에 쌓는다"는 방식은 어디서나 똑같이 표현되니 이식성이 좋고, 피연산자 주소를 일일이 안 적어도 돼 바이트코드가 짧다. 대신 진짜 CPU에서 빠르게 돌리려면 언젠가 이걸 레지스터 방식으로 바꿔 줘야 하는데 — 그 일을 하는 게 뒤에 나올 JIT다. (벌써 다음 이야기로 이어진다.)


2. java App — 먼저 JVM이라는 프로그램이 뜬다

우리가 java App을 치면, 사실 제일 먼저 실행되는 건 우리 코드가 아니라 JVM 자신이다. JVM도 결국 운영체제 위에서 도는 하나의 프로세스다. 그래서 OS로부터 자기가 쓸 메모리 공간을 받아 와 영역을 나눈다. 이 지도를 머릿속에 그려 두면 나머지가 전부 여기에 얹힌다.

다이어그램 로딩 중…

비유하면 힙은 창고다. new로 만든 물건(객체)이 전부 여기 쌓이고, 아무도 안 찾는 물건은 청소부(GC)가 치운다. 스택은 작업대다. 메서드를 하나 부를 때마다 작업판(프레임)을 하나 깔고, 그 위에서 지역변수와 계산을 처리하다, 메서드가 끝나면 작업판을 치운다. 스택은 쌓고 치우는 구조라 청소부가 필요 없다 — 끝나면 알아서 사라지니까.

여기서 벌써 흔한 오해 하나가 풀린다. "기본형은 스택, 객체는 힙"이라는 말은, 지역변수로 선언한 int는 작업판 위에 값으로 있지만 new로 만든 객체는 창고에 있고 작업판에는 그 **주소(참조)**만 적힌다는 뜻이다.

이 메모리는 진짜 물리 메모리를 통째로 미리 먹는 게 아니다. OS의 가상 메모리 위에 주소만 잡아 두고, 실제로 그 페이지(보통 4KB)를 건드릴 때 물리 메모리가 붙는다. 그래서 -Xmx16g를 줘도 시작하자마자 16GB를 쓰진 않는다.


3. 우리 클래스가 무대에 오르기까지 — 로딩에서 초기화까지

JVM이 떴으니 이제 App 클래스를 메모리로 데려와야 한다. 그냥 읽어 들이는 게 아니라 네 단계를 거치는데, 각 단계에 이유가 있다.

다이어그램 로딩 중…

로딩은 클래스 파일의 바이트를 읽어 메타스페이스에 클래스의 "설계도"를 만드는 일이다. 이때 클래스로더가 부모에게 먼저 물어보는 부모 위임 규칙을 따른다 — 핵심 클래스(java.lang.String 등)를 누가 가짜로 바꿔치기하지 못하게 하는 안전장치다.

검증이 특히 중요하다. 바이트코드는 사람이 손으로 조작할 수도 있으니, JVM은 실행 전에 "이 바이트코드가 스택을 넘치게 하진 않는지, 엉뚱한 타입을 섞진 않는지, 점프가 코드 밖으로 튀진 않는지"를 전부 확인한다. 이 검증기가 있어서 자바는 잘못된 바이트코드로 메모리를 깨는 공격을 막는다 — JVM 보안의 토대다.

그다음 준비에서 static 필드를 기본값(0/null)으로 깔고, 해석에서 "java/lang/String" 같은 *이름표(심볼릭 참조)*를 실제 메모리 주소로 바꾼다. 마지막 초기화에서 static 블록과 static 필드 대입이 실행된다. 중요한 건 이 모든 게 그 클래스를 처음 쓰는 순간에 일어난다는 점이다(미리 다 하지 않는다). 그래서 static 초기화 순서가 버그가 되기도 한다.


4. new 한 줄 — 객체는 메모리에 어떻게 놓이는가

이제 코드가 돌면서 new로 객체를 만든다. 그 객체 하나가 창고(힙)에 놓이는 실제 모습을 비트 단위로 들여다보자. 객체는 이렇게 생겼다:

[ 헤더 ][ 우리가 선언한 필드들 ][ 정렬용 패딩 ]

앞쪽 헤더는 우리가 안 적었는데도 JVM이 붙이는 관리 정보다. 두 부분으로 되어 있다:

  • mark word(8바이트) — 이 객체의 신원 정보가 비트로 빽빽이 들어간다: hashCode, GC가 "몇 번 살아남았나" 세는 나이, synchronized 락 상태 등. 상황에 따라 같은 8바이트를 다르게 해석한다.
  • klass 포인터(압축하면 4바이트) — "나는 어느 클래스의 객체인가"를 가리키는 주소(메타스페이스의 설계도로).

그래서 객체 하나의 기본 헤더는 12바이트(= 8 + 4)다. 여기에 필드가 붙는다:

class Point { int x; int y; }
헤더 12 + int 4 + int 4 = 20  →  8의 배수로 패딩  →  실제 24바이트

왜 8의 배수로 맞출까? 두 가지 이득이 한꺼번에 따라온다. ① CPU는 8바이트(워드) 경계에 정렬된 메모리를 더 빨리 읽는다. ② 모든 객체 주소가 8의 배수면 주소의 끝 3비트가 항상 0이라, 그만큼 잘라내고 저장할 수 있다 → 64비트 주소를 32비트로 압축해도 32GB까지 가리킨다(이게 힙이 32GB 이하일 때 기본으로 켜지는 압축 oops다). 정렬 하나가 속도메모리 절약을 동시에 준 셈이다.

자바 25는 여기서 한 발 더 나간다. **Compact Object Headers(JEP 519)**가 mark word와 klass 포인터를 하나의 8바이트로 합쳐, 헤더를 12 → 8바이트로 줄인다. 작은 객체가 수억 개인 서버에서 힙을 ~22% 아낀다 — 헤더 4바이트가 그만큼 무겁다는 뜻이다.


5. 메서드를 부른다 — 작업판을 쌓고, 누구를 부를지 고른다

객체가 생겼으니 메서드를 호출한다. 호출 한 번마다 스택에 프레임(작업판)이 하나 쌓인다. 프레임 안에는 지역변수 칸피연산자 스택(1번에서 본 계산용 접시)이 들어 있다. 메서드가 끝나면 프레임이 통째로 사라지고, 그래서 재귀가 너무 깊으면 작업판이 쌓이다 한계를 넘어 StackOverflowError가 난다.

그런데 "어떤 메서드를 부를지"는 생각보다 미묘하다. 바이트코드에는 호출 명령이 여러 개다:

  • invokestatic(static), invokespecial(생성자·private) — 컴파일 때 이미 정해진 주소를 부른다.
  • invokevirtual(보통의 인스턴스 메서드) — 실행 때 객체의 실제 타입을 보고 고른다.

이 "실행 때 고르기"가 다형성의 정체다. 어떻게 고를까? 클래스마다 메서드 주소를 적어 둔 표(vtable)가 있고, 객체의 klass 포인터(4번에서 본 그것!)를 따라가 그 표에서 진짜 메서드를 찾아 간접 점프한다. CPU 입장에선 "주소를 보고 나서야 어디로 뛸지 아는" 분기라 예측이 까다롭다. 그래서 JIT는 "이 자리는 늘 같은 타입이더라"를 알아채면 표 조회를 건너뛰고 직접 호출로 바꿔(역가상화) 인라인해 버린다.

invokedynamic도 있는데, 람다가 이걸 쓴다. 람다를 익명 클래스로 일일이 컴파일하면 .class가 폭증하니, 호출 지점을 처음 실행할 때 연결하도록 미뤄 둔다. 클래스 수도 줄고 최적화 여지도 남는다.


6. 처음엔 통역, 점점 번역 — 그리고 청소

드디어 실행이다. JVM은 처음엔 바이트코드를 인터프리터한 줄씩 통역하며 돌린다. 시작이 빠른 대신 느리다. 그래서 JVM은 슬쩍 통계를 낸다 — "이 메서드가 몇 번 불렸나, 이 루프가 몇 바퀴 도나". 어떤 코드가 뜨거운(자주 도는) 핫스팟이 되면, JIT 컴파일러가 그 부분만 골라 진짜 기계어로 번역해 코드 캐시에 넣는다. 이후 그 코드는 네이티브 속도로 난다. 1번에서 미뤄 둔 "스택 머신 → 레지스터 머신" 변환이 바로 여기서 일어난다.

한편 창고(힙)는 계속 차오른다. 그러면 청소부 GC가 안 쓰는 객체를 치운다. 새 객체는 대부분 금방 죽으니, GC는 갓 태어난 영역만 자주·짧게 치우고(minor GC), 오래 살아남은 객체만 안쪽으로 옮긴다. 이 JIT와 GC의 내부 동작은 워낙 중요해서 다음 페이지에서 따로 깊게 본다 — 여기선 "통역으로 시작해 점점 번역되고, 그동안 청소가 돈다"는 큰 그림만 잡으면 된다.


7. 한 장으로 보는 전체 흐름

지금까지의 이야기를 하나로 잇는다. java App을 치면:

다이어그램 로딩 중…

한 문장 요약: 번역(javac) → JVM 부팅 → 적재·검증 → 통역(인터프리터) → 가열(JIT가 기계어로) → 청소(GC) → 종료. 같은 .class 하나가 어느 컴퓨터에서나 이 흐름을 똑같이 돈다.

이제 6번에서 미룬 JIT가 어떻게 최적화하고 GC가 어떻게 청소하는지, 그리고 그 모든 게 CPU 캐시·메모리 배리어와 어떻게 맞물리는지를 다음 페이지에서 이어 본다.


한눈에 정리

  • 바이트코드는 가상 CPU(JVM)의 명령어 → 어느 컴퓨터에서나 도는 비결(WORA).
  • 메모리: 힙(객체·공유·GC) / 스택(메서드 프레임·스레드별) / 메타스페이스(클래스 설계도).
  • 클래스 적재: 로딩→검증(보안)→준비→해석→초기화, 처음 쓸 때.
  • 객체 헤더 12바이트(mark 8 + klass 4), 8바이트 정렬이 속도 + 압축 oops를 동시에. 자바 25는 헤더 8바이트.
  • 호출: static은 컴파일 때, virtual은 실행 때(vtable) — 다형성의 정체. 람다는 invokedynamic.
  • 실행: 인터프리터로 시작 → 핫스팟을 JIT가 기계어로 → 그동안 GC가 힙 청소. (자세한 건 다음 페이지)
동시성GC·JIT는 무대 뒤에서 — 내부와 실무 튜닝