┌─ 힙 (Heap, -Xmx) ────────────────────────────────┐
│ Young: Eden ──▶ Survivor 0/1 Old (Tenured) │
│ 새 객체는 Eden 에서 태어나고, 살아남으면 Old 로 │
└───────────────────────────────────────────────────┘
┌─ 비힙 ────────────────────────────────────────────┐
│ Metaspace (클래스 정보) CodeCache (JIT 결과) │
│ 스레드 스택 (-Xss, 스레드마다) │
└───────────────────────────────────────────────────┘-Xmx 는 힙만 제한합니다. 컨테이너 메모리 한도가 1GB 인데 -Xmx1g 를 주면 메타스페이스·스택·네이티브 버퍼가 들어갈 자리가 없어 OS 가 프로세스를 죽입니다. 힙은 컨테이너 한도의 50~75% 로 잡습니다.
| 옵션 | 뜻 | 기본값 |
|---|---|---|
-Xmx |
최대 힙 | 물리 메모리의 1/4 |
-Xms |
시작 힙 | 물리 메모리의 1/64 |
-Xss |
스레드 스택 | 1MB (플랫폼 스레드) |
-XX:MaxMetaspaceSize |
메타스페이스 상한 | 무제한 |
운영에서는 -Xms 와 -Xmx 를 같게 둡니다. 힙이 커졌다 줄었다 하면서 생기는 Full GC 를 피하기 위해서입니다.
요청 하나를 처리하며 만드는 DTO·문자열·스트림 객체는 응답이 나가면 전부 쓰레기입니다. 이런 객체는 Eden 에서 태어나 첫 Young GC 에 사라집니다. Young GC 는 "살아 있는 것만 복사" 하므로 쓰레기가 많을수록 빠릅니다.
몇 번의 Young GC 를 살아남은 객체는 Old 로 승격됩니다. 캐시, 커넥션 풀, 싱글턴 빈이 여기 삽니다. Old 가 가득 차면 Full GC 가 돌고, 이것이 응답 지연의 주범입니다.
3절 예제 2 에서 1GB 를 할당해도 Young GC 7회, Old GC 0회인 것이 이 가설의 증거입니다.
GC 루트(스택의 지역 변수, static 필드, 살아 있는 스레드) 에서 참조를 따라갈 수 있으면 살아 있는 객체입니다. 누수는 "쓰지 않지만 도달은 되는" 객체가 쌓이는 것입니다.
| 누수 패턴 | 예 |
|---|---|
| 만료 없는 static 캐시 | static Map<String, Data> CACHE |
| 리스너·콜백 해제 누락 | 이벤트 등록 후 remove 안 함 |
ThreadLocal 정리 누락 |
풀 스레드에 값이 남음 |
| 닫지 않은 스트림·커넥션 | 네이티브 버퍼까지 붙잡음 |
큰 컬렉션의 subList |
원본 전체를 참조 |
WeakHashMap 이나 WeakReference 는 "다른 곳에서 안 쓰면 치워도 된다" 를 GC 에 알리는 방법입니다. 단, 값이 아니라 키가 약한 참조라는 점에 주의합니다.
| GC | 방식 | 어울리는 곳 |
|---|---|---|
| Serial | 스레드 1개, 전부 정지 | 작은 힙, 코어 1~2개, 배치 |
| Parallel | 여러 스레드, 전부 정지 | 처리량 우선 배치 |
| G1 (기본) | 영역 단위, 정지 시간 목표 | 대부분의 서버 |
| ZGC | 거의 전부 동시 실행 | 큰 힙, 10ms 미만 정지 필요 |
"전부 정지"(Stop-The-World) 는 GC 동안 애플리케이션 스레드가 멈추는 것입니다. Parallel 은 총 GC 시간이 가장 짧지만 한 번 멈출 때 오래 멈춥니다. ZGC 는 멈춤이 1ms 미만이지만 동시 작업에 CPU 를 씁니다. 처리량과 응답 시간 사이의 거래입니다.
Java 21 의 기본은 G1 이고, 웹 서버에는 그대로 두는 것이 정답에 가깝습니다. 야간 배치처럼 응답 시간이 중요하지 않으면 -XX:+UseParallelGC 가 더 빠를 수 있습니다.
-Xlog:gc 한 줄이면 됩니다. 운영에서는 파일로 돌립니다.
-Xlog:gc*:file=/var/log/app/gc.log:time,uptime:filecount=5,filesize=20m한 줄의 뜻입니다.
[0.102s][info][gc] GC(9) Pause Full (G1 Compaction Pause) 62M->62M(64M) 2.089ms
│ │ │ │ │ │ └ 걸린 시간
│ │ │ │ │ └ 전체 힙
│ │ │ │ └ GC 후 사용량
│ │ │ └ GC 전 사용량
│ │ └ 종류: Young / Full / Remark 등
│ └ GC 일련번호
└ JVM 시작 후 경과 시간62M->62M 처럼 GC 를 해도 줄지 않으면 전부 살아 있는 객체입니다. 이 줄이 반복되다 OOM 이 납니다. 반대로 30M->3M 은 건강한 Young GC 입니다.
Java heap space 는 힙, Metaspace 는 클래스 로딩, unable to create native thread 는 스레드 수입니다.힙 덤프는 OOM 순간에 자동으로 받도록 미리 옵션을 켜 둡니다. 죽은 뒤에는 받을 수 없습니다.
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/var/log/app/살아 있는 프로세스의 덤프는 jcmd <pid> GC.heap_dump 파일명 으로 받습니다. 덤프 중에는 애플리케이션이 멈추므로 운영에서는 트래픽이 적은 시간에 받습니다.
64비트 JVM 에서 압축 참조가 켜져 있으면(힙 32GB 미만) 객체 헤더 12바이트, 참조 4바이트입니다. 크기는 8바이트 단위로 올림됩니다.
| 것 | 크기 |
|---|---|
int 하나 |
4 바이트 |
Integer 하나 |
16 바이트 (헤더 12 + 값 4) |
빈 String |
24 + 16 = 40 바이트 |
"item123" |
24 + 24 = 48 바이트 |
ArrayList<Integer> 100만 개 |
약 20 MB |
int[] 100만 개 |
4 MB |
100만 건을 List<Integer> 로 들면 int[] 의 5배입니다. 배치에서 ID 목록을 들고 다닐 때 원시 배열이나 IntStream 을 쓰는 이유입니다.