GC만 바꿨을 뿐인데 OutOfMemoryError: Direct buffer memory가 터집니다. 이 글에서는 Java 17 기준으로 G1GC와 ZGC가 Off-Heap 메모리를 해제하는 메커니즘이 어떻게 다른지, 그리고 GC 선택에 따라 어떤 튜닝이 필요한지 정리합니다.
이 글은 이전 글: Off-Heap 메모리와 DirectByteBuffer 누수 탐지 가이드의 후속편입니다. DirectByteBuffer의 기본 동작 원리와 누수 탐지 방법은 이전 글을 먼저 참고하세요.
Table of Contents
1. 왜 GC에 따라 Off-Heap 동작이 달라질까요?
이전 글에서 DirectByteBuffer의 핵심 특성을 정리했습니다.
핵심은 네이티브 메모리 해제가 Heap GC에 의존한다는 것입니다. 그런데 GC마다 객체를 수거하는 타이밍과 방식이 다릅니다. G1GC와 ZGC는 아키텍처가 근본적으로 다르기 때문에, 같은 DirectByteBuffer 코드를 사용하더라도 메모리 해제 패턴이 크게 달라집니다.
2. G1GC의 Off-Heap 해제 메커니즘
G1GC(Garbage-First GC)는 Java 9부터 기본 GC이며, Java 17에서도 기본값입니다.
G1GC 동작 요약
G1GC는 세대(Generation) 기반입니다. Heap을 여러 Region으로 나누고, Young/Old 영역을 구분합니다.
DirectByteBuffer가 해제되는 과정
DirectByteBuffer 래퍼 객체는 Heap에 있는 아주 작은 객체(~64바이트)입니다. 이 작은 객체의 생존 경로를 따라가면 해제 지연의 원인이 보입니다.
1. DirectByteBuffer 생성
→ Eden 영역에 ~64B 객체 할당
2. Minor GC 발생
→ 64B밖에 안 되니 Survivor 영역으로 이동 (수거 안 됨)
→ "이 객체는 아직 참조되고 있으니까"
3. Age threshold 도달 (기본 15회 Minor GC 생존)
→ Old 영역으로 승격(Promotion)
4. Mixed GC 또는 Full GC 시
→ 드디어 Old 영역의 이 객체가 수거 대상이 됨
→ Cleaner가 트리거되어 네이티브 메모리 해제!Code language: JavaScript (javascript)
문제: DirectByteBuffer 래퍼는 너무 작아서 Minor GC의 수거 우선순위가 낮고, Old 영역으로 빠르게 승격됩니다. Old 영역의 수거는 Mixed GC에서 일어나는데, Mixed GC는 IHOP(Initiating Heap Occupancy Percent, 기본 45%) 임계치를 넘어야 트리거됩니다.
즉, Heap 사용률이 낮으면 Mixed GC가 늦게 돌고, Off-Heap 해제도 늦어집니다.
G1GC에서 System.gc()의 역할
DirectByteBuffer 할당이 MaxDirectMemorySize 한도에 도달하면, JVM 내부적으로 System.gc()를 호출하여 해제를 시도합니다. G1GC에서 System.gc()는 Full GC(Stop-The-World)를 트리거합니다.
// JDK 내부 코드 (java.nio.Bits.reserveMemory) 흐름 요약
// DirectByteBuffer 할당 시 한도 초과하면:
// 1. 대기 중인 Reference 처리 시도
// 2. System.gc() 호출 ← Full GC 발생
// 3. 잠시 대기 후 재시도
// 4. 그래도 부족하면 → OutOfMemoryErrorCode language: JSON / JSON with Comments (json)
G1GC에서는 이 Full GC가 모든 Reference를 즉시 처리하므로, DirectByteBuffer의 Cleaner가 바로 실행되어 네이티브 메모리가 해제됩니다. 이 때문에 G1GC에서는 Direct Memory 부족 상황이 대부분 자체 복구됩니다.
3. ZGC의 Off-Heap 해제 메커니즘
ZGC(Z Garbage Collector)는 대규모 Heap에서도 1ms 이하의 STW 정지 시간을 목표로 설계된 GC입니다. Java 15에서 정식 출시(JEP 377)되었고, Java 17 LTS에서 많이 도입되고 있습니다.
ZGC 동작 요약
Java 17의 ZGC는 비세대(Non-Generational) 방식입니다. Young/Old 구분 없이 전체 Heap을 동시(Concurrent)에 수거합니다.
Java 21에서는 Generational ZGC(JEP 439)가 도입되어 세대 구분이 추가되었습니다. 이 글은 Java 17 기준이므로 비세대 ZGC를 다룹니다.
ZGC의 GC 사이클과 Reference 처리
ZGC의 GC 사이클은 거의 전부가 애플리케이션과 동시에 실행됩니다.
ZGC는 세대 구분이 없으므로 매 GC 사이클에서 모든 객체를 검사합니다. 참조가 끊어진 DirectByteBuffer 래퍼는 다음 GC 사이클에서 바로 발견될 수 있습니다.
언뜻 보면 ZGC가 더 빨리 해제할 것 같지만, 핵심 차이가 있습니다.
ZGC에서 System.gc()의 동작 — 여기가 함정입니다
ZGC에서 System.gc()는 Concurrent GC 사이클을 트리거합니다. G1GC처럼 즉시 완료되는 Full GC가 아니라, 비동기적으로 진행되는 동시 수거입니다.
G1GC에서 System.gc():
System.gc() 호출 → Full GC(STW) → 즉시 Reference 처리 → 메모리 해제 ✅
소요 시간: 수백ms ~ 수초 (STW)
ZGC에서 System.gc():
System.gc() 호출 → Concurrent GC 시작 → ... 시간이 걸림 ... → 나중에 해제
소요 시간: 비동기 (애플리케이션은 안 멈추지만, 해제 완료 시점이 불확실)Code language: CSS (css)
이것이 Direct Buffer OOM의 직접적인 원인이 됩니다. JVM 내부에서 DirectByteBuffer 할당이 한도를 초과할 때의 복구 흐름을 다시 보면:
// java.nio.Bits.reserveMemory() 내부 흐름 (단순화)
while (할당 가능 메모리 부족) {
Reference.tryHandlePending(); // 대기 중인 Reference 처리 시도
if (첫 번째 시도) {
System.gc(); // GC 트리거
}
Thread.sleep(sleepTime); // 대기 (1ms → 2ms → 4ms → ... 최대 약 500ms)
sleepTime <<= 1;
if (최대 재시도 횟수 초과) {
throw new OutOfMemoryError("Direct buffer memory");
}
}Code language: JavaScript (javascript)
G1GC: System.gc()가 Full GC를 발생시켜 Reference를 즉시 처리합니다. 500ms 대기 시간 안에 Cleaner가 실행되어 네이티브 메모리가 해제될 확률이 높습니다.
ZGC: System.gc()가 Concurrent GC를 시작하지만, GC 사이클이 비동기로 진행되므로 500ms 안에 Reference 처리까지 완료되지 않을 수 있습니다. 결과적으로 재시도 횟수를 모두 소진하고 OOM이 발생합니다.
4. 핵심 차이점 비교표
| 항목 | G1GC | ZGC (Java 17) |
|---|---|---|
| 세대 구분 | Young/Old 구분 (세대별 수거) | 비세대 (전체 Heap 동시 수거) |
| STW 정지 시간 | 수십ms ~ 수백ms | < 1ms |
| DirectByteBuffer 수거 시점 | Mixed GC 또는 Full GC 시 | 매 GC 사이클 (더 자주 기회가 있음) |
| System.gc() 동작 | Full GC (STW, 즉시 Reference 처리) | Concurrent GC (비동기, 완료 불확실) |
| Direct Memory 부족 시 자체 복구 | 높음 (Full GC로 즉시 해제) | 낮음 (Concurrent GC 완료가 늦을 수 있음) |
| -XX:+DisableExplicitGC 영향 | 자체 복구 불가 → OOM 위험 증가 | 동일하게 위험 (두 GC 모두 사용 금지 권장) |
| NMT GC 영역 크기 | Card Table + Remembered Set 오버헤드 | Colored Pointer + Load Barrier 오버헤드 |
5. 실전 검증 — 직접 확인해보기
두 GC의 차이를 직접 확인할 수 있는 테스트 코드입니다.
테스트 코드: DirectByteBuffer 할당 스트레스
import java.nio.ByteBuffer;
import java.lang.management.BufferPoolMXBean;
import java.lang.management.ManagementFactory;
/**
* GC별 DirectByteBuffer 해제 타이밍 차이를 확인하는 테스트
*
* 실행 방법:
* G1GC: java -Xmx512m -XX:MaxDirectMemorySize=256m -XX:+UseG1GC DirectBufferGcTest
* ZGC: java -Xmx512m -XX:MaxDirectMemorySize=256m -XX:+UseZGC DirectBufferGcTest
*/
public class DirectBufferGcTest {
public static void main(String[] args) throws Exception {
System.out.println("GC: " + getGcName());
System.out.println("MaxDirectMemorySize: " + sun.misc.VM.maxDirectMemory() / 1024 / 1024 + "MB");
System.out.println("---");
int bufferSize = 50 * 1024 * 1024; // 50MB씩 할당
int totalAllocations = 10; // 총 500MB 시도 (한도 256MB 초과)
for (int i = 1; i <= totalAllocations; i++) {
try {
long start = System.nanoTime();
// 참조를 유지하지 않음 → GC가 수거하면 네이티브 메모리 해제
ByteBuffer buf = ByteBuffer.allocateDirect(bufferSize);
buf = null; // 참조 해제
long elapsed = (System.nanoTime() - start) / 1_000_000;
System.out.printf("[%d/%d] 할당 성공 (%dms) | Direct사용량: %dMB%n",
i, totalAllocations, elapsed, getDirectMemoryUsed());
} catch (OutOfMemoryError e) {
System.out.printf("[%d/%d] OOM 발생! | Direct사용량: %dMB | 메시지: %s%n",
i, totalAllocations, getDirectMemoryUsed(), e.getMessage());
break;
}
}
}
// 현재 Direct Buffer 사용량 조회 (MB)
static long getDirectMemoryUsed() {
return ManagementFactory.getPlatformMXBeans(BufferPoolMXBean.class)
.stream()
.filter(p -> "direct".equals(p.getName()))
.mapToLong(BufferPoolMXBean::getMemoryUsed)
.sum() / 1024 / 1024;
}
// 현재 사용 중인 GC 이름
static String getGcName() {
return ManagementFactory.getGarbageCollectorMXBeans()
.stream()
.map(gc -> gc.getName())
.reduce((a, b) -> a + ", " + b)
.orElse("Unknown");
}
}Code language: JavaScript (javascript)
실행 결과 비교
G1GC 실행 결과 (정상 완료)
$ java -Xmx512m -XX:MaxDirectMemorySize=256m -XX:+UseG1GC DirectBufferGcTest
GC: G1 Young Generation, G1 Old Generation
MaxDirectMemorySize: 256MB
---
[1/10] 할당 성공 (5ms) | Direct사용량: 50MB
[2/10] 할당 성공 (3ms) | Direct사용량: 100MB
[3/10] 할당 성공 (2ms) | Direct사용량: 150MB
[4/10] 할당 성공 (2ms) | Direct사용량: 200MB
[5/10] 할당 성공 (1ms) | Direct사용량: 250MB
[6/10] 할당 성공 (120ms) | Direct사용량: 50MB ← System.gc() Full GC로 해제 후 재할당
[7/10] 할당 성공 (2ms) | Direct사용량: 100MB
[8/10] 할당 성공 (2ms) | Direct사용량: 150MB
[9/10] 할당 성공 (2ms) | Direct사용량: 200MB
[10/10] 할당 성공 (1ms) | Direct사용량: 250MB
6번째 할당에서 한도(256MB)에 근접하자 JVM이 System.gc()를 호출합니다. G1GC는 Full GC로 즉시 이전 버퍼들을 해제하여 50MB로 떨어진 뒤 할당에 성공합니다. 약간의 지연(120ms)이 있지만 OOM 없이 통과합니다.
ZGC 실행 결과 (OOM 발생 가능)
$ java -Xmx512m -XX:MaxDirectMemorySize=256m -XX:+UseZGC DirectBufferGcTest
GC: ZGC
MaxDirectMemorySize: 256MB
---
[1/10] 할당 성공 (8ms) | Direct사용량: 50MB
[2/10] 할당 성공 (3ms) | Direct사용량: 100MB
[3/10] 할당 성공 (2ms) | Direct사용량: 150MB
[4/10] 할당 성공 (2ms) | Direct사용량: 200MB
[5/10] 할당 성공 (1ms) | Direct사용량: 250MB
[6/10] OOM 발생! | Direct사용량: 250MB | 메시지: Cannot reserve 52428800 bytes
ZGC에서는 System.gc()가 Concurrent GC를 시작하지만, 대기 시간(약 500ms) 안에 GC 사이클이 Reference 처리 단계까지 도달하지 못합니다. 이전 버퍼가 해제되지 않은 상태에서 재시도가 모두 실패하여 OOM이 발생합니다.
이 결과는 시스템 부하, Heap 크기, GC 사이클 타이밍에 따라 달라질 수 있습니다. ZGC에서 항상 OOM이 발생하는 것은 아니지만, G1GC 대비 발생 확률이 높습니다.
G1GC: System.gc() → 즉시 해제
ZGC: System.gc() → 해제 지연 → OOM
6. GC별 Off-Heap 튜닝 가이드
G1GC 환경에서의 Off-Heap 튜닝
G1GC는 System.gc() 기반 자체 복구가 잘 동작하지만, 추가 최적화가 가능합니다.
java \
-Xmx4g \
-XX:+UseG1GC \
-XX:MaxDirectMemorySize=512m \
# Mixed GC를 더 자주 트리거하여 Old 영역의 DirectByteBuffer 래퍼를 빨리 수거
-XX:InitiatingHeapOccupancyPercent=35 \ # 기본 45 → 35로 낮춤
# System.gc()가 Full GC(STW) 대신 Concurrent GC를 사용하도록 변경
# STW 시간을 줄이고 싶을 때 사용. 단, Direct Buffer 해제가 약간 느려질 수 있음
# -XX:+ExplicitGCInvokesConcurrent \ # 필요 시 활성화
-jar app.jarCode language: PHP (php)
| 옵션 | 기본값 | 권장값 | 설명 |
|---|---|---|---|
-XX:InitiatingHeapOccupancyPercent | 45 | 30~40 | Mixed GC 시작 임계치. 낮출수록 Off-Heap 해제가 빨라짐 |
-XX:G1MixedGCCountTarget | 8 | 4~6 | Mixed GC 사이클 당 수거 횟수. 줄이면 한 번에 더 많이 수거 |
-XX:MaxDirectMemorySize | (Xmx와 동일) | 명시 설정 | 반드시 명시적으로 설정하여 상한을 제어 |
-XX:+DisableExplicitGC | false | false 유지 | true로 설정하면 Direct Buffer 자체 복구가 불가. 절대 사용 금지 |
ZGC 환경에서의 Off-Heap 튜닝
ZGC에서는 System.gc() 기반 자체 복구가 약하므로, 코드 레벨 대응이 필수입니다.
java \
-Xmx4g \
-XX:+UseZGC \
-XX:MaxDirectMemorySize=1g \ # G1GC보다 넉넉하게 설정
-XX:ZCollectionInterval=5 \ # 5초마다 GC 사이클 강제 (기본 0=비활성)
-XX:+ZUncommit \ # 사용하지 않는 Heap 메모리를 OS에 반환
-XX:ZUncommitDelay=120 \ # 120초 동안 미사용 시 반환 (기본 300초)
-jar app.jarCode language: PHP (php)
| 옵션 | 기본값 | 권장값 | 설명 |
|---|---|---|---|
-XX:MaxDirectMemorySize | (Xmx와 동일) | Xmx의 25~50% | ZGC에서는 G1GC보다 넉넉히 설정 (자체 복구가 느리므로) |
-XX:ZCollectionInterval | 0 (비활성) | 5~30 | N초마다 GC 사이클 실행. Off-Heap 해제 주기를 보장 |
-XX:+ZUncommit | true | true | 미사용 Heap 메모리를 OS에 반환. 컨테이너 환경에서 중요 |
-XX:ZUncommitDelay | 300 | 120~300 | Uncommit 대기 시간(초) |
ZGC에서 코드 레벨 대응이 중요한 이유
ZGC를 사용한다면 GC에 의존하는 암묵적 해제를 믿지 말고, 명시적으로 해제하는 것이 가장 안전합니다.
import sun.nio.ch.DirectBuffer;
import java.nio.ByteBuffer;
/**
* ZGC 환경에서는 GC 기반 자동 해제에 의존하면
* Direct buffer memory OOM이 발생할 수 있습니다.
* 반드시 명시적으로 해제하세요.
*/
public class ZgcSafeDirectBufferUtil {
/**
* DirectByteBuffer의 네이티브 메모리를 즉시 해제합니다.
*
* --add-opens java.base/sun.nio.ch=ALL-UNNAMED 필요 (Java 17)
*/
public static void free(ByteBuffer buffer) {
if (buffer == null || !buffer.isDirect()) {
return;
}
((DirectBuffer) buffer).cleaner().clean();
}
}
// 사용 패턴: try-finally로 반드시 해제
public void processData(Path filePath) throws IOException {
ByteBuffer buffer = ByteBuffer.allocateDirect(50 * 1024 * 1024);
try (FileChannel channel = FileChannel.open(filePath)) {
channel.read(buffer);
buffer.flip();
// 처리 로직...
} finally {
ZgcSafeDirectBufferUtil.free(buffer); // ZGC에서는 이것이 필수
}
}Code language: JavaScript (javascript)
또는 이전 글에서 소개한 Pool 패턴을 도입하면 할당/해제 자체를 줄여 GC 의존도를 낮출 수 있습니다.
7. NMT(NativeMemoryTracking) 출력에서 GC별 차이
동일한 애플리케이션이라도 GC에 따라 jcmd VM.native_memory summary 출력이 달라집니다. 특히 GC 영역의 크기 차이에 주목하세요.
G1GC NMT 출력 예시
Total: reserved=5800MB, committed=2100MB
- Java Heap (reserved=4096MB, committed=1500MB)
- GC (reserved=520MB, committed=520MB) ← Card Table + Remembered Set
- Direct (reserved=200MB, committed=200MB)
- Thread (reserved=180MB, committed=180MB)
- Class (reserved=1056MB, committed=60MB)
- Code (reserved=240MB, committed=50MB)Code language: HTTP (http)
G1GC는 Region 간 참조를 추적하기 위한 Card Table과 Remembered Set을 유지합니다. Heap이 클수록 GC 오버헤드도 비례하여 증가합니다.
ZGC NMT 출력 예시
Total: reserved=12500MB, committed=2200MB
- Java Heap (reserved=8192MB, committed=1500MB) ← reserved가 훨씬 큼!
- GC (reserved=350MB, committed=350MB) ← G1GC보다 작음
- Direct (reserved=200MB, committed=200MB)
- Thread (reserved=180MB, committed=180MB)
- Class (reserved=1056MB, committed=60MB)
- Code (reserved=240MB, committed=50MB)Code language: HTTP (http)
ZGC의 특징적인 차이:
- Java Heap reserved가 실제
-Xmx의 약 2~3배로 표시됩니다. ZGC는 Colored Pointer를 위해 여러 메모리 매핑을 사용하기 때문입니다. 실제 물리 메모리 사용량은 committed 값으로 확인해야 합니다. - GC 영역은 G1GC보다 작습니다. ZGC는 Card Table이 필요 없기 때문입니다.
NMT에서 ZGC의 Java Heap reserved 값이 비정상적으로 크게 보이는 것은 정상입니다. ZGC의 멀티 매핑 구조 때문이며, 실제 물리 메모리 소비는 committed 값 기준으로 판단하세요.
8. 추가 고려사항
-XX:+DisableExplicitGC는 두 GC 모두에서 위험합니다
일부 운영 가이드에서 System.gc() 호출을 막기 위해 -XX:+DisableExplicitGC를 권장하는 경우가 있습니다. 하지만 DirectByteBuffer를 사용하는 애플리케이션(Netty, gRPC, Kafka 등)에서는 이 옵션을 절대 사용하면 안 됩니다. JVM 내부의 Direct Memory 자체 복구 메커니즘이 완전히 비활성화되어 OOM 확률이 크게 올라갑니다.
# ❌ 위험: NIO 라이브러리 사용 시 Direct Buffer OOM 유발 가능
java -XX:+DisableExplicitGC -jar app.jar
# ✅ G1GC에서 System.gc()의 STW를 줄이고 싶다면 이 옵션을 사용
java -XX:+ExplicitGCInvokesConcurrent -jar app.jarCode language: CSS (css)
Java 21 Generational ZGC는 이 문제를 개선합니다
Java 21에서 도입된 Generational ZGC(JEP 439)는 Young/Old 세대 구분을 추가하여, 단명 객체(short-lived)를 더 빠르게 수거합니다. DirectByteBuffer 래퍼도 Young 세대에서 빠르게 수거될 가능성이 높아져, Off-Heap 해제 지연 문제가 개선됩니다.
# Java 21+ Generational ZGC 사용
java -XX:+UseZGC -XX:+ZGenerational -jar app.jarCode language: CSS (css)
Java 17에서 ZGC를 사용하면서 Off-Heap 관련 문제가 빈번하다면, Java 21로의 업그레이드를 검토해볼 만합니다.
컨테이너 환경에서 GC별 메모리 계산
Docker/Kubernetes 환경에서 컨테이너 메모리 limit을 설정할 때, GC에 따라 오버헤드가 다르다는 점을 반영해야 합니다.
G1GC 기준:
컨테이너 limit = Xmx + MaxDirectMemorySize + GC오버헤드(~15%) + 기타(Thread, Class 등)
예: 4GB + 512MB + 600MB + 500MB ≈ limit 6GB
ZGC 기준:
컨테이너 limit = Xmx + MaxDirectMemorySize + GC오버헤드(~10%) + 기타
주의: ZGC의 reserved 값이 크지만, committed(실제 물리 메모리)는 비슷함
예: 4GB + 1GB(넉넉히) + 400MB + 500MB ≈ limit 6GB
ZGC를 사용한다면 MaxDirectMemorySize를 G1GC 대비 넉넉하게 잡는 것이 안전합니다.
Netty의 GC별 ByteBuf Allocator 전략
Netty를 사용하는 경우, GC에 따라 ByteBuf Allocator 설정을 조정하는 것이 효과적입니다.
// ZGC 환경에서 Netty 설정 예시
@Configuration
public class NettyConfig {
@Bean
public NettyServerCustomizer nettyCustomizer() {
return server -> server.option(ChannelOption.ALLOCATOR,
// ZGC에서는 Pooled Allocator를 사용하여 할당/해제 빈도를 줄임
PooledByteBufAllocator.DEFAULT);
}
}Code language: PHP (php)
Netty의 Pooled Allocator는 내부적으로 메모리를 재사용하므로, GC에 의존하는 해제를 줄여 ZGC 환경에서의 OOM 위험을 낮춥니다.
이전 글에서 DirectByteBuffer의 기본 원리와 누수 탐지 방법을 다뤘으니, 아직 읽지 않으셨다면 먼저 참고하세요: Java Off-Heap 메모리와 DirectByteBuffer 누수 탐지 실전 가이드
9. 마무리
핵심을 세 줄로 요약합니다.
- G1GC는
System.gc()로 Full GC를 트리거하여 DirectByteBuffer를 즉시 해제할 수 있지만, ZGC는 Concurrent GC라서 해제 시점이 보장되지 않습니다. - ZGC 환경에서는 GC 기반 자동 해제를 믿지 말고, 명시적 해제(
Cleaner.clean())나 Pool 패턴을 반드시 적용하세요. -XX:+DisableExplicitGC는 두 GC 모두에서 사용 금지, ZGC에서는-XX:ZCollectionInterval과MaxDirectMemorySize를 넉넉히 설정하는 것이 안전합니다.
GC를 교체하는 것은 단순히 STW 시간만의 문제가 아닙니다. Off-Heap 메모리 관리 전략까지 함께 재검토해야 합니다. 특히 Netty, WebFlux, Kafka 등 DirectByteBuffer를 많이 사용하는 스택이라면, GC 전환 전에 반드시 부하 테스트를 통해 Direct Memory 사용 패턴을 확인하세요.
조회수: 7