F-LAB JAVA · 2주차 · Phase 1 · 자바 변수 ↔ 메모리 영역의 매핑
이 Unit을 끝내면 다음을 답할 수 있어야 한다.
int sum = a + b; 한 줄이 Operand Stack에서 어떻게 처리되는가?StackOverflowError가 발생하는 정확한 메커니즘은?Stack은 "메서드 호출의 발자취"를 담는 LIFO 자료구조다.
호출 한 번 = 프레임 push, 반환 한 번 = 프레임 pop.
각 프레임 안에는 지역 변수 배열 · 피연산자 스택 · 프레임 데이터가 있고,
JVM의 모든 산술/논리 연산은 피연산자 스택 위에서 일어난다.
| Stack 요소 | 비유 |
|---|---|
| Stack | 책상 위에 쌓은 메모지 더미 (LIFO) |
| Stack Frame | 메모지 1장 (메서드 1번 호출 = 1장) |
| Local Variable Array | 메모지의 상단 — 변수 이름과 값 (이 회의의 참석자/안건) |
| Operand Stack | 메모지의 하단 — 계산용 임시 메모 (계산 중인 숫자) |
| Frame Data | 메모지의 모서리 — 끝나면 어디로 돌아갈지 표시 |
| PC Register | 펜이 가리키는 줄 — "지금 여기 읽는 중" |
회의(메서드)가 끝나면 그 메모지(프레임)를 통째로 버리고, 이전 회의 메모지를 다시 봄.
1. Stack 복습 + 정밀화 — 1.2/1.3에서 본 것 위에 쌓기
2. 스택 프레임의 3가지 구성 요소
3. Local Variable Array — 지역 변수의 진짜 저장 방식
4. Operand Stack — JVM은 스택 머신이다
5. PC Register와 명령 추적 — 한 줄씩 명령을 따라가는 메커니즘
6. 메서드 호출과 반환의 전체 흐름
7. 멀티스레드와 Stack — 컨텍스트 스위칭의 핵심
8. ILIC 실무 + 디버깅 — Stack Trace · Thread Dump 읽기
9. 면접 질문 + 자기 점검
Stack:
┌───────────────────────┐
│ add() 프레임 │ ← 현재 실행 중
│ 매개변수, 지역변수 │
├───────────────────────┤
│ run() 프레임 │
├───────────────────────┤
│ main() 프레임 │
└───────────────────────┘
→ 메서드 호출마다 프레임이 쌓이고, 종료하면 사라진다.
→ 지역 변수와 매개변수가 그 안에 있다.
이번 Unit에서 정밀화할 것:
Stack Frame 1개:
┌────────────────────────────────────┐
│ 1. Local Variable Array │ ← 매개변수 + 지역 변수 (배열 형태)
│ [0]: this │
│ [1]: weight (매개변수) │
│ [2]: base (지역변수) │
│ [3]: ... │
├────────────────────────────────────┤
│ 2. Operand Stack │ ← 계산용 임시 스택
│ (현재 비어있음 또는 일부 값) │
├────────────────────────────────────┤
│ 3. Frame Data │ ← 메타 정보
│ Return Address: 호출자의 PC │
│ 상수 풀 참조 등 │
└────────────────────────────────────┘
→ 이 3개가 모든 스택 프레임의 표준 구조.
지역 변수와 매개변수의 저장소. 배열 형태로 관리.
public BigDecimal calculate(int weight, BigDecimal rate) {
BigDecimal base = new BigDecimal("100");
BigDecimal result = base.multiply(rate);
return result;
}
LVA의 모습:
Local Variable Array (인덱스 기반):
[0] this ← 인스턴스 메서드의 숨겨진 첫 매개변수
[1] weight ← 매개변수 1
[2] rate ← 매개변수 2
[3] base ← 지역 변수
[4] result ← 지역 변수
static 메서드면
[0]부터 첫 매개변수가 시작.
바이트코드에서의 모습 (Phase 3 미리보기):
iload_1 ← LVA[1] 로드 (weight)
aload_2 ← LVA[2] 로드 (rate)
astore_3 ← LVA[3]에 저장 (base)
iload_X, astore_X 같은 명령이 LVA의 인덱스로 변수에 접근한다.
JVM이 산술/논리 연산을 수행하는 임시 작업 공간.
int sum = 1 + 2;
Operand Stack 동작:
Step 1: iconst_1 [1] ← 1을 푸시
Step 2: iconst_2 [1, 2] ← 2를 푸시
Step 3: iadd [3] ← 위 2개를 꺼내 더한 값 푸시
Step 4: istore_1 [] ← 결과를 LVA[1]에 저장
핵심: JVM은 레지스터 머신이 아니라 스택 머신.
add eax, ebx (레지스터끼리 직접 연산)값을 스택에 푸시 → 연산 명령 → 결과가 스택에 남음프레임의 메타 정보가 들어가는 영역.
→ 이 영역은 JVM 구현체마다 세부 구조가 다름. 개념적으로 이해하면 충분.
public class App {
public static int add(int a, int b) {
int sum = a + b;
return sum;
}
}
add(10, 20) 호출 직후, 끝나기 직전의 프레임:
add() 프레임:
┌──────────────────────────────────┐
│ Local Variable Array │
│ [0] a = 10 │ ← (static이라 this 없음)
│ [1] b = 20 │
│ [2] sum = 30 │
├──────────────────────────────────┤
│ Operand Stack │
│ [bottom] ... [top: 30] │ ← ireturn 직전, 반환값 푸시 상태
├──────────────────────────────────┤
│ Frame Data │
│ Return Address → 호출자 PC │
│ 상수 풀 참조 │
└──────────────────────────────────┘
JVM 입장에서 변수 이름은 컴파일 시점에 사라진다. 바이트코드에는 인덱스만 남는다.
public void process(int x, int y) {
int sum = x + y;
String msg = "result";
}
LVA:
[0] this ← 인스턴스 메서드의 숨은 매개변수
[1] x ← 첫 매개변수
[2] y ← 두 번째 매개변수
[3] sum ← 지역 변수 1
[4] msg ← 지역 변수 2
→ "x" 라는 이름은 디버깅 정보에만 남음 (javac -g).
→ 실제 실행은 인덱스로.
public void method(int a, long b, double c, int d) {}
LVA:
[0] this
[1] a ← int, 1칸
[2] b ← long, 2칸 차지 시작
[3] " ← (b의 두 번째 칸)
[4] c ← double, 2칸 차지 시작
[5] " ← (c의 두 번째 칸)
[6] d ← int, 1칸
→ long과 double은 64비트라 2개의 슬롯 사용.
→ 면접에선 잘 안 나오지만, 바이트코드 읽을 때 알아두면 좋음.
public void method() {
{
int a = 10; // LVA[1] 사용
} // 블록 끝 → a 사라짐
{
String b = "x"; // LVA[1] 재사용 가능!
}
}
LVA의 슬롯은 스코프가 끝나면 재사용된다.
→ 메서드 안에서 변수 100개 선언해도, 같은 시점에 살아있는 것만 슬롯 차지.
→ 컴파일러가 최적화.
javac -g App.java # 디버그 정보 포함
javap -v -p App.class
출력 일부:
LocalVariableTable:
Start Length Slot Name Signature
0 6 0 this LApp;
0 6 1 x I
0 6 2 y I
3 3 3 sum I
→ Slot이 LVA 인덱스, Signature가 타입(I=int, L...;=object).
→ IDE 디버거가 보여주는 변수 이름의 출처.
JVM의 모든 계산은 피연산자 스택 위에서 일어난다.
레지스터 머신 (x86 CPU):
add eax, ebx ; eax = eax + ebx
스택 머신 (JVM):
iload_1 ; 첫 번째 값을 스택에 푸시
iload_2 ; 두 번째 값을 스택에 푸시
iadd ; 위 두 개를 꺼내 더하고 결과를 푸시
istore_3 ; 스택 top을 LVA[3]에 저장
장점:
단점:
| 카테고리 | 명령어 예 | 동작 |
|---|---|---|
| 상수 로드 | iconst_0, iconst_1, bipush 100 | 스택에 상수 푸시 |
| 변수 로드 | iload_1, aload_2, lload_3 | LVA → 스택 푸시 |
| 변수 저장 | istore_1, astore_2 | 스택 pop → LVA 저장 |
| 산술 | iadd, isub, imul, idiv | 스택 top 2개 연산 |
| 메서드 호출 | invokestatic, invokevirtual, invokespecial | 스택 인자 사용, 결과 푸시 |
| 반환 | ireturn, areturn, return | 스택 top을 호출자 스택으로 |
| 분기 | ifeq, ifne, goto | PC 점프 |
i = int, a = address(reference), l = long, f = float, d = double.
int sum = a + b;public int add(int a, int b) {
int sum = a + b;
return sum;
}
바이트코드:
0: iload_1 // a 로드
1: iload_2 // b 로드
2: iadd // a + b
3: istore_3 // sum에 저장
4: iload_3 // sum 로드
5: ireturn // 반환
add(10, 20) 호출 시 Operand Stack 변화:
LVA: [this=ref, a=10, b=20, sum=?]
PC=0: iload_1
Stack: [10] ← a를 푸시
PC=1: iload_2
Stack: [10, 20] ← b를 푸시
PC=2: iadd
Stack: [30] ← 10과 20을 꺼내 더하고 30 푸시
PC=3: istore_3
Stack: [] ← 30 꺼내서 LVA[3]에 저장
LVA: [this, 10, 20, 30]
PC=4: iload_3
Stack: [30] ← LVA[3] 푸시
PC=5: ireturn
Stack: [] (호출자 스택에 30 푸시) ← 30을 반환
→ 모든 연산이 스택 푸시 / 팝 패턴.
String s = "Hello".toUpperCase();
바이트코드:
0: ldc "Hello" // 상수 풀에서 "Hello" 로드
2: invokevirtual #N // toUpperCase // top의 String 객체로 메서드 호출
5: astore_1 // 결과 저장
PC=0: ldc "Hello"
Stack: ["Hello"]
PC=2: invokevirtual toUpperCase
- "Hello" pop
- toUpperCase() 호출 (새 프레임 push)
- 반환값 "HELLO" 를 우리 스택에 push
Stack: ["HELLO"]
PC=5: astore_1
Stack: []
LVA: [this, "HELLO"]
→ 메서드 호출 = 인자를 스택에 쌓고 invoke 명령 → 호출된 메서드는 그 인자들을 LVA로 받음 → 반환값은 호출자 스택에 다시 push.
Program Counter Register — JVM이 현재 실행 중인 바이트코드 명령의 위치를 가리키는 포인터.
add() 메서드의 바이트코드:
0: iload_1 ◄── PC가 여기를 가리키는 중
1: iload_2
2: iadd
3: istore_3
...
명령이 실행될 때마다 PC가 다음 명령으로 이동.
분기(if, goto, return)는 PC를 다른 위치로 점프시킴.
Thread 1 Thread 2
PC = method1+5 PC = method2+12
↓ ↓
각자 다른 명령을 동시에 실행
각 스레드는 자기만의 실행 위치를 가진다.
공유하면 → 모두가 같은 위치에서 같은 명령을 실행 → 동시성 의미 없음.
→ Stack과 PC Register는 같이 스레드별. 한 세트.
public void caller() {
int x = 10; // PC=0~2
int y = callee(); // PC=3~5 (invoke)
use(y); // PC=6~...
}
public int callee() {
return 42;
}
호출 흐름:
1. caller PC=3 — invokestatic callee
2. Frame Data에 Return Address = 6 저장 (호출자의 다음 PC)
3. callee 프레임 push, PC = callee의 0번
4. callee 실행 완료
5. ireturn — 반환값을 caller의 Operand Stack에 push
6. callee 프레임 pop
7. caller로 복귀 — PC를 Return Address (=6) 로 복원
8. caller PC=6부터 계속
→ Frame Data의 Return Address가 정확히 이 메커니즘을 위해 존재.
public native void method(); // JNI
Native 메서드 실행 중에는 PC Register는 undefined.
→ JVM 바이트코드가 아닌 C/C++ 코드를 실행 중이라, JVM의 PC가 의미 없음.
→ JNI 호출 정보는 Native Method Stack에 따로 저장.
운영 중인 JVM의 현재 명령 위치는 thread dump에서 확인 가능.
jstack <PID>
출력:
"http-nio-8080-exec-1" #25 daemon prio=5 tid=0x... nid=0x... runnable
java.lang.Thread.State: RUNNABLE
at java.net.SocketInputStream.socketRead0(Native Method)
at java.net.SocketInputStream.socketRead(SocketInputStream.java:116)
at java.net.SocketInputStream.read(SocketInputStream.java:171)
...
→ 각 줄이 스택의 한 프레임.
→ "현재 PC가 어느 메서드의 어느 줄을 가리키는가" 의 시각화.
caller() {
...
int result = callee(10, 20);
...
}
인자 준비: caller의 Operand Stack에 인자 push
Caller Stack: [10, 20]
invoke 명령 실행: invokestatic, invokevirtual 등
새 프레임 생성: callee의 프레임 push
Stack:
┌────────────┐
│ callee │ ◄── 새 프레임
├────────────┤
│ caller │
└────────────┘
인자 이동: caller의 Operand Stack top → callee의 LVA
callee LVA: [10, 20]
Caller Stack: []
Return Address 저장: callee의 Frame Data에 caller의 다음 PC 저장
PC 이동: PC를 callee의 첫 명령으로
callee 실행 시작
callee의 마지막: ireturn
반환값 결정: callee의 Operand Stack top이 반환값
callee Stack: [30] ← 반환할 값
프레임 pop: callee 프레임 제거
PC 복원: Frame Data의 Return Address → PC
반환값 push: caller의 Operand Stack에 30 push
Caller Stack: [30]
caller 계속: PC가 가리키는 곳부터 실행 (예: istore_X)
public void log(String msg) {
System.out.println(msg);
return; // 또는 생략
}
→ 반환값 없음. 그냥 프레임 pop + PC 복원.
→ caller의 Operand Stack에는 아무것도 push되지 않음.
public int divide(int a, int b) {
return a / b; // b=0이면 ArithmeticException
}
→ 예외도 Stack을 거꾸로 거슬러 올라간다.
→ 이게 Stack Trace의 정체.
Process (JVM)
├─ Thread A ──── Stack A + PC A
├─ Thread B ──── Stack B + PC B
└─ Thread C ──── Stack C + PC C
모두 공유:
Heap + Method Area
각 스레드는:
공유하는 것:
→ 그래서 멀티스레드 환경에서 Heap의 객체 접근만 동기화하면 됨.
OS가 한 스레드에서 다른 스레드로 CPU를 넘기는 작업.
Thread A 실행 중:
CPU 레지스터 ← Thread A의 PC, SP, 작업 데이터
CPU 컨텍스트 스위칭:
1. Thread A의 CPU 상태를 메모리에 저장
2. Thread B의 저장된 CPU 상태를 레지스터에 복원
3. Thread B 실행 시작
각 스레드가 자기 Stack과 PC를 따로 가지고 있어야 컨텍스트 스위칭이 가능.
@RestController
public class ShipmentController {
private final ShipmentService service; // 모든 스레드 공유
@GetMapping("/{id}")
public ShipmentResponse get(@PathVariable Long id) {
// 이 메서드는 톰캣의 worker thread 풀의 어떤 스레드가 실행
// - 각 요청마다 새 Stack 프레임
// - id는 그 스레드의 Stack에만 존재
return ShipmentResponse.from(service.findById(id));
}
}
HTTP 요청 100개 동시 → 톰캣 worker 100개 (또는 NIO + 적은 worker)
각 worker:
자기 Stack
- id 변수
- shipment 지역 변수
자기 PC
- 지금 어디 실행 중인지
공유:
ShipmentController 인스턴스 (Heap)
ShipmentService 인스턴스 (Heap)
Shipment 클래스 정보 (Method Area)
private static final ThreadLocal<String> userContext = new ThreadLocal<>();
// 스레드 A에서
userContext.set("user-001");
// 같은 스레드 A의 다른 메서드에서
userContext.get(); // "user-001"
// 스레드 B에서
userContext.get(); // null ← 다른 스레드는 못 봄
→ Stack의 지역 변수는 메서드 종료 시 사라짐.
→ ThreadLocal은 스레드 단위로 살아남는 변수.
→ Spring Security, 트랜잭션 등에서 활용.
⚠️ 누수 위험: ThreadLocal은 Heap에 저장됨. Worker thread가 재사용되는 톰캣 환경에서 remove() 안 하면 메모리 누수.
try {
userContext.set(user);
process();
} finally {
userContext.remove(); // ★ 반드시
}
→ 1주차 try-with-resources 패턴과 연결.
java.lang.NullPointerException
at com.ilic.shipment.ShipmentService.calculate(ShipmentService.java:45)
at com.ilic.shipment.ShipmentController.get(ShipmentController.java:23)
at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
...
읽는 법:
at = "여기에서 호출했음"각 줄은 스택 프레임 1개. 예외 발생 시 위에서 아래로 Stack을 dump한 것.
java.lang.RuntimeException: 운임 계산 실패
at com.ilic.shipment.FareCalculator.compute(FareCalculator.java:78)
at com.ilic.shipment.ShipmentService.calculate(ShipmentService.java:45)
...
Caused by: java.sql.SQLException: ORA-01017
at oracle.jdbc.driver.OraclePreparedStatement.execute(...)
...
Suppressed: java.io.IOException: connection reset
at ...
→ 운영에서 Caused by를 추적해 진짜 원인 찾기.
# 1. JVM PID 확인
jps -l
# 2. Thread dump 생성
jstack <PID> > thread-dump.txt
# 3. 시점을 여러 번 떠서 비교 (3~5초 간격, 3번 정도)
jstack <PID> > dump1.txt
sleep 3
jstack <PID> > dump2.txt
sleep 3
jstack <PID> > dump3.txt
"Thread-A" #11 prio=5 ... waiting for monitor entry [...]
java.lang.Thread.State: BLOCKED (on object monitor)
at com.ilic.LockService.methodA(LockService.java:30)
- waiting to lock <0x000000076c123000> (a java.lang.Object)
- locked <0x000000076c456000> (a java.lang.Object)
"Thread-B" #12 prio=5 ... waiting for monitor entry [...]
java.lang.Thread.State: BLOCKED (on object monitor)
at com.ilic.LockService.methodB(LockService.java:50)
- waiting to lock <0x000000076c456000> (a java.lang.Object)
- locked <0x000000076c123000> (a java.lang.Object)
해독:
jstack은 데드락을 자동 감지해서 출력 끝에 표시:
Found one Java-level deadlock:
=============================
"Thread-A": waiting to lock monitor 0x00..., which is held by "Thread-B"
"Thread-B": waiting to lock monitor 0x00..., which is held by "Thread-A"
운영 장애 흔한 원인:
BLOCKED → 락 경합WAITING (on object monitor)RUNNABLE이지만 socketRead 멈춤ILIC 시나리오:
public BigDecimal calculateRecursive(int depth) {
return calculateRecursive(depth + 1); // 무한 재귀
}
발생:
Exception in thread "main" java.lang.StackOverflowError
at ShipmentService.calculateRecursive(ShipmentService.java:10)
at ShipmentService.calculateRecursive(ShipmentService.java:10)
at ShipmentService.calculateRecursive(ShipmentService.java:10)
...
(수천 줄)
해결 옵션:
1. 로직 수정: 종료 조건 추가
2. 반복문 변환: 재귀를 while/for로
3. -Xss 증가: 깊은 재귀가 필요한 경우 (최후의 수단)
java -Xss2m MyApp # 스택 크기 2MB로
# Stack 크기
-Xss512k # 기본 약 512KB ~ 1MB
# Thread Dump를 시그널로 받기 (Unix)
kill -3 <PID> # JVM이 stderr에 thread dump 출력
# Heap dump on OOM (1.2 Unit에서 본 것)
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/var/log/app/
# Async-profiler 같은 도구도 Stack 정보 활용
| Q | 핵심 답변 |
|---|---|
| 스택 프레임의 3가지 구성 요소? | Local Variable Array · Operand Stack · Frame Data |
| JVM이 스택 머신이라는 의미? | 모든 산술 연산이 Operand Stack의 push/pop으로 일어남 |
int sum = a + b의 바이트코드? | iload_1 → iload_2 → iadd → istore_3 |
| Local Variable Array의 [0]은 보통 무엇? | 인스턴스 메서드의 this (static이면 첫 매개변수) |
| PC Register가 스레드별인 이유? | 각 스레드가 독립적으로 다른 명령을 실행 중. 공유하면 동시성 의미 없음 |
| 메서드 호출 시 Return Address는 어디에? | 새 프레임의 Frame Data에 저장 (호출자의 다음 PC) |
| 메서드 반환 시 반환값 전달? | callee의 Operand Stack top → caller의 Operand Stack에 push |
| StackOverflowError vs OutOfMemoryError 차이? | 전자는 Stack 영역, 후자는 Heap/Metaspace 등 다른 영역 |
| Thread Dump를 어떻게 읽나? | 위→아래가 호출 역순. 최상단이 현재 실행 위치 |
long이 LVA에서 2슬롯 차지하는 이유? | 64비트라 32비트 슬롯 2개 사용 |
int sum = a + b의 바이트코드를 추적할 수 있다1. 스택 프레임 = LVA + Operand Stack + Frame Data
2. JVM의 모든 연산은 Operand Stack 위에서
3. 멀티스레드 = 스레드별 Stack + PC
이번 Unit에서 메서드가 Stack에서 어떻게 실행되는지 봤다면, 다음은 Heap의 객체가 자기 클래스 메서드를 어떻게 찾아가는지 정밀하게 본다.
invokevirtual vs invokespecial vs invokestatic vs invokeinterface이번 Unit에서 iload_1, iadd, istore_3 같은 바이트코드를 자연스럽게 봤다.
Phase 3에서는 이런 명령들을 javap -c -v로 직접 디컴파일하면서 상수 풀과 함께 읽는다.
이번 Unit의 추상화 수준:
iload_1 → "LVA[1] 값을 스택에 푸시"
Phase 3에서 보게 될 수준:
3: invokespecial #1 // Method java/lang/Object."<init>":()V
→ #1이 상수 풀의 1번 항목 → "java/lang/Object"의 "<init>"의 시그니처 "()V"