들어가며
1990년대 초반, 소프트웨어 개발은 하나의 큰 제약에 묶여 있었습니다. C/C++과 같은 플랫폼 종속적인 컴파일 언어가 주류를 이루던 시절, 개발자들은 Windows용 프로그램, Linux용 프로그램, 그리고 각각의 유닉스 시스템용 프로그램을 따로 작성해야 했습니다. 같은 기능을 하는 프로그램이지만, 플랫폼이 다르면 코드를 처음부터 다시 작성하거나 대폭 수정해야 하는 것이 당연한 일이었죠.
이런 상황에서 1995년 썬 마이크로시스템즈가 내놓은 자바는 혁명적인 아이디어를 제시했습니다. “Write Once, Run Anywhere” - 한 번 작성한 코드가 어떤 플랫폼에서든 동작한다는 것이었습니다. 이는 단순한 마케팅 슬로건이 아니라, 소프트웨어 개발 패러다임을 바꾸는 기술적 혁신이었습니다.
그렇다면 자바는 어떻게 이런 플랫폼 독립성을 실현할 수 있었을까요?
그 핵심은 자바 언어 자체가 아닌, 자바 코드가 실행되는 가상의 컴퓨터, 즉 JVM(Java Virtual Machine) 에 있었습니다. JVM이라는 중간층을 두어 운영체제와 자바 애플리케이션 사이를 연결함으로써, 개발자는 더 이상 각 플랫폼의 세부사항을 걱정할 필요가 없게 되었죠.
하지만 여기서 한 가지 흥미로운 사실은, JVM 위에서 실행되는 언어가 자바만이 아니라는 점입니다. 현재 Kotlin, Scala, Groovy, Clojure 등 다양한 언어들이 JVM을 기반으로 동작하고 있습니다. 이는 JVM이 단순히 자바만을 위한 실행 환경이 아니라, 다양한 언어들의 공통 플랫폼 역할을 하고 있음을 의미합니다.
그렇다면 JVM은 정확히 어떻게 동작하는 걸까요? 왜 메모리 누수나 성능 문제에 직면했을 때 JVM을 이해해야 한다고 말하는 걸까요?
본 글에서는 이런 궁금증에 답하기 위한 첫걸음으로 JVM의 내부 구조와 각 구성 요소를 살펴봅니다. 그 중에서도 런타임 데이터 영역들이 어떻게 관리되는지, 그리고 이 모든 것이 어떻게 연결되어 우리가 작성한 코드를 실행하는지에 집중하여 알아봅니다.
자바는 어떻게 실행될까?
자바 코드는 어떻게 실행될까요? 우리가 작성한 자바 코드가 실제로 컴퓨터에서 실행되기까지는 여러 단계를 거칩니다. 자바가 실행되는 전체적인 흐름을 간단하게 살펴보겠습니다.
1단계 컴파일.
자바 코드가 실행되기 위해서는 먼저 JVM에게 자바코드를 전달해줘야합니다. 하지만 안타깝게도 JVM은 .java 로 끝나는 파일을 곧바로 해석할 수 없습니다. 왜냐하면 .java 파일은 사람이 읽기 쉽게 작성된 고수준 언어이기 때문입니다.
대신에 JVM이 이해할 수 있는 JVM 전용 언어로 먼저 변환이 되어야하는데 이를 .class 바이트코드라 부릅니다. 이때 이 바이트코드로 변환을 담당해주는 작업을 컴파일이라고 하고 이 역할은 자바 컴파일러(javac)가 담당합니다. `
javac Hello.java // Hello.class 생성.
이 바이트코드는 사람이 읽기는 어렵지만, JVM이 이해할 수 있는 중간 언어입니다. JVM은 이렇게 변환된 코드를 받아서 각 플랫폼에 맞게 해석하고 실행할 수 있습니다. 이러한 작업 덕분에 플랫폼에 구애받지 않고 코드를 실행할 수 있게 되죠.
2단계 로딩.
바이트 코드가 준비되었다면 비로소 JVM에 전달될 수 있게 됩니다. 이 과정을 “클래스 로딩”이라고 부르며, 클래스 로더가 이 역할을 담당합니다.
흥미로운 점은 JVM이 애플리케이션 실행 시 모든 클래스를 한번에 메모리에 올리지 않는다는 것입니다. 대신 필요할 때마다 동적으로 로딩합니다. 예를 들어, 조건문 안에 있는 클래스는 그 조건이 참일 때만 메모리에 로드됩니다. 이런 동적 로딩 방식이 자바의 유연성과 확장성을 만들어내는 핵심 요소입니다.
클래스 로더는 이외에도 더 세부적으로 나뉘지만 이 글에서는 다루지 않습니다.
3단계 실행.
클래스가 로딩되면 JVM의 실행 엔진이 바이트코드를 해석하여 실제로 실행합니다. 이 과정에서 바이트코드가 각 플랫폼에 맞는 기계어로 변환되어 CPU에서 실행될 수 있게 됩니다.
JVM의 실행엔진은 크게 2가지 방식으로 동작합니다.
먼저 인터프리터 방식입니다. 인터프리터는 바이트코드를 한 줄씩 읽어가며 기계어로 변환하고 즉시 실행하는 방식입니다.마치 동시 통역사가 발언을 실시간으로 번역하는 것과 같습니다. 이 인터프리터 덕분에 같은 바이트코드라도 플랫폼에 따라 적절한 기계어로 변환되어 실행될 수 있습니다. 만약 이런 방식이 없다면 각 플랫폼마다 별도의 실행 파일을 만들어야 했을 것입니다.
하지만 인터프리터 방식은 한 줄씩 해석하면서 실행하기 때문에 미리 컴파일 해놓고 실행하는 방식보다는 실행속도 측면에 상대적으로 느립니다. 특히 같은 코드가 반복 실행될 때마다 매번 번역 과정을 거쳐야 하는 비효율성이 있습니다.
그래서 이를 JIT 컴파일러가 보완을 해줍니다. JIT 컴파일러는 자주 실행되는 코드(핫스팟) 을 감지하여 미리 최적화된 기계어로 컴파일해서 저장해둡니다. 단골손님을 위해 미리 음료를 타두는 것과 같은 느낌입니다.
// 반복되는 코드 - JIT 컴파일러가 최적화 대상으로 인식
for (int i = 0; i < 1000000; i++) {
int result = calculate(i); // 이 부분이 핫스팟이 됨
}
덕분에 JIT 컴파일러에 의해 최적화된 부분에서는 인터프리터 대신 미리 기계어로 컴파일된 부분을 바로 실행하므로 속도가 빨라지게 되죠.
더 자세한 내용은 왜 JVM은 인터프리터와 컴파일러 두 가지 방식을 사용할까?
JVM 구조
JVM의 크게 다음과 같은 구조로 나뉩니다.
(컴파일 -> 로딩 -> 실행)
클래스 로더.실행 엔진. 런타임 데이터 영역.
로드해둔 클래스 바이트코드들은 어디에 있을까요? 실행되는 동안 데이터는 어디서 관리될까요? 그러면 어디에다가 저장해둘까요?
런타임 데이터 영역은?
클래스 로더를 통해 자바 소스코드를 컴파일한 바이트 코드를 런타임 데이터 영역에 로딩한다. 애플리케이션이 동작하는 동안 필요한 클래스, 코드, 데이터들이 적재되는 영역이 바로 런타임 데이터 영역이다. 그리고 이 런타임 데이터 영역은 JVM의 실행 엔진과 상호작용하며 실행과 정리와 같은 일들이 이루어진다.
이 런타임 데이터 영역을 인지하는 것이 왜 중요할까? 다음 질문들을 통해서 JVM이 가지는 런타임 데이터 영역에 대한 의미와 런타임 데이터 영역에 대한 이해를 높여보는 것이 이 글의 목표이다.
만약 JVM에 메모리 영역 구분이 없다면 어떤 일이 일어날까?
모든 데이터가 하나의 큰 메모리 공간에 뒤섞여 있다면, 어떤 문제들이 발생할까? 지역변수, 객체, 메서드 정보, 클래스 정보가 하나의 큰 통 메모리 공간에 한꺼번에 들어가 있다면 어떤 문제가 발생할까?
public void calculateSum() {
int a = 10;
int b = 20;
int result = a + b;
Person person = new Person("김철수");
}
간단한 코드를 통해 예시를 들어보자. 만일 메모리 영역에 대한 구분이 없다면, 위 코드에서 볼 수 있는 지역변수, 객체, 메서드의 실행 정보, 클래스 정보들이 하나의 메모리 영역에 뒤섞여 있을 것이다.
이제 잠시 JVM의 입장이 되어서 다음 질문에 대답해보는 약간의 상상력을 발휘해볼 수 있다.
- 이 메서드가 종료되고나서
a,b,result와 같은 더 이상 사용되지 않는 지역 변수들은 언제 사라져야할까? person객체는 이 메서드가 종료될때 같이 정리되어야할까? 그렇지 않다면 언제 종료되어야할까?
메모리 영역이 구분되지 않은 상태에서 JVM이 “어떤 것을 언제 정리해야할지?” 어떻게 판단할 수 있을까? 그리고 이런 판단 과정은 성능에 어떤 영향을 미칠까?
생각할 수 있는 가장 러프한 아이디어는 코드를 실행할 때마다 매번 메모리 영역을 풀 스캔하면서 더 이상 사용되지 않는 데이터를 찾아 정리하는 것이다. 그렇게 되면 자연스럽게 데이터의 사용여부에 사용되는 시간 소요가 추가로 발생하고, 이는 성능에 영향에 끼치지 않을까? 이를 조금 더 구체적으로 정리하면 다음과 같다.
영역 구분이 없을 경우:
- 모든 메모리를 스캔해서 “데이터가 아직 사용되는지?”
- 객체의 참조 관계를 추적하기 위해 전체 메모리 탐색이 필요해진다.
- 정리할때 마다 모든 데이터의 생명 주기 계산이 필요해진다.
하지만 영역이 구분되면 이런 문제들을 보다 쉽게 해결할 수 있다. 그렇다면 왜 Stack, Heap, Method 이 3가지 영역으로 나누었을까?
왜 이렇게 추상화를 한걸까?
메모리 관리를 대신 해준다.
JVM의 런타임 데이터 영역은 OS의 메모리 구조 위에 한 겹 더 추상화된 구조를 만드는 것이고, 그 주된 목적은 메모리 관리를 자바가 직접 담당하기 위함이야. 이걸 조금 더 구조적으로 풀어볼게.
어? 파이썬도 메모리 관리 안해주는데요?
덕분에 고 수준 기능들을 지원해줌. GC, 리플렉션, 동적 클래스 로딩 같은 고수준 기능 지원
-
GC는 객체 생존 여부 추적을 위한 메모리 추상화가 전제되어야 함
-
리플렉션은 클래스 메타정보를 따로 관리하는 Method Area 같은 구조가 있어야 가능
왜 하필 3가지?
Java의 구성 요소들의 특성을 생각해보면, 이 영역들을 구분하므로써 얻을 수 있는 장점들이 보다 명확해진다. 다음 질문들에 대답해본다.
- 지역 변수은 언제 사용이 시작되고 정리되어야할까?
- 객체는 언제 사용이 시작되고 정리되어야할까?
- 클래스 정보는 언제 사용이 시작되고 정리되어야할까?
이 구성요소들이 가지는 특성을 이해하고 영역을 구분하면 그에 맞춘 효율적인 메모리 관리가 가능해진다.
생명주기 관리를 용이하게, 메모리 영역을 효율적으로 사용하기 위해 각 영역이 나누어졌다고 생각함.
- “런타임 데이터 영역은 자바의 추상화된 메모리 관리의 기초 뼈대이며, JVM의 GC, 스레드 구조, 클래스 로딩과 깊게 연결된다.”
Method Area
클래스로더에 의해 클래스 정보(메타데이터)들이 저장되는 공간이다. 클래스 단위의 정보들이 저장되는 공간.
이 영역은 모든 스레드들과 공유되는 공간이다.
여기에 저장되는 내용들은 다음과 같음 .
- 클래스 정보. (타입, )
- 필드 정보 (이름, 데이터 타입, 접근제어자)
-
메서드 정보. (이름, 매개변수, 리턴 타입, 접근제어자.)
- 정적 변수
- 클래스의 로딩 정보.
- 런타임 상수 풀. (클래스, 인터페이스가 사용하는 상수와 심볼 정보)
메서드영역은 Java8 이전과 이후로 관리되는 영역에서 변경점이 생겼음. Java 8 이전에는 PermGen이라고 하는 JVM 내부의 고정된 영역에서 메서드 영역이 관리되었음. 메서드 영역(Method Area) 구현체라고 이해하면 편함.
2가지 영역으로 구분되어 관리됨.
heap, metaspace.
- Strping Pool, static변수는 힙 영역
메서드 영역은 정리되지 않나? 언제 언로딩되는가?
힙처럼 자주 정리되지는 않고 제한된 상황에서만 GC에 의해서 정리됨.
- 클래스 자체에 대한 참조
Class.forName()결과를 변수에 저장- 리플렉션으로 얻은 Class 객체
- 클래스의 인스턴스들
- 해당 클래스로 생성된 모든 객체들
- ClassLoader에 대한 참조
- 해당 클래스를 로딩한 ClassLoader
언로딩 조건: 위 3가지가 모두 참조되지 않을 때만 클래스 언로딩이 가능하다.
Stack 영역.
앞서 영역을 별도로 분리하는 것은 각 구성 요소들의 수행되는 방식에 따른 효율적 관리를 위해서라고 했음.
그 중에서 지역변수와 메서드의 실행과 관련된 부분은 Stack 영역으로 분리되어서 관리된다. 그러면 왜 하필 stack이라는 자료구조로 동작하는 것일까?
이는 메서드의 실행이 어떻게 이루어지는지를 파악하면 왜 이들을 관리하기 위한 영역이 Stack 자료구조의 동작방식을 따르는지, 왜 Stack이라고 부르는지 알 수 있음.
메서드의 호출 방식
메서드는 기본적으로 중첩 구조를 가짐. 가장 먼저 시작된 메서드는 가장 나중에 종료가 됨. 그리고 메서드는 실행되는 동안 자기만의 영역을 가짐. 이 영역에서 사용되는 구성 요소가 지역 변수임.
methodA 시작 ────────────────────────── methodA 종료
└─ methodB 시작 ─────────── methodB 종료
└─ methodC 시작 ── methodC 종료
┌─────────────────────────────┐
│ methodA 스택 프레임 │
├─────────────────────────────┤
│ • Local Variables (result) │
│ • Return Address │
│ • Operand Stack ← 여기가 핵심! │
└─────────────────────────────┘
Stack Area는 “스레드별로 독립적”이라고 했잖아요. 그런데 왜 하필 “스택(Stack)” 자료구조를 사용했을까요?
Heap
만약 수만 개의 스레드가 동시에 new 키워드로 객체를 생성한다면 어떨까요?
- 메모리 할당 충돌: 여러 스레드가 동시에 같은 메모리 영역을 할당받으려 하면?
- 성능 문제: 매번 동기화하면 너무 느려지지 않을까요?
구조를 알아야하는 이유는 뭘까?
이 영역의 동작을 이해하면 어떤 문제 해결에 유리해질까?