JVM은 요청을 어떻게 실행하고 메모리를 관리할까¶
자바 코드는 클래스 로딩과 기계어 최적화, 여러 메모리 영역과 스레드를 거쳐 실행됩니다. 내부 용어를 시작 지연·메모리 부족·꼬리 지연·데이터 경쟁에 연결합니다.
같은 자바 서버라도 시작 직후에는 느리다가 빨라질 수 있고, 힙 사용량이 남아 있는데 메모리 부족으로 종료될 수 있다. 평균 응답은 안정적인데 가비지 컬렉션 구간마다 p99가 튈 수도 있다. 여러 스레드가 공유한 값은 소스 코드에 적힌 순서와 다르게 보일 수 있다.
JVM(Java Virtual Machine, 자바 가상 머신)을 배우는 이유는 실행 옵션을 많이 외우기 위해서가 아니다. 어떤 런타임 비용이 현재 증상을 만들었는지 구분하기 위해서다.
flowchart TB
C["클래스 파일의 바이트코드"] --> L["클래스 로딩·검증·초기화"]
L --> I["해석 실행"]
I -->|"자주 실행되는 경로"| J["JIT 컴파일<br/>기계어로 최적화"]
J --> CPU["CPU 코어에서 실행"]
CPU --> S["스레드별 스택<br/>현재 메서드와 지역 상태"]
S --> H["공유 힙<br/>객체와 배열"]
G["가비지 컬렉터"] -->|"도달할 수 없는 객체 회수"| H
M["자바 메모리 모델"] -->|"스레드 사이 가시성과 순서 규칙"| S
클래스는 사용되기 전에 로딩·연결·초기화를 거친다¶
클래스 로더는 이름으로 클래스 파일을 찾아 JVM에 올린다. JVM은 바이트코드 형식과 자료형 안전성을 검증하고, 정적 필드 공간을 준비하며, 다른 클래스와 메서드를 가리키는 기호 참조를 실제 대상으로 연결한다. 그 뒤 정적 초기화 코드를 실행한다.
보통 부모 클래스 로더에 먼저 물어보는 부모 위임 방식은 애플리케이션이 핵심 자바 클래스를 우연히 덮어쓰는 일을 줄인다. 플러그인이나 애플리케이션 서버처럼 클래스 로더가 여러 개면 이름이 같은 클래스도 로더가 다르면 JVM에서 다른 자료형이다. ClassCastException이 클래스 이름은 같은데도 발생하거나, 재배포 뒤 메타스페이스가 계속 늘면 로더가 이전 클래스와 객체를 붙잡고 있는지 확인한다.
정적 초기화에서 네트워크 호출이나 큰 작업을 하면 첫 요청이 그 비용을 떠안을 수 있다. 시작 시간 문제를 데이터베이스나 JIT 탓으로 돌리기 전에 어떤 클래스 초기화가 지연됐는지 나눈다.
힙만 봐서는 프로세스 메모리를 설명할 수 없다¶
힙(heap)은 객체와 배열이 주로 놓이는 공유 메모리다. 각 스레드의 스택(stack)에는 현재 실행 중인 메서드 프레임, 지역 변수와 중간 계산 상태가 있다. 메타스페이스(metaspace)는 로드된 클래스의 메타데이터를 네이티브 메모리에 둔다.
프로세스는 이 밖에도 직접 바이트 버퍼, JIT가 만든 기계어 코드, 가비지 컬렉터 자료구조와 운영체제 스레드 스택을 사용한다. 그래서 최대 힙을 뜻하는 -Xmx가 4GiB라고 프로세스 전체가 4GiB 안에 머무는 것은 아니다. 메모리 부족 오류가 나면 메시지와 함께 힙·메타스페이스·직접 버퍼·생성 가능한 네이티브 스레드 가운데 어느 영역이 바닥났는지 먼저 구분한다.
JIT는 실제로 자주 실행된 경로를 최적화한다¶
JVM은 처음부터 모든 바이트코드를 최고 수준 기계어로 만들지 않는다. 먼저 해석하거나 가벼운 컴파일로 실행하면서 호출 횟수와 실제 자료형 같은 정보를 모은다. JIT(Just-In-Time Compiler, 적시 컴파일러)는 자주 실행되는 뜨거운 경로를 인라인하고 반복문을 최적화한다.
실행 중 관측한 가정을 바탕으로 만든 최적화는 가정이 깨지면 되돌릴 수 있다. 이를 역최적화라고 한다. 따라서 서버 시작 직후와 충분히 예열된 뒤의 성능을 같은 조건으로 봐야 한다. 짧은 수동 반복문으로 마이크로벤치마크를 만들면 사용하지 않는 계산이 제거되거나 예열이 섞일 수 있으므로 JMH 같은 전용 도구가 필요한 이유도 여기에 있다.
탈출 분석(escape analysis)은 새 객체가 현재 메서드나 스레드 밖으로 전달되는지를 살핀다. 안전하다고 판단하면 객체 할당이나 잠금을 줄일 수 있다. 소스에 new가 보인다는 이유만으로 매번 실제 힙 객체가 생긴다고 단정할 수 없다.
GC는 쓰지 않는 객체보다 살아 있는 객체의 비용을 치른다¶
GC(Garbage Collection, 가비지 컬렉션)는 루트에서 더는 도달할 수 없는 객체의 메모리를 회수한다. 서버에서 중요한 값은 현재 사용량 하나가 아니다. 초당 얼마나 많은 객체를 만드는지, 수집 뒤에도 살아남는 객체 집합이 얼마나 큰지, 회수 속도가 할당 속도를 따라가는지를 함께 본다.
G1은 힙을 여러 영역으로 나누고 짧게 살아 있는 객체 수집과 오래 살아남은 영역의 혼합 수집을 조절하는 범용 수집기다. ZGC는 표시와 객체 이동의 대부분을 애플리케이션과 동시에 수행해 매우 짧은 정지를 목표로 한다. 낮은 정지가 자동으로 더 높은 처리량이나 더 작은 메모리 사용을 뜻하지는 않는다. 동시 수집에 CPU와 추가 메모리를 쓸 수 있다.
GC 정지와 p99가 같은 시각에 겹쳤다고 끝내지 말고, 객체 할당률·수집 후 살아 있는 크기·수집에 쓴 CPU·힙 밖 메모리도 확인한다. 큰 캐시처럼 계속 살아 있는 객체가 많다면 수집기 옵션보다 보관 정책이 먼저 문제일 수 있다.
자바 메모리 모델은 스레드 사이의 약속이다¶
여러 CPU 코어와 컴파일러는 결과가 바뀌지 않는 범위에서 명령을 재배치하고 값을 캐시한다. 하지만 한 스레드만 보는 결과와 다른 스레드가 관찰하는 순서는 같지 않을 수 있다. JMM(Java Memory Model, 자바 메모리 모델)은 어떤 동기화 관계가 있을 때 앞선 쓰기가 뒤의 조회에 보이는지 정한다.
핵심은 happens-before(선행 발생) 관계다. 한 모니터 잠금을 해제한 작업은 나중에 같은 잠금을 얻은 작업보다 앞선다. volatile 변수에 쓴 값은 뒤의 같은 변수 조회보다 앞서고, 스레드를 시작하기 전의 작업은 시작된 스레드 안의 작업보다 앞선다.
volatile은 가시성과 순서를 제공하지만 count++처럼 조회·계산·쓰기가 합쳐진 연산 전체를 원자적으로 만들지는 않는다. 두 스레드가 동시에 증가시키면 업데이트를 잃을 수 있다.
synchronized, Lock, CAS는 경쟁을 다루는 방식이 다르다¶
synchronized는 한 시점에 한 스레드만 임계 구역에 들어가게 하고, 잠금 해제와 획득 사이에 메모리 가시성도 만든다. 대부분의 단순한 상호 배제에는 좋은 기본값이다.
Lock은 잠금을 기다리다 중단하거나 제한 시간 뒤 포기하고, 여러 조건 대기열을 두는 등 더 세밀한 제어가 필요할 때 사용한다. 기능이 많은 만큼 모든 경로에서 반드시 해제하도록 관리해야 한다.
CAS(Compare-And-Set)는 현재 값이 예상한 값과 같을 때만 원자적으로 바꾼다. 잠금 없는 자료구조와 원자 변수의 기반이다. 경쟁이 심하면 여러 스레드가 계속 실패하고 다시 시도해 CPU를 소모할 수 있고, 공정성도 자동으로 생기지 않는다. “잠금이 없다”는 말은 “경쟁 비용이 없다”는 뜻이 아니다.
스레드 풀의 핵심은 생성 비용보다 동시 실행 상한이다¶
플랫폼 스레드는 생성과 스택 비용이 있으므로 재사용 풀을 쓴다. 하지만 서버에서 더 중요한 역할은 한꺼번에 실행할 작업 수와 기다릴 위치를 제한하는 것이다. CPU 계산은 코어 수보다 훨씬 많은 스레드가 있어도 빨라지지 않는다. 입출력 대기가 많으면 더 많은 작업을 겹칠 수 있지만, 데이터베이스 연결과 하위 서비스 처리량이 실제 상한이다.
무제한 대기열은 과부하를 해결하지 않는다. 실행하지 못한 요청을 메모리에 쌓아 시간 초과와 메모리 부족으로 늦게 드러낼 뿐이다. 현재 처리할 수 없는 요청을 어디서 거절하고, 얼마나 기다리게 하며, 하위 시스템에 몇 개까지 보낼지 함께 정해야 한다.
CompletableFuture는 비동기 계산의 연결과 실행기를 함께 봐야 한다¶
CompletableFuture는 나중에 완료될 값을 단계로 연결해 두 외부 호출을 병렬로 실행하거나 결과를 조합하게 한다. thenApply는 값을 다른 값으로 바꾸고, thenCompose는 다음 비동기 작업을 이어 중첩된 퓨처를 펼친다.
코드 모양만 보고 어느 스레드에서 실행되는지 알 수 없는 경우가 있다. Async 계열에 실행기를 지정하지 않으면 공용 ForkJoinPool을 사용할 수 있다. 그 안에서 오래 블로킹하면 다른 비동기 계산이 실행 기회를 잃는다. 운영 코드에서는 실행기와 큐 상한, 전체 마감 시각, 예외 처리와 취소 전파를 명시한다.
서로 관련된 하위 작업의 생명주기와 취소를 한 범위로 묶고 싶다면 구조적 동시성 개념이 더 자연스러울 수 있다. 다만 Java 21의 구조적 동시성 API는 미리 보기 기능이므로 정식 기능인 가상 스레드와 구분해야 한다.
가상 스레드는 기다리는 요청의 표현 비용을 줄인다¶
Java 21의 가상 스레드는 요청마다 순차적인 스레드 코드를 유지하면서, 입출력 대기 중 운영체제 스레드를 계속 붙잡지 않게 한다. CPU 계산을 빠르게 하거나 데이터베이스 연결을 늘리는 기능은 아니다. 기다리는 요청을 많이 표현할 수 있게 된 만큼 동시에 살아 있는 요청 객체와 하위 시스템 대기열을 제한하는 일이 더 중요해진다.
내부 구조와 적용 조건은 Java 17에서 21로: 가상 스레드에서 이어서 설명한다.
증상에서 출발하면 볼 지점이 달라진다¶
시작과 첫 요청만 느리면 클래스 초기화와 JIT 예열을 나눈다. 프로세스 메모리가 컨테이너 제한에 닿으면 힙 사용량만 보지 말고 직접 버퍼·메타스페이스·스레드 수를 포함한다. 일정 주기로 p99가 튀면 GC 정지, 할당 급증과 살아 있는 객체 집합을 같은 시간축에 둔다. CPU가 높은데 처리량이 늘지 않으면 CAS 재시도, 잠금 경쟁과 과도한 스레드 문맥 전환을 본다.
JVM 튜닝은 옵션을 먼저 바꾸는 일이 아니다. 객체 생성률, 살아 있는 데이터, CPU 사용, 대기와 동시 실행 상한 가운데 어느 비용이 지배적인지 측정한 뒤 그 비용을 만드는 코드와 설정을 함께 바꾸는 일이다.