F-LAB JAVA · 2주차 · Phase 3 · 바이트코드와 상수 풀
이 Unit을 끝내면 다음을 답할 수 있어야 한다.
"hello" 리터럴이 코드에 5번 등장하면 상수 풀에 몇 번 들어가는가?Methodref 항목 1개가 실제로 몇 개의 다른 항목을 참조하는가?#10 = Class #11 같은 항목의 의미는?Constant Pool은 ".class 파일 안에 들어있는 정적 상수 표"다.
컴파일 타임에 만들어지고, 바이트코드의 모든#N인덱스가 여기를 가리킨다.
클래스가 사용하는 모든 외부 참조 (다른 클래스, 메서드, 필드, 문자열) 가 이곳에 1번씩만 저장되며,
JVM이 클래스 로딩 시 이 표를 보고 실제 메모리 주소로 해소(resolve) 한다.
| 풀 | 위치 | 시점 | 내용 |
|---|---|---|---|
| Constant Pool | .class 파일 안 | 컴파일 타임 생성, 클래스 로딩 시 메모리 적재 | 메타 정보 (Class, Method, Field 참조 + 리터럴) |
| String Pool | Heap의 일부 (Java 7+) | 런타임 | 실제 String 객체 인스턴스 |
→ 둘 다 "풀" 이라 헷갈리지만 다른 개념.
→ Unit 1.6에서 본 것은 String Pool.
→ 이번 Unit은 Constant Pool.
| 카탈로그 시스템 | JVM |
|---|---|
| 책 (실제 자료) | 다른 클래스/메서드/필드의 실제 정의 |
카드 카탈로그 (.class 안의 카드 목록) | Constant Pool |
| 카드에 적힌 정보 | 책 제목, 저자, 청구기호 등 |
| 카드 번호 (#15) | 상수 풀 인덱스 |
| 사서가 카드 보고 책 찾기 | JVM의 Resolution (다음 Unit 3.3) |
도서관 입장에서:
→ 컴파일 타임에는 다른 클래스의 실제 주소를 모름. 카드(=심볼) 로 적어둠.
→ 클래스 로딩 시 카드를 따라가 실제 위치 찾음.
1. 두 가지 풀의 결정적 구분
2. Constant Pool이 왜 필요한가
3. 14가지 항목 타입 — 전체 카테고리
4. 항목 간 참조 — 단순 값 vs 복합 참조
5. javap -v로 상수 풀 직접 보기
6. 컴파일러가 상수 풀 만드는 과정
7. ILIC 실무 — 상수 풀 분석 사례
8. 흔한 실수 + 디버깅
9. 면접 질문 + 자기 점검
박승제씨가 1주차에서 본 String Pool과 이번 Unit의 Constant Pool은 완전히 다른 풀이다.
String Pool (Unit 1.6):
위치: Heap (런타임)
내용: String 객체의 실제 인스턴스
목적: 같은 값의 String 객체 재사용
생성: 런타임에 동적
Constant Pool (이번 Unit):
위치: .class 파일 + Method Area
내용: 클래스의 모든 외부 참조 (메타 정보)
목적: 바이트코드의 #N 인덱스 해소
생성: 컴파일 타임에 정적
.java 소스
↓ 컴파일
.class 파일
└── Constant Pool 섹션 ← (1) 여기 만들어짐
↓ JVM 클래스 로딩
Method Area
└── 클래스 Klass
└── 런타임 상수 풀 ← (2) 여기로 적재됨 (Constant Pool의 메모리 버전)
↓ 첫 String 리터럴 사용 시
Heap
└── String Pool ← (3) 여기 String 객체 생성됨
Constant Pool과 String Pool의 관계:
#10 = String "hello") 이 있음→ Constant Pool은 "재료", String Pool은 "완제품".
public class App {
public static void main(String[] args) {
String a = "hello"; // String literal
}
}
.class 파일 안:
Constant Pool:
#10 = String #16 // "hello"
#16 = Utf8 hello ← 실제 바이트
바이트코드:
0: ldc #10 // "hello"
2: astore_1
JVM의 동작:
1. ldc #10 만나면
2. Constant Pool의 #10 = String 타입, 가리키는 곳은 #16 (Utf8 "hello")
3. String Pool에 "hello" 있나? 없으면 등록
4. String Pool의 객체 참조를 Stack에 push
→ Constant Pool의 #10 항목과 String Pool의 String 객체는 다른 메모리에 있음.
→ 연결고리는 클래스 로딩 시 JVM이 만든다.
public class ShipmentService {
public Shipment findById(Long id) {
return repository.findById(id).orElseThrow();
}
}
이 코드를 컴파일하려면:
Shipment 클래스 정의 위치 알아야 함repository.findById 메서드 위치 알아야 함Optional.orElseThrow 메서드 위치 알아야 함그런데 컴파일 시점엔:
컴파일러: "실제 주소를 모르겠는데, 이름은 알겠어"
↓
.class 파일에 "이름"으로 적어둠 (= 심볼 참조)
↓
런타임에 JVM이 그 이름을 찾아 실제 주소로 해소
이게 Constant Pool의 역할:
#15)로 이 항목을 가리킴같은 참조가 코드에 100번 나와도:
public class Calc {
public void method() {
String a = "hello";
String b = "hello";
String c = "hello";
// ... 100번
}
}
Constant Pool에는 단 1번 저장:
Constant Pool:
#5 = String #6
#6 = Utf8 hello
바이트코드에서 100번 모두 #5 참조:
ldc #5
astore_1
ldc #5
astore_2
ldc #5
...
→ .class 파일 크기 절약.
→ 런타임 메모리도 절약.
String s = "hello";
String t = "hello";
boolean b = "hello".equals(s);
세 곳에서 "hello" 등장.
컴파일러가 정리:
Constant Pool:
#5 = Utf8 hello ← 실제 데이터 1번
#6 = String #5 ← String 항목 1번
세 곳의 ldc 명령 모두 #6 사용.
→ 일관성 + 효율성.
같은 문자열 리터럴이 여러 곳에 쓰이면 상수 풀에 몇 번 저장되는가?
답: 단 1번.
.class 파일 크기 + Method Area 사용량 최소화Java 8 기준 17가지, 핵심 14가지:
Constant Pool 항목 타입:
📝 텍스트/리터럴:
1. Utf8 (실제 바이트 문자열)
2. String ("hello" 같은 자바 String)
3. Integer (int 상수)
4. Float (float 상수)
5. Long (long 상수)
6. Double (double 상수)
🏷️ 클래스/타입 참조:
7. Class (클래스 또는 인터페이스)
8. NameAndType (이름 + 시그니처 쌍)
🔗 멤버 참조:
9. Fieldref (필드 참조)
10. Methodref (메서드 참조)
11. InterfaceMethodref (인터페이스 메서드 참조)
⚡ 동적 호출 (Java 7+):
12. MethodHandle (메서드 핸들)
13. MethodType (메서드 시그니처)
14. InvokeDynamic (invokedynamic용)
#5 = Utf8 hello
#6 = String #5 // 가리키는 곳: #5 = Utf8 "hello"
"hello"는 클래스명도 메서드명도 아닌 자바 String 값#10 = Class #11
#11 = Utf8 com/ilic/Shipment
/로 구분 (Shipment.class 가 아닌 com/ilic/Shipment)#15 = NameAndType #16:#17
#16 = Utf8 calculate
#17 = Utf8 (I)Ljava/math/BigDecimal;
#20 = Methodref #10.#15
#10 = Class #11 // com/ilic/Shipment
#15 = NameAndType #16:#17 // calculate:(I)Ljava/math/BigDecimal;
해독:
#20 = Method 참조#10 = Shipment#15 = calculate(int) → BigDecimal→ Methodref 1개가 결국 4개 항목 (Class + Utf8 + NameAndType + 두 Utf8)을 참조.
바이트코드:
invokevirtual #20
#20 (Methodref) ──┬──► #10 (Class) ──► #11 (Utf8) = "com/ilic/Shipment"
│
└──► #15 (NameAndType) ──┬──► #16 (Utf8) = "calculate"
│
└──► #17 (Utf8) = "(I)Ljava/math/BigDecimal;"
→ 단일 메서드 호출에 5개 항목 참여.
→ 인덱스가 많아 보이는 이유.
Runnable r = () -> System.out.println("hi");
0: invokedynamic #5, 0 ← 람다 객체 생성
Constant Pool:
#5 = InvokeDynamic #0:#6
#6 = NameAndType #7:#8
#7 = Utf8 run
#8 = Utf8 ()Ljava/lang/Runnable;
BootstrapMethods 섹션:
#0: LambdaMetafactory.metafactory
→ Unit 3.1에서 살짝 본 invokedynamic 의 비밀.
유형 1: 실제 값을 가진 항목
- Utf8 → 바이트 데이터 직접 보유
- Integer → int 값
- Float → float 값
- Long → long 값
- Double → double 값
유형 2: 다른 항목을 참조하는 항목
- String → Utf8 1개 참조
- Class → Utf8 1개 참조
- NameAndType → Utf8 2개 참조
- Methodref → Class 1개 + NameAndType 1개 참조
- Fieldref → Class 1개 + NameAndType 1개 참조
- ...
public class Shipment {
private String blNo;
public String getBlNo() { return blNo; }
public void setBlNo(String blNo) { this.blNo = blNo; }
}
세 곳에서 blNo 필드 참조 (생성자, getter, setter).
세 곳 모두 같은 Fieldref:
Constant Pool:
#5 = Fieldref #4.#15
#4 = Class #20 → Utf8 "com/ilic/Shipment"
#15 = NameAndType #16:#17
#16 = Utf8 blNo
#17 = Utf8 Ljava/lang/String;
만약 모든 정보를 Fieldref 안에 다 넣으면:
쪼개기:
→ 중복 제거 + 재사용.
calc.add(1, 2);
calc.add(3, 4);
두 곳 모두:
invokevirtual #N // 같은 인덱스
#N 항목:
#N = Methodref #C.#NT
#C = Class (Calc)
#NT = NameAndType (add:(II)I)
같은 메서드를 N번 호출해도 Constant Pool 항목은 변하지 않음.
ldc #5 // 8-bit 인덱스 (#1 ~ #255)
ldc_w #500 // 16-bit 인덱스 (큰 풀)
ldc2_w #10 // long, double 전용
큰 클래스(Constant Pool 항목이 256개 초과)는 ldc_w 자동 사용.
→ 컴파일러가 알아서 선택.
public class ShipmentCalc {
private int callCount = 0;
public BigDecimal calculate(int weight) {
callCount++;
return BigDecimal.valueOf((long) weight * 100);
}
}
javac ShipmentCalc.java
javap -c -v ShipmentCalc.class
Constant pool:
#1 = Methodref #6.#23 // java/lang/Object."<init>":()V
#2 = Fieldref #5.#24 // ShipmentCalc.callCount:I
#3 = Methodref #25.#26 // java/math/BigDecimal.valueOf:(J)Ljava/math/BigDecimal;
#4 = Class #27 // ShipmentCalc
#5 = Class #28 // ShipmentCalc
#6 = Class #29 // java/lang/Object
#7 = Utf8 callCount
#8 = Utf8 I
#9 = Utf8 <init>
#10 = Utf8 ()V
...
#23 = NameAndType #9:#10 // "<init>":()V
#24 = NameAndType #7:#8 // callCount:I
#25 = Class #30 // java/math/BigDecimal
#26 = NameAndType #31:#32 // valueOf:(J)Ljava/math/BigDecimal;
#27 = Utf8 ShipmentCalc
#28 = Utf8 ShipmentCalc
#29 = Utf8 java/lang/Object
#30 = Utf8 java/math/BigDecimal
#31 = Utf8 valueOf
#32 = Utf8 (J)Ljava/math/BigDecimal;
...
#1 = Methodref #6.#23 // java/lang/Object."<init>":()V
#6 (Class) 와 #23 (NameAndType)java/lang/Object 클래스의 <init> 메서드 (시그니처 ()V)super() 호출#2 = Fieldref #5.#24 // ShipmentCalc.callCount:I
#5 (Class) 와 #24 (NameAndType)#3 = Methodref #25.#26 // java/math/BigDecimal.valueOf:(J)Ljava/math/BigDecimal;
BigDecimal.valueOf(...) 호출 위치의 invokestaticpublic java.math.BigDecimal calculate(int);
Code:
0: aload_0
1: dup
2: getfield #2 // Field callCount:I
5: iconst_1
6: iadd
7: putfield #2 // Field callCount:I
10: iload_1
11: i2l
12: ldc2_w #40 // long 100l
15: lmul
16: invokestatic #3 // Method java/math/BigDecimal.valueOf:(J)Ljava/math/BigDecimal;
19: areturn
#2 (Fieldref → callCount) → getfield/putfield에서 사용.
#3 (Methodref → BigDecimal.valueOf) → invokestatic에서 사용.
→ 바이트코드의 모든 #N 이 풀 항목으로 풀이.
자주 보는 디스크립터:
| 디스크립터 | 의미 |
|---|---|
I | int |
J | long |
F | float |
D | double |
B | byte |
S | short |
C | char |
Z | boolean |
V | void (반환만) |
[I | int[] |
[[I | int[][] |
Ljava/lang/String; | String 객체 (참조 타입) |
[Ljava/lang/String; | String[] |
메서드 디스크립터 = (매개변수들)반환타입:
calculate(int, BigDecimal) → BigDecimal
(ILjava/math/BigDecimal;)Ljava/math/BigDecimal;
main(String[]) → void
([Ljava/lang/String;)V
toString() → String
()Ljava/lang/String;
→ 처음에 어렵지만 곧 익숙해진다.
# 옵션 1 — 바이트코드만
javap -c ShipmentCalc.class
# 옵션 2 — 상수 풀까지
javap -c -v ShipmentCalc.class
# 옵션 3 — private까지
javap -c -v -p ShipmentCalc.class
-v 옵션이 상수 풀을 보여줌.
→ Phase 3에서 가장 자주 쓸 명령은 javap -c -v -p.
.java 소스 파싱
↓ AST 생성
↓
Constant Pool Builder 시작
↓
모든 사용 자원 수집:
- 사용하는 모든 클래스 → Class 항목
- 호출하는 모든 메서드 → Methodref 항목
- 접근하는 모든 필드 → Fieldref 항목
- 모든 String 리터럴 → String 항목
- 모든 int/long/float/double 큰 상수 → 해당 항목
↓
중복 제거 + 항목 간 참조 연결
↓
인덱스 부여 (#1, #2, ...)
↓
바이트코드 생성 시 이 인덱스로 명령어 작성
↓
.class 파일에 Constant Pool 섹션 + 바이트코드 저장
String s = "hello";
컴파일러가 추가하는 풀 항목:
#X = Utf8 "hello" ← 실제 문자열 데이터
#Y = String #X ← 자바 String 개념 항목
바이트코드:
ldc #Y
astore_1
shipment.calculate(100);
컴파일러가 추가하는 풀 항목:
#A = Utf8 "com/ilic/Shipment"
#B = Class #A
#C = Utf8 "calculate"
#D = Utf8 "(I)Ljava/math/BigDecimal;"
#E = NameAndType #C:#D
#F = Methodref #B.#E
바이트코드:
aload_1 // shipment
bipush 100
invokevirtual #F
→ 메서드 호출 1번에 풀 항목 최대 6개 생성 (이미 다른 것이 있다면 재사용).
int x = 1 + 2 + 3;
int y = "Hello, " + "World";
컴파일러:
1 + 2 + 3 → 6으로 미리 계산"Hello, " + "World" → "Hello, World"로 결합Constant Pool:
#X = Integer 6
#Y = String #Z
#Z = Utf8 "Hello, World"
바이트코드:
bipush 6 // x = 6
ldc #Y // y = "Hello, World"
→ 두 개의 문자열이 풀에 각각 등록되는 게 아니라, 합쳐진 결과만 등록.
public class Demo {
String a = "test";
String b = "test";
String c = "test";
void m1() { System.out.println("test"); }
void m2() { return "test".length(); }
}
"test" 가 5번 등장.
Constant Pool:
#5 = String #6
#6 = Utf8 "test"
단 2개 항목 (String + Utf8).
5번의 사용 모두 ldc #5.
Constant Pool 최대 항목 수: 65535 (16-bit 인덱스)
매우 큰 클래스 (자동 생성 코드, 거대 enum 등)에서 한계 도달 가능:
error: too many constants in constant pool
해결:
→ 실무에서 만날 일은 매우 드물지만 알아둘 만함.
ILIC 코드베이스의 한 클래스가 비정상적으로 큼:
ShipmentEntity.class — 200KB
원인 추측: 자동 생성 코드, lombok 어노테이션 폭증.
javap -v ShipmentEntity.class | grep -c "^ #"
# Constant Pool 항목 개수
만약 50000개 이상 → 너무 많은 외부 참조 사용.
→ 분리, 리팩토링 검토.
public class Config {
private static final String DB_PASSWORD = "secret123"; // ❌
}
이 코드를 컴파일하면 Constant Pool에 "secret123" 가 평문으로 박힘.
strings Config.class | grep -i password
# secret123
→ JAR에 비밀번호 박지 말 것. 환경 변수, Vault, KMS 사용.
# 외부 라이브러리 .class를 분석해 사용하는 클래스 확인
javap -v MysteryLib.class | grep "Class " | head -20
→ "이 클래스가 어느 다른 클래스를 참조하나" 한눈에 확인.
→ 의존성 그래프 작성에 활용.
Java 8:
String s = "Hello, " + name;
Constant Pool:
#5 = String "Hello, "
바이트코드:
new StringBuilder
dup
ldc #5
invokespecial StringBuilder.<init>(String)
aload_1
invokevirtual StringBuilder.append(String)
invokevirtual StringBuilder.toString()
Java 9+:
String s = "Hello, " + name;
Constant Pool:
#5 = String "Hello, "
#6 = InvokeDynamic #0:#7
#7 = NameAndType ...
BootstrapMethods:
#0: StringConcatFactory.makeConcatWithConstants
바이트코드:
ldc #5
aload_1
invokedynamic #6 ← 런타임에 최적 전략 선택
→ 같은 코드의 .class 가 자바 버전에 따라 다르게 컴파일.
→ javap -v 로 확인 가능.
@RestController 의 진실@RestController
@RequestMapping("/api/shipments")
public class ShipmentController { ... }
javap -v 출력 일부:
RuntimeVisibleAnnotations:
0: #15(#16=s#17)
org.springframework.web.bind.annotation.RestController
1: #18(#16=s#19)
org.springframework.web.bind.annotation.RequestMapping(
"/api/shipments"
)
→ 어노테이션도 Constant Pool에 보존됨.
→ Spring이 런타임에 이 정보를 읽어 매핑 (Reflection).
data class Shipment(val blNo: String, val eta: LocalDate)
Kotlin 컴파일러가 만드는 .class:
Metadata 어노테이션 (Kotlin 전용 정보)javap -c -v Shipment.class | head -50
→ Kotlin의 마법을 직접 확인.
질문: "Constant Pool은 어디 있나요?"
❌ "Heap입니다" → String Pool과 혼동
✅ ".class 파일 안 + 로딩 시 Method Area"
기억할 것:
// ❌ 절대 시도 X
// 컴파일러는 인덱스를 자동 부여. 사용자가 제어 불가
→ 박승제씨가 직접 인덱스를 다룰 일은 없음. 읽을 때만.
public class Demo {
String a = "test";
String b = "test";
String c = "test";
}
"test"가 풀에 3번 들어갈까? → 단 1번.
컴파일러가 중복 제거.
#5 = Utf8 "hello"
#6 = String #5
Utf8 = 바이트 데이터 (텍스트)String = 자바 String 객체 개념같은 텍스트라도 용도가 다름:
Class 항목이 참조)String 항목 거쳐서 사용자동 생성 코드 (gRPC stubs, JPA metamodel 등) 시 풀 크기 폭증 가능.
error: too many constants in constant pool
→ 클래스 분할, 코드 제너레이터 설정 조정.
clazz.getName(); // "com.ilic.Shipment"
이 결과는 런타임에 Klass 정보에서 가져옴.
.class 파일의 Constant Pool에서 직접 읽지는 않음.
JVM이 미리 풀 정보를 Method Area에 로딩해두고 거기서 가져옴.
# 1. javap — 표준
javap -c -v -p Class.class
# 2. ASM Bytecode Viewer (IDE)
# 3. JBE (Java Bytecode Editor)
# 4. JOL (객체 레이아웃과 함께)
운영에서 가장 자주 쓰는 건 단연 javap.
| Q | 핵심 답변 |
|---|---|
| Constant Pool과 String Pool 차이? | 정적 메타정보(.class+Method Area) vs 동적 String 인스턴스(Heap) |
| 상수 풀 항목 14가지 카테고리? | 텍스트/리터럴, 클래스/타입, 멤버 참조, 동적 호출 |
| Methodref 1개가 실제 참조하는 항목 수? | 5개 (자기 + Class + NameAndType + 두 Utf8) |
| 같은 "hello"가 N번 등장하면 풀에 몇 번? | 1번. 컴파일러가 중복 제거 |
| Utf8과 String 항목의 차이? | 바이트 데이터 vs 자바 String 객체 개념 |
디스크립터 (I)Ljava/math/BigDecimal; 의미? | int 받아서 BigDecimal 반환하는 메서드 |
javap -v 의 v 옵션? | verbose. 상수 풀까지 표시 |
| InvokeDynamic의 사용처? | 람다, String concat, switch 패턴 |
| 어노테이션 정보도 풀에 저장되나? | 예. RuntimeVisibleAnnotations 속성 |
| 상수 풀 최대 크기? | 65535 항목 (16-bit 인덱스) |
I, J, Ljava/lang/String; 등)javap -c -v 출력의 상수 풀을 해석할 수 있다#N을 풀 항목으로 추적할 수 있다1. Constant Pool ≠ String Pool
2. 14가지 항목 타입 + 단순/복합 참조
3. 효율성과 일관성
#N이 모두 여기 가리킴이번 Unit에서 Constant Pool의 정적 구조를 봤다면, 다음은 런타임에 어떻게 실제 주소로 변환되는가.
🎯 Phase 3 — 바이트코드와 상수 풀 ★ 2주차의 정점
✅ Unit 3.1 바이트코드란 무엇인가
✅ Unit 3.2 상수 풀의 생성과 구조 ← 여기
⏭ Unit 3.3 심볼 참조
⏭ Unit 3.4 바이트코드 실전 분석
javap -c -v ShipmentService.class
이 출력을 한 줄도 빠짐없이 이해하고, 박승제씨가:
.class 파일 직접 분석✅ Phase 1 — 자바 변수 ↔ 메모리 매핑 (1.1 ~ 1.6 완주)
✅ Phase 2 — JVM 메서드 실행 메커니즘 (2.1 ~ 2.4 완주)
🎯 Phase 3 — 바이트코드와 상수 풀 (정점, 2/4 진행)
⏭ Phase 4 — G1 GC 심화
⏭ Phase 5 — 컬렉션 내부 구조
⏭ Phase 6 — Reflection & Iterator
⏭ Phase 7 — Buffer