C
Java/JVM/Lesson 06

JVM — 아키텍처·GC·String Pool

60분·theory

JVM — 아키텍처·GC·String Pool

🎯 이 lesson 을 읽고 나면

이 lesson 을 다 읽고 나면 아래 3가지를 자신 있게 할 수 있습니다.

  • ✅ JVM 메모리 구조 (Heap / Stack / Metaspace) + GC 알고리즘
  • ✅ 운영 JVM 옵션 (-Xms/-Xmx/-XX:+UseG1GC) 5종 추천
  • ✅ OOM 발생 시 HeapDump 분석 워크플로 설명

학습 목표를 체크리스트로 두고 다 답할 수 있게 되면 lesson 을 닫으세요.

JVM 이 뭐냐 — *한 번 짜면 어디서나 실행*

핵심 한 줄

JVM (Java Virtual Machine) = Java 코드를 실행하는 가상 머신. "Write Once, Run Anywhere" — 한 번 작성한 Java 코드가 Windows·macOS·Linux 어디서나 똑같이 실행됩니다.

어떻게 가능한가

Java 코드는 바로 실행되지 않습니다. 두 단계를 거칩니다:

1. 컴파일: .java 파일 → .class 파일 (바이트코드). OS 와 무관한 중간 언어.
2. 실행: JVM 이 .class 를 읽어서 각 OS 의 네이티브 명령 으로 변환·실행.

바이트코드 + JVM 조합이 Java 의 핵심입니다. 작성 시점 에 윈도우·맥을 신경 쓸 필요 없고, 실행 시점 에 그 OS 용 JVM 이 알아서 처리합니다.

JVM 메모리 — 어디에 뭐가 저장되나

JVM 은 메모리를 여러 영역으로 나눠 관리합니다. 중요한 4 가지:

  • Heap모든 객체와 배열. 가장 큰 영역. GC 의 무대.
  • Stack메서드 호출 프레임지역 변수. 스레드마다 별도.
  • Method Area클래스 메타데이터 (필드·메서드 정보). 전체 스레드 공유.
  • PC Register현재 실행 중인 명령 위치. 스레드별.
java
public void run() {
    int x = 5;                    // x → Stack
    User u = new User("홍");      // User 객체 → Heap, u (참조) → Stack
}

x 같은 원시 값은 스택 에. new 로 만든 객체는 에. 그 객체를 가리키는 참조 만 스택에. 이 구분이 Java 메모리의 기본 그림 입니다.

JIT 컴파일러 — 자주 쓰는 코드는 더 빨리

처음엔 JVM 이 바이트코드를 해석 (interpret) 합니다. 한 줄씩 읽어서 실행. 느립니다.

하지만 어떤 메서드가 자주 호출되는지 추적하다가, 임계값을 넘으면 그 메서드를 네이티브 코드로 컴파일 해 둡니다. 이걸 JIT (Just-In-Time) 라고 합니다.

JIT 가 프로파일링 기반으로 최적화하기 때문에, 오래 실행된 JVM 이 더 빠릅니다 (warm-up). 이게 Java 서버가 처음엔 느리다가 점점 빨라지는 이유입니다.

C++ 같이 미리 컴파일 된 코드도 빠르지만, JIT 는 실제 실행 패턴 을 보고 최적화해서 어떤 경우엔 C++ 보다 빠르기도 합니다.

한 번 정리

JVM 은 단순한 번역기 가 아닙니다. 메모리 관리·최적화·GC 까지 다 알아서 합니다. Java 개발자가 메모리 할당·해제를 직접 하지 않아도 되는 이유입니다.

Garbage Collection — *자동 메모리 청소*

GC 가 하는 일

C·C++ 에서는 free()직접 메모리를 해제 해야 합니다. 깜빡하면 메모리 누수, 두 번 해제하면 프로그램 충돌.

Java 는 Garbage Collector 가 자동으로 처리합니다. 더 이상 참조되지 않는 객체 를 찾아내서 메모리에서 회수합니다. 개발자는 생성만 하고 나머지는 잊으면 됩니다.

세대 가설 — 대부분 객체는 금방 죽는다

JVM 의 GC 가 빠른 이유는 세대 가설 입니다.

> 대부분의 객체는 짧게 살고 죽는다. 메서드 안에서 만든 new·String.split() 결과 등 — 메서드 끝나면 더 이상 안 쓰이죠.

이 가설을 활용해 Heap 을 두 영역으로 나눕니다:

  • Young Generation — 새로 태어난 객체. 대부분 여기서 죽음.
  • Old Generation — Young 에서 살아남은 객체. 오래 살아남을 가능성 큼.
code
Young Generation              Old Generation
┌────┬──────┬─────┐          ┌─────────────┐
│Eden│  S0  │ S1  │          │  Tenured    │
└────┴──────┴─────┘          └─────────────┘

새 객체는 Eden 에 들어갑니다. Minor GC 때 살아남으면 Survivor (S0) 로, 또 살아남으면 S1 로 — 여러 번 살아남으면 Old Generation 으로 승격됩니다.

Minor GC vs Full GC

  • Minor GC: Young 영역만 청소. 자주·빠름 (수십 ms).
  • Full GC: Old 까지 청소. 드물지만 느림 (수백 ms ~ 수초). 모든 스레드 정지 (Stop-The-World).

운영 환경에서 Full GC 가 자주 발생하면 큰 문제입니다. 응답 시간이 갑자기 튀고, 사용자는 느려졌다 고 느끼죠. Old Gen 이 자주 차거나, 메모리 누수가 있다는 신호일 수 있습니다.

GC 알고리즘 — 선택 가능

JVM 은 여러 GC 알고리즘을 제공합니다. 어떤 걸 쓸지 튜닝 으로 결정합니다.

  • Serial GC — 단일 스레드. 작은 앱·임베디드.
  • Parallel GC — 다중 스레드. 처리량 우선 (배치).
  • G1 GC — Java 9+ 기본. 예측 가능한 일시정지. 대부분 좋음.
  • ZGC — Java 11+. 일시정지 1ms 미만. 대용량·저지연.
  • Shenandoah — ZGC 와 유사. RedHat 주도.

-XX:+UseG1GC 같은 JVM 옵션으로 선택합니다. 작은 앱은 G1, 큰 앱·저지연은 ZGC 가 기본 가이드라인입니다.

한 번 정리

GC 는 자동 메모리 관리 라는 큰 편의를 줍니다. 하지만 공짜는 아닙니다 — Full GC 가 자주 발생하면 응답 지연. 메모리 사용 패턴을 잘 설계하고, JVM 옵션을 튜닝하는 게 실무의 핵심 역량입니다.

String — *불변 객체의 대표*

String 은 변하지 않는다

Java 의 String불변 (Immutable) 객체입니다. 한 번 만들어지면 그 내용을 바꿀 수 없습니다.

java
String s = "hello";
s.concat(" world");      // 새 객체 반환, s 는 그대로
System.out.println(s);   // "hello"

concat·replace·toUpperCase 등 모든 메서드는 새 String 을 만들어 반환 합니다. 원본은 손대지 않죠.

왜 불변인가

불변에는 큰 이득이 있습니다:

  • 스레드 안전: 여러 스레드가 동시 접근해도 수정될 일이 없음
  • HashMap 키 안전: 키로 쓸 수 있음 (해시값이 변하지 않음)
  • 보안: 파일 경로·URL 을 함부로 못 바꿈
  • String Pool 최적화: 같은 값은 공유 가능

String Pool — 같은 글자는 공유

JVM 은 String 을 위한 특별한 공간 — String Pool — 을 둡니다. 같은 글자의 String 은 하나만 저장 하고 공유 합니다.

java
String a = "hello";          // 풀에 저장
String b = "hello";          // 풀에서 재사용 — a 와 같은 객체!
String c = new String("hello");  // new 는 풀 우회 — 새 객체

a == b   // true  (참조 같음)
a == c   // false (참조 다름)
a.equals(c)  // true  (값은 같음)

여기서 Java 에서 가장 헷갈리는 것이 나옵니다.

== vs equals():

  • ==참조 비교 (같은 객체인가?)
  • equals()값 비교 (같은 내용인가?)

String 비교는 항상 equals() 입니다. == 로 비교하면 우연히 맞을 수도, 틀릴 수도 있습니다.

StringBuilder — 문자열 연결의 함정

java
// ❌ 반복문에서 + 사용 — 매우 느림
String result = "";
for (int i = 0; i < 1000; i++) {
    result += i;   // 매번 새 String 객체 생성. O(n²)
}

result += i새 String 을 만들어 result 에 다시 할당합니다. 1000번 반복하면 1000개의 임시 객체가 생기죠. GC 가 미친듯이 돕니다.

StringBuilder 를 쓰면 내부 버퍼 에 누적하다가 마지막에 한 번만 String 으로 변환합니다.

java
// ✅ StringBuilder
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 1000; i++) {
    sb.append(i);
}
String result = sb.toString();    // O(n)

> 💡 단순한 a + b + c 는 컴파일러가 자동으로 StringBuilder 로 변환합니다. 반복문 안에서만 직접 써야 합니다.

Text Block (Java 15+) — 여러 줄 문자열

java
String html = """
    <html>
        <body>
            <p>안녕</p>
        </body>
    </html>
    """;

들여쓰기·줄바꿈을 그대로 유지하면서 깔끔하게 쓸 수 있습니다. JSON·SQL·HTML 작성에 극적으로 편함.

한 번 정리

String 은 불변 입니다. 비교는 equals(), 반복 연결은 StringBuilder, 여러 줄은 Text Block. 이 셋만 알아도 일상 코드의 90% 는 안전합니다.

Java 17~21 최신 기능 — *코드가 짧아진다*

record (Java 14·16 정식) — 불변 데이터 클래스

데이터만 담는 단순 클래스를 매번 생성자·getter·equals·hashCode·toString 다 쓰려면 코드가 50줄 입니다. record 는 한 줄 로 끝납니다.

java
public record User(Long id, String name, String email) { }

이 한 줄이 다음을 자동 생성합니다:

  • 생성자 new User(1L, "홍", "[email protected]")
  • getter u.id()·u.name() (getId() 아님 — 약간 다름)
  • equals()·hashCode()·toString()

DTO·이벤트·VO 같은 단순 데이터 객체 에 환상적입니다. Lombok 의 @Value 와 같은 역할을 언어 차원에서 지원하는 거죠.

sealed class (Java 17) — 허용된 자식만

java
public sealed interface Shape permits Circle, Square, Triangle { }
final class Circle implements Shape { }

Shape 를 구현할 수 있는 클래스를 Circle·Square·Triangle제한 합니다. 다른 곳에서 Shape 를 구현하려 하면 컴파일 에러.

왜 유용한가? switch 패턴 매칭 과 결합하면 모든 경우를 컴파일러가 검증 해줍니다. 새 도형이 추가되면 모든 switch 를 업데이트 하지 않으면 컴파일 안 됨 — 빠뜨리는 실수가 사라집니다.

switch 표현식 (Java 14) — 값을 리턴

옛 switch 는 문장 이라 값을 리턴하지 못했습니다. 모던 switch 는 표현식 이라 가능:

java
String type = switch (status) {
    case PENDING -> "대기";
    case PAID, REFUNDED -> "처리완료";
    default -> "알 수 없음";
};

-> 화살표 + break 불필요 + 값 리턴 + 여러 case 묶기. 옛 switch 의 fall-through 함정 도 사라졌습니다.

Pattern Matching (Java 21) — if-instanceof 안녕

java
// 옛날
if (obj instanceof String) {
    String s = (String) obj;
    if (s.length() > 0) ...
}

// 모던
if (obj instanceof String s && s.length() > 0) ...

instanceof변수 선언 을 한 줄에. switch 와 결합하면 더 강력합니다:

java
String describe(Object o) {
    return switch (o) {
        case Integer i -> "int " + i;
        case String s when s.length() > 0 -> "str " + s;
        case null -> "null";
        default -> "other";
    };
}

sealed class 와 결합하면 모든 경우 강제 까지 더해집니다.

Optional (Java 8) — null 의 명시적 표현

User getUser() 를 호출했는데 null 일 수도 있다 는 걸 누가 알까요? 모릅니다. 그래서 NullPointerException 폭발.

java
Optional<User> getUser(Long id) { ... }

리턴 타입에 Optional 이 있으면 반드시 비어있을 수 있음을 인지 하게 됩니다.

java
Optional<User> u = userRepo.findById(id);
u.map(User::getEmail)
 .filter(e -> e.endsWith("@company.com"))
 .ifPresent(this::sendEmail);

String email = u.map(User::getEmail).orElse("unknown");

> 💡 Optional 은 리턴 타입에만 쓰세요. 필드·매개변수 에 쓰는 건 안티 패턴입니다.

Virtual Thread (Java 21) — 동시성 혁명

위 Collections + Functional lesson 에서 다룬 그 기능. 1만+ 동시 작업을 OS 스레드 부담 없이 처리합니다. I/O 대기 시 자동 yield. Java 가 Go·Kotlin 수준의 동시성을 갖게 된 사건입니다.

한 번 정리

Java 17~21 의 신기능들은 코드를 짧게 + 컴파일러가 더 많이 검증 하게 만듭니다. record·sealed·switch 패턴·Optional·Virtual Thread — 새 프로젝트라면 적극 사용 권장. 옛 프로젝트는 점진 도입.

🎮 JVM 메모리·GC 시각화

클래스 로딩 → 객체 생성 → GC 흐름을 단계별로 확인하세요.
📝 Hello.java — 개발자가 작성
public class Hello {
    public static void main(String[] args) {
        System.out.println("안녕, Java!");
    }
}
💡 .java 파일 — 사람이 읽을 수 있는 소스코드
⚙️ javac Hello.java → Hello.class (바이트코드)
Hello.java
📄
사람이 읽는 코드

javac
Hello.class
💾
JVM이 읽는 바이트코드
💡 바이트코드는 어떤 OS에서도 실행 가능 — "Write Once, Run Anywhere"
📦 ClassLoader — .class 파일을 메모리에 적재
Bootstrap ClassLoader → java.lang.* (JDK 기본 클래스)
Extension ClassLoader → javax.*, ext 라이브러리
Application ClassLoader → Hello.class ← 우리 코드!
💡 JVM 메모리: Method Area(클래스 정보) → Heap(객체) → Stack(메서드 호출)
🚀 JIT 컴파일러 — 바이트코드 → 네이티브 코드 (초고속)
바이트코드
느림
JIT
컴파일
네이티브 코드
빠름 ⚡
💡 자주 실행되는 코드(Hot Spot)를 JIT가 감지해 네이티브로 변환 → 처음엔 느리고 나중엔 빨라지는 이유
✅ 실행 결과
$ java Hello
안녕, Java!
.java
소스코드
.class
바이트코드
출력
결과

GC 튜닝 옵션 — 실무에서 자주 쓰는 5가지

GC 옵션이 왜 필요한가

기본 설정의 JVM 은 작은 메모리 + 일반 워크로드 기준입니다. 실제 서비스는:

  • 메모리 1GB → 8GB 로 키워야 하고
  • 응답 지연이 중요한 경우 Stop-the-world 시간을 줄여야 합니다

이걸 JVM 옵션 (-X, -XX) 으로 조정합니다.

가장 자주 쓰는 5개

1. -Xms / -Xmx — 힙 메모리 크기

bash
java -Xms512m -Xmx2g -jar app.jar
  • -Xms = 시작 힙 크기 (initial)
  • -Xmx = 최대 힙 크기 (maximum)

실무 팁: -Xms == -Xmx같게 설정하세요. 동적 확장 비용을 피해 예측 가능한 성능. AWS·k8s 환경에서 사실상 표준.

2. -XX:+UseG1GC — G1 가비지 컬렉터

bash
-XX:+UseG1GC

Java 9+ 기본값 (Java 17 도 G1). 4GB 이상 힙 에서 응답 지연이 짧음. 명시적으로 적어두면 명확함 차원에서 좋습니다.

Java 11+ ZGC, Java 15+ Shenandoah 가 더 짧은 pause time 을 제공하지만 Heap 16GB+ 가 아니면 G1 으로 충분.

3. -XX:MaxGCPauseMillis=200 — 최대 GC 멈춤 시간 목표

bash
-XX:MaxGCPauseMillis=200

JVM 에 "GC 한 번에 200ms 넘기지 마" 라는 힌트. 보장이 아닌 목표. 응답 지연 SLA 가 있는 서비스에 필수.

4. -XX:+HeapDumpOnOutOfMemoryError — OOM 시 덤프 자동 생성

bash
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/var/log/app-heapdump.hprof

OOM 터지면 자동으로 힙 덤프 를 떨굽니다. 사후 분석용 — 프로덕션 필수 옵션.

5. -XX:+PrintGCDetails (Java 8) / -Xlog:gc* (Java 9+) — GC 로그

bash
# Java 17
-Xlog:gc*:file=/var/log/gc.log:time,uptime:filecount=10,filesize=10M

GC 가 언제·얼마나·왜 일어났는지 기록. 부하 테스트 시 필수.

실무 표준 조합 (Spring Boot 운영)

bash
java \
  -Xms2g -Xmx2g \
  -XX:+UseG1GC \
  -XX:MaxGCPauseMillis=200 \
  -XX:+HeapDumpOnOutOfMemoryError \
  -XX:HeapDumpPath=/var/log/heapdump.hprof \
  -Xlog:gc*:file=/var/log/gc.log:time,uptime:filecount=10,filesize=10M \
  -jar app.jar

이 옵션을 모두 외울 필요는 없습니다. 다만 옵션이 존재한다는 사실왜 쓰는지 만 알면 됩니다 — 면접에서 "GC 튜닝 해보셨어요?" 라고 물으면 이 5개를 언급할 수 있어야 합니다.

☕ 직접 실행 — String Pool · == vs equals

String 의 함정. 리터럴 vs new String() 의 차이를 == 와 equals 로 확인.
☕ Java
✏️ 코드 편집기
📟 출력 결과
▶ 실행하기 버튼을 눌러보세요
💡 코드를 직접 수정하고 실행해보세요. 변수값을 바꾸거나 println을 추가해 결과를 확인하세요!
☁️ Judge0 API로 서버에서 실행 — Java / Python / JS / C++ 지원

🤖 AI 에게 이렇게 요청해보세요

이 lesson 의 개념을 알면 AI 에게 구체적으로 지시할 수 있습니다. 막연한 "고쳐줘" 가 아니라 어휘를 가진 요청 — 그게 토큰 절약의 출발점입니다.

  • "이 Spring Boot 앱의 운영 JVM 옵션 (Xms/Xmx/G1GC/HeapDump) 추천해줘"
  • "이 코드의 OutOfMemoryError 원인을 힙 덤프 분석 관점에서 진단해줘"
  • "GC 로그 옵션을 Java 17 형식으로 추가해줘"

왜 이게 토큰을 줄이나

개념을 모를 땐 AI 답변을 받고도 "그게 뭐예요?" 를 다시 물어야 합니다. 그 "다시 물음" 이 토큰을 잡아먹습니다. 개념 한 번 익혀두면 대화가 한 번에 끝납니다.

JVM — 아키텍처·GC·String Pool·Java 17~21 - Java