F-LAB JAVA · 2주차 · Phase 1 · 자바 변수 ↔ 메모리 영역의 매핑
🏁 Phase 1 마지막 Unit
이 Unit을 끝내면 다음을 답할 수 있어야 한다.
String a = "abc"; String b = "abc"; 일 때 String 객체는 메모리에 몇 개?"abc" 리터럴과 new String("abc") 의 메모리 차이는?String.intern() 은 정확히 무엇을 하는가?-128 ~ 127 경계의 의미는?Literal Pool은 "자주 쓰이는 불변 값을 재활용하기 위한 캐시"다.
String 리터럴, 작은 Integer, Boolean 등이 미리 만들어져 풀에 저장되며,
같은 값이 다시 등장하면 새로 만들지 않고 풀의 기존 객체를 가리킨다.
메모리 절약 +==비교 가능 + 빠른 접근의 3가지 이점.
| 시나리오 | 비유 |
|---|---|
| 리터럴 사용 | 공용 도서를 빌려 읽음 (도서 1권 = 모두가 같은 책) |
| new String | 직접 같은 책을 사서 가짐 (책 1권을 매번 새로 인쇄) |
| intern() 호출 | 자기 책을 도서관에 기부해서 공용으로 만듦 |
도서관(Pool)에는 같은 책이 단 1권만 존재. 책 100명이 빌려 봐도 1권으로 충분.
같은 제목의 책을 직접 사면(=new String) 도서관 책과 별개의 사본이 생김.
1. Literal Pool의 탄생 배경
2. String Constant Pool 깊이 보기
3. 리터럴 vs new String — 메모리 추적
4. String.intern() 메커니즘
5. JVM의 다른 풀들 — Integer Cache · Boolean · null
6. String의 진화 — Java 6/7/8/9의 변화
7. ILIC 실무 — Pool 활용과 함정
8. 흔한 실수 + 디버깅
9. 면접 질문 + 자기 점검 + Phase 1 정리
자바 프로그램에서 가장 자주 등장하는 객체는 무엇일까?
jmap -histo:live <PID> | head
거의 모든 자바 앱에서:
num #instances #bytes class name
1: 1500000 45000000 [B (byte arrays)
2: 800000 19200000 java.lang.String ← 거의 항상 상위
3: 300000 4800000 java.util.HashMap$Node
...
→ String은 전체 객체의 20~40%.
→ 만약 매번 새 객체로 만들면 메모리 폭발.
// 어떤 ILIC Service 메서드
log.info("Shipment created: {}", shipmentId);
log.info("Shipment created: {}", anotherId);
log.info("Shipment created: {}", yetAnotherId);
// 모두 "Shipment created: {}" 라는 같은 문자열을 씀
같은 리터럴이 코드 곳곳에 등장. 매번 새 객체를 만든다면 너무 낭비.
1번만 만들고 재사용.
String a = "abc"; // 풀에 "abc" 만들고 그 참조 반환
String b = "abc"; // 풀의 기존 "abc" 참조 반환
a == b; // true ← 같은 객체!
→ 메모리 절약 + 참조 비교 가능 (== 만으로 동등성 판단).
조건:
자바의 답:
→ 이번 Unit에서 모두 다룬다.
이게 시험 단골:
Java 6 이전: String Pool ⊂ Method Area (PermGen)
Java 7 이후: String Pool ⊂ Heap ★ 현재
Java 8 이후: PermGen → Metaspace, String Pool은 그대로 Heap
왜 옮겼나?
Java 6 이전 - PermGen에 String Pool:
- String이 너무 많으면 PermGen 가득 차서 OOM
- 잘 안 쓰는 String도 절대 안 사라짐
Java 7+ - Heap에 String Pool:
- 사용 안 하는 String은 GC 대상
- 동적으로 크기 조정
Heap:
┌──────────────────────────────┐
│ Young Generation │
│ ├─ Eden │
│ └─ Survivor │
├──────────────────────────────┤
│ Old Generation │
│ ┌────────────────────────┐ │
│ │ String Constant Pool │ │ ← 보통 Old Gen 근처
│ │ "abc", "hello", ... │ │ (해시 테이블 구조)
│ └────────────────────────┘ │
└──────────────────────────────┘
내부 구현은 해시 테이블.
# 풀 크기 조정
-XX:StringTableSize=1009 # 기본값, 소수
큰 앱이라면 늘려서 충돌 줄임.
// 코드 안의 모든 String 리터럴
"Shipment"
"BL-001"
""
"abc"
// + intern() 호출된 String
someDynamicString.intern();
컴파일 시점에 풀 등록:
A.java의 모든 String 리터럴
↓ javac
A.class의 상수 풀에 String 항목 기록
↓ JVM 클래스 로딩 시
String Pool에 자동 등록
String Pool은 "표"일 뿐:
┌────────────────────────────────┐
│ Hash → String 객체 참조 │
│ 8923 → 0x7f4a2c01 ("abc") │
│ 1234 → 0x7f4a3d12 ("hello") │
└────────────────────────────────┘
실제 String 객체:
Heap의 어딘가:
String @0x7f4a2c01 ← value = ['a','b','c']
String @0x7f4a3d12 ← value = ['h','e','l','l','o']
→ 풀은 참조의 목록일 뿐, 실제 String 데이터는 Heap에 있는 일반 객체.
→ 따라서 "공유" = "같은 Heap 객체를 여러 변수가 가리킴".
String a = "Shipment";
String b = "Shipment";
String c = new String("Shipment");
System.out.println(a == b); // true ← 같은 풀 객체
System.out.println(a == c); // false ← c는 새 객체
System.out.println(a.equals(c)); // true ← 값은 같음
System.out.println(a == c.intern()); // true ← intern은 풀 참조 반환
→ Pool 이해의 핵심 4줄.
String a = "abc";
JVM 동작:
Step 1: String Pool 검색 — "abc" 있나?
Step 2-A (있으면): 기존 객체 참조 반환
Step 2-B (없으면): 새 String 객체 생성 + 풀에 등록 + 참조 반환
Step 3: 변수 a에 그 참조 저장
메모리:
Stack:
a = 0x7f4a2c01 ──┐
│
Heap (String Pool에 등록됨):
"abc" @0x7f4a2c01 ◄┘
String b = new String("abc");
JVM 동작:
Step 1: "abc" 리터럴이 풀에 있는지 확인 → 있다면 그대로, 없다면 풀에 등록
Step 2: Heap에 별도의 새 String 객체 생성
Step 3: 그 새 객체의 value는 풀 "abc"의 value를 복사 (또는 공유)
Step 4: 변수 b에 새 객체 참조 저장
메모리:
Stack:
b = 0x7f4a4e23 ──┐
│
Heap: │
"abc" @0x7f4a2c01 (풀에 있음, b는 안 가리킴)
String @0x7f4a4e23 (b가 가리킴) ◄┘ ← 별도 객체!
→ 객체 2개.
String a = "abc"; // 풀의 객체
String b = new String("abc"); // 새 객체
String c = "abc"; // 풀의 객체 (a와 같음)
String d = new String("abc"); // 새 객체 (b와 다른 또 다른 새 객체)
Heap:
"abc" @0x...01 (풀) ◄── a, c가 가리킴
String @0x...02 (새) ◄── b만 가리킴
String @0x...03 (새) ◄── d만 가리킴
비교:
a == b; // false
a == c; // true
b == d; // false (둘 다 new지만 별개 객체)
a.equals(d); // true
String s = "Shipment"; // 풀 등록 (있으면 기존, 없으면 신규)
String t = "Shipment"; // 기존 풀 객체
String u = "Ship" + "ment"; // 컴파일 타임 상수 폴딩 → "Shipment" 리터럴 → 풀 객체
String v = "Ship" + dynamicMent;// 런타임 결합 → 새 객체 (풀 X)
s == t; // true
s == u; // true ← 컴파일러가 "Shipment"로 미리 합침
s == v; // false ← 동적 결합은 new로 처리
→ 컴파일 타임에 결정되는 String은 풀, 런타임 결합은 새 객체.
가상 시나리오: 1만 건의 화물 로그.
// 방법 A — 리터럴 사용
for (int i = 0; i < 10_000; i++) {
log.info("Shipment processed", shipment);
// ↑ 풀의 같은 객체 사용
}
// 방법 B — 매번 new String (의도적 나쁜 예)
for (int i = 0; i < 10_000; i++) {
log.info(new String("Shipment processed"), shipment);
// ↑ 매번 새 객체!
}
| 방법 | String 객체 수 | 메모리 |
|---|---|---|
| A (리터럴) | 1개 | ≈ 50 bytes |
| B (new) | 10,001개 | ≈ 500 KB |
→ 1만 배 차이. 리터럴을 적극 사용하라는 결론.
public native String intern();
동작: 호출하는 String 객체의 값이 풀에 있으면 그 풀 참조 반환, 없으면 풀에 등록 후 반환.
String dynamic = new String("hello"); // 풀 외부의 객체
String interned = dynamic.intern(); // 풀의 "hello" 참조
dynamic == interned; // false (서로 다른 객체)
"hello" == interned; // true (둘 다 풀 객체)
String fromDb = resultSet.getString("status"); // DB에서 가져온 동적 String
String pooled = fromDb.intern(); // 풀 등록 또는 기존 사용
// 이후 비교를 == 으로 빠르게
if (pooled == "ACTIVE") { ... }
⚠ 거의 안티 패턴. equals 가 가독성·안정성 면에서 우수.
// 100만 개 객체가 모두 status = "ACTIVE" 또는 "INACTIVE"
public class Shipment {
private String status; // 두 값만 있음
public void setStatus(String s) {
this.status = s.intern(); // 풀로 통일 → 객체 2개로 충분
}
}
→ 100만 개 객체가 모두 같은 2개의 String 객체 참조 → 메모리 절약.
// ❌ 모든 String을 intern
public void process(String userInput) {
String s = userInput.intern(); // ← 사용자 입력을 풀에 영구 등록!
// ...
}
문제:
사용자 입력 예: "userId-12345", "session-abcd", ...
→ 모두 풀에 영구 등록 → 풀 크기 폭발
규칙:
equals 사용Java 6 이전:
Java 7+:
→ 그래도 사용자 입력 intern은 여전히 위험.
G1 GC의 옵션:
-XX:+UseStringDeduplication
JVM이 자동으로 같은 값을 가진 String 객체들의 내부 char[]/byte[] 를 공유.
String 객체 1000개가 모두 "ACTIVE"
↓ Deduplication 활성화 후 GC
String 객체 1000개의 value 필드가 모두 같은 byte[] 가리킴
→ intern 안 써도 비슷한 효과. G1에서만 가능, Java 8u20+.
→ 운영에서 String 메모리 줄이고 싶으면 이 옵션이 더 안전.
Integer a = 100;
Integer b = 100;
Integer c = 1000;
Integer d = 1000;
a == b; // true ← Integer Cache 사용
c == d; // false ← 캐시 밖
Integer.valueOf(int)는 -128 ~ 127 의 정수를 미리 만들어 캐시.
public static Integer valueOf(int i) {
if (i >= IntegerCache.low && i <= IntegerCache.high)
return IntegerCache.cache[i + (-IntegerCache.low)];
return new Integer(i);
}
오토박싱(Integer a = 100)도 내부적으로 valueOf 호출.
Integer Cache (Method Area의 static):
cache[0] = Integer(-128)
cache[1] = Integer(-127)
...
cache[128] = Integer(0)
...
cache[255] = Integer(127)
-XX:AutoBoxCacheMax=10000
이 옵션으로 캐시 상한을 늘릴 수 있음. (하한은 -128 고정).
Integer a = 1000;
Integer b = 1000;
a == b; // 기본: false. AutoBoxCacheMax=10000 시: true
public final class Long {
private static class LongCache {
static final Long[] cache = new Long[256]; // -128 ~ 127
}
}
Long l1 = 100L;
Long l2 = 100L;
l1 == l2; // true
Long l3 = 1000L;
Long l4 = 1000L;
l3 == l4; // false
public final class Boolean {
public static final Boolean TRUE = new Boolean(true);
public static final Boolean FALSE = new Boolean(false);
public static Boolean valueOf(boolean b) {
return b ? TRUE : FALSE;
}
}
→ 단 2개의 객체. 항상 캐시 사용.
Boolean a = true;
Boolean b = true;
a == b; // true (항상)
Double a = 1.0;
Double b = 1.0;
a == b; // false ← 캐시 없음
부동소수점은 IEEE 754의 특성상 캐시가 의미 없음 (NaN, 정밀도 문제).
| 타입 | 캐시 범위 | 비고 |
|---|---|---|
Integer | -128 ~ 127 | AutoBoxCacheMax로 상한 조정 |
Long | -128 ~ 127 | 조정 불가 |
Short | -128 ~ 127 | 조정 불가 |
Byte | 전체 (-128 ~ 127) | 모든 값 캐시 |
Character | 0 ~ 127 | ASCII 범위 |
Boolean | TRUE, FALSE | 단 2개 |
Float | (캐시 없음) | |
Double | (캐시 없음) |
OutOfMemoryError: PermGenBefore: PermGen ⊃ String Pool
After: Heap ⊃ String Pool
장점:
StringTableSize 기본값 증가 (1009)Java 7: Heap, PermGen
Java 8+: Heap, Metaspace (네이티브 메모리)
String Pool은 그대로 Heap에 위치
기존 String 내부:
public final class String {
private final char[] value; // ← UTF-16, 2 bytes per char
}
ASCII 문자열도 char 1개당 2바이트 → 메모리 낭비.
Java 9+:
public final class String {
private final byte[] value; // ← Latin-1 또는 UTF-16
private final byte coder; // 0=Latin-1, 1=UTF-16
}
"hello" // 5 bytes (Latin-1)
"안녕" // 4 bytes (UTF-16, 2 chars × 2)
→ 대부분의 영문 String 메모리 절반으로 감소.
기존 + 연산:
String s = "Hello, " + name + "!";
// 컴파일러가 변환:
StringBuilder sb = new StringBuilder();
sb.append("Hello, ");
sb.append(name);
sb.append("!");
String s = sb.toString();
Java 9+:
// 컴파일러가 변환:
String s = INDY[makeConcatWithConstants("Hello, ", name, "!")];
// ↑ invokedynamic
String s = "안녕하세요";
| 버전 | 위치 | 내부 표현 | 메모리 |
|---|---|---|---|
| Java 6 | PermGen Pool | char[] (2B × 5) | ≈ 10 + 헤더 |
| Java 7 | Heap Pool | char[] | 동일 |
| Java 9+ | Heap Pool | byte[] + coder | 동일 (한글) |
String s = "hello";
| 버전 | 위치 | 내부 표현 | 메모리 |
|---|---|---|---|
| Java 6 | PermGen | char[] (2B × 5) | ≈ 10 + 헤더 |
| Java 9+ | Heap | byte[] (1B × 5) | ≈ 5 + 헤더 (절반!) |
→ Java 9+ ASCII 문자열은 메모리 절반.
@Service
public class ShipmentService {
public Shipment process(ShipmentRequest req) {
log.info("Processing shipment: {}", req.getBlNo());
// ↑ 풀의 문자열 자동 활용
validate(req);
return repository.save(new Shipment(req));
}
}
"Processing shipment: {}" 는 코드 안 리터럴 → 자동으로 풀에 1개.
앱 전체에서 호출 횟수에 무관하게 1개의 String 객체.
→ 리터럴을 그대로 쓰면 항상 효율적. 의식하지 않아도 됨.
// ❌ String 상수
public class ShipmentStatus {
public static final String DRAFT = "DRAFT";
public static final String IN_TRANSIT = "IN_TRANSIT";
public static final String DELIVERED = "DELIVERED";
}
shipment.setStatus(ShipmentStatus.DRAFT);
// 비교 시
if (shipment.getStatus().equals(ShipmentStatus.DRAFT)) { ... }
// ✅ enum
public enum ShipmentStatus {
DRAFT, IN_TRANSIT, DELIVERED
}
shipment.setStatus(ShipmentStatus.DRAFT);
if (shipment.getStatus() == ShipmentStatus.DRAFT) { ... }
// ↑ == 가능. enum은 본질적으로 풀 (각 값 단 1개)
enum의 메모리 동작:
Method Area의 static 영역에 저장== 비교 안전→ String 상수 대신 enum 사용 권장. 메모리 + 안전성 + 가독성 모두 우위.
// ❌ intern 남용
@Entity
public class Shipment {
private String status;
public void setStatus(String s) {
this.status = s.intern(); // 풀에 강제 등록 — 위험
}
}
// ✅ enum 변환
@Entity
public class Shipment {
@Enumerated(EnumType.STRING)
private ShipmentStatus status; // JPA가 자동 변환
}
→ DB의 String → Entity의 enum. 코드 어디서도 String을 비교할 일 없음.
public String generateBlNo(Shipment s) {
return "BL-" + s.getId() + "-" + LocalDate.now().getYear();
// ↑ 동적 결합 → 매번 새 객체 (풀 X)
}
이 결과:
→ 운영에서 호출량이 많으면 메모리 압박. 보통 작은 비용이라 문제 없음.
public void log(String userInput) {
String key = "input:" + userInput;
cache.put(key.intern(), result); // ❌ key를 풀에 등록
}
사용자 입력이 매번 다름 → 풀에 영원히 쌓임 → 메모리 누수.
해결:
WeakHashMap 사용해 사용자 입력 키 자동 회수# 1. String Deduplication (G1 GC + Java 8u20+)
java -XX:+UseG1GC -XX:+UseStringDeduplication MyApp
# 2. String Pool 크기 모니터링
java -XX:+PrintStringTableStatistics MyApp
# 종료 시 풀 사용량 출력
# 3. AutoBoxCacheMax 조정 (특정 워크로드만)
java -XX:AutoBoxCacheMax=2048 MyApp
대부분의 일반 앱은 기본값으로 충분. 메모리 분석 후 결정.
== 로 String 비교String status = getStatus(); // DB 또는 외부 입력
if (status == "ACTIVE") { ... } // ❌ false일 가능성 높음
→ 외부에서 온 String은 풀 객체가 아닐 가능성 높음. 항상 equals.
String a = "hello";
String b = new String("hello");
a == b; // false ← 자주 헷갈림
a.equals(b); // true
new String(...) 은 반드시 새 객체. 풀에 같은 값이 있어도 별개.
Integer a = 127;
Integer b = 127;
a == b; // true ← 캐시
Integer c = 128;
Integer d = 128;
c == d; // false ← 캐시 밖
→ Integer 비교는 항상 equals 또는 primitive 비교.
// 안전
int x = a.intValue();
int y = b.intValue();
x == y; // primitive 비교 — 항상 안전
// ❌
for (UserRequest r : requests) {
cache.put(r.getUserId().intern(), data);
// ↑ 사용자 ID를 풀에 영구 등록
}
10만 사용자 → 10만 String이 풀에 영구.
해결: intern 제거.
// 의도: 풀에 등록되길 기대?
StringBuilder sb = new StringBuilder("Shipment").append("-").append(id);
String result = sb.toString(); // 새 객체 (풀 외)
String pooled = result.intern(); // 풀 등록 가능 — 하지만 권장 X
→ StringBuilder의 결과는 풀 외. 정적 리터럴이 아니면 동적.
String s = "한글입니다";
// Java 9+: UTF-16 (2B/char) → 10 bytes
String t = "ASCII text";
// Java 9+: Latin-1 (1B/char) → 10 bytes
ASCII와 한글이 같은 길이라면 한글이 2배 메모리.
→ 운영 시 인코딩 고려해서 메모리 추정.
운영에서 String이 의심되면:
-XX:+PrintStringTableStatistics
종료 시 출력:
SymbolTable statistics:
Number of buckets : 64 = 1024 bytes, each 16
Number of entries : 12345 = ...
...
StringTable statistics:
Number of buckets : 65536 = 524288 bytes, each 8
Number of entries : 50000 = 800000 bytes
Number of literals : 50000 = ...
→ Entries 수와 메모리 사용량 확인. 비정상 증가 시 intern 남용 의심.
# 1. heap dump
jmap -dump:format=b,file=heap.hprof <PID>
# 2. Eclipse MAT 분석
# - "Histogram" → String 인스턴스 카운트
# - 비정상 많으면 → "List Objects with → outgoing references" 추적
# - 누가 들고 있는지 확인 (Map? List? 캐시?)
| Q | 핵심 답변 |
|---|---|
| String Pool은 어디에 있나? | Java 7+: Heap. Java 6 이전: PermGen |
String a = "abc"; String b = "abc"의 객체 수? | 1개. 둘 다 풀의 같은 객체 가리킴 |
new String("abc")의 차이? | 풀과 별개의 새 객체 생성 |
| intern()의 동작? | 풀에 있으면 그 참조, 없으면 등록 후 참조 반환 |
| Integer 캐시 범위? | -128 ~ 127 (AutoBoxCacheMax로 상한 조정 가능) |
| Integer 캐시가 그 범위인 이유? | 자주 쓰이는 작은 값 + byte 범위와 일치 |
| Java 9 Compact Strings? | char[] → byte[] + coder. ASCII는 메모리 절반 |
| String Deduplication? | G1 GC가 같은 값의 byte[]를 공유. 자동 풀링 효과 |
String 비교는 == vs equals? | 항상 equals. 풀 객체가 아닐 수 있음 |
| intern을 언제 쓰지 말아야? | 동적/사용자 입력. 메모리 누수 위험 |
==을 쓰지 않는다equals 또는 primitive 변환1. Literal Pool = "자주 쓰이는 불변 값의 캐시"
== 비교 가능 + 메모리 절약 + 빠른 접근2. Pool은 String만 있지 않다
3. 운영의 핵심 메시지
Unit 1.1 — 자바 변수의 3종류
↓ 지역 · 인스턴스 · 클래스 변수
Unit 1.2 — 변수별 저장 위치
↓ Stack · Heap · Method Area 매핑
Unit 1.3 — Method Area의 3개 존 ★
↓ Class Metadata · Static · Non-Static Zone
Unit 1.4 — Stack Area의 동작
↓ LVA · Operand Stack · Frame Data
Unit 1.5 — Heap과 객체-Metadata 연결
↓ Object Header · Class Pointer · VMT · invoke 4종
Unit 1.6 — Literal Pool Area
↓ String Pool · Integer Cache · Compact Strings
즉답할 수 있어야 할 질문:
Shipment s = new Shipment("BL-001"); s.calculate(); 한 줄이 실행될 때 메모리 어디에 무엇이 생기는가?String a = "abc"; String b = "abc"; a == b 의 결과와 이유는?모두 답할 수 있다면 Phase 1 졸업. Phase 2로 진입.
Phase 1에서 메모리 구조를 봤다면, Phase 2는 그 구조 위에서 메서드 호출이 어떻게 일어나는지 한 단계 더 정밀하게 본다.
new 연산자가 실제로 하는 일javap -c -v Shipment.class
위 명령으로 출력되는 바이트코드를 눈으로 읽고 해석할 수 있게 되는 것이 Phase 3의 목표.
invokevirtual #N 의 #N 이 가리키는 것→ Unit 1.5에서 본 invoke 명령들이 Phase 3에서 상수 풀과 결합해 완전한 그림으로 완성된다.
Phase 1 — 자바 변수 ↔ 메모리 매핑
✅ Unit 1.1 자바 변수의 3종류
✅ Unit 1.2 변수별 저장 위치
✅ Unit 1.3 Method Area의 3개 존 ★
✅ Unit 1.4 Stack Area의 동작
✅ Unit 1.5 Heap과 객체-Metadata 연결
✅ Unit 1.6 Literal Pool Area ← 여기
Phase 2 — JVM 메서드 실행 메커니즘
⏭ Unit 2.1 메서드 호출의 2단계 처리
⏭ Unit 2.2 Static vs 인스턴스 호출 경로
⏭ Unit 2.3 인스턴스 메서드 호출의 전 과정
⏭ Unit 2.4 new 연산자의 실제 동작
Phase 3 — 바이트코드와 상수 풀 ★ 정점
⏭ Unit 3.1 바이트코드란 무엇인가
⏭ Unit 3.2 상수 풀의 생성과 구조
⏭ Unit 3.3 심볼 참조
⏭ Unit 3.4 바이트코드 실전 분석
Phase 4 — G1 GC 심화
Phase 5 — 컬렉션 내부 구조
Phase 6 — Reflection & Iterator
Phase 7 — Buffer