Java NIO ByteBuffer 내부 구조 — Direct vs Heap, Cleaner, 그리고 off-heap 메모리가 GC를 우회하는 방법
Netty가 빠른 이유, Kafka 클라이언트가 직렬화에 신경 쓰는 이유, MappedByteBuffer로 수 GB짜리 파일을 다루는 이유. 그 한가운데에는
ByteBuffer가 있어요. 이번 글에서는ByteBuffer의 두 얼굴 — heap과 direct — 가 어떻게 다른지, off-heap 메모리는 어떻게 잡고 어떻게 풀리는지, JVM과 운영체제 사이에서 어떤 약속이 오가는지를 OpenJDK 소스 코드와 공식 문서를 따라가며 풀어봐요. NIO를 직접 다루거나, Netty·Kafka의 동작이 궁금했던 분들을 위한 글이에요.
시작하기 전에 — ByteBuffer가 두 종류라는 사실
java.nio.ByteBuffer 한 줄을 쓰는 방법은 두 가지예요.
ByteBuffer heap = ByteBuffer.allocate(1024); // 1
ByteBuffer direct = ByteBuffer.allocateDirect(1024); // 2
둘 다 ByteBuffer 타입이지만, 1번은 JVM heap 위에 byte[]를 잡고 2번은 heap 바깥(off-heap)에 네이티브 메모리를 잡아요. 같은 API 뒤에서 두 가지 완전히 다른 구현이 동작하는 거예요.
흥미로운 점은 ByteBuffer 자체가 abstract class라는 거예요. allocate 호출이 만들어주는 인스턴스는 사실 HeapByteBuffer, allocateDirect가 만들어주는 인스턴스는 DirectByteBuffer예요. 두 클래스 모두 java.nio 패키지의 package-private 클래스라서 직접 참조할 일은 없지만, 어떻게 다르게 동작하는지를 이해해야 메모리 사용량을 예측할 수 있어요.
이 글의 흐름은 이래요.
flowchart TD
A[Buffer abstract class] --> B[ByteBuffer abstract]
B --> C[HeapByteBuffer]
B --> D[MappedByteBuffer]
D --> E[DirectByteBuffer]
C --> F[backed by byte array on JVM heap]
E --> G[backed by native memory via Unsafe]
E --> H[Cleaner releases native memory after GC]
MappedByteBuffer가 DirectByteBuffer의 부모 클래스라는 점이 의외인데, OpenJDK 메인라인에서 MappedByteBuffer는 sealed 클래스이고 유일하게 허용된 자식 클래스가 DirectByteBuffer예요. 메모리 매핑이 결국 direct buffer의 한 종류이기 때문에 이렇게 설계됐어요. 하나씩 풀어가요.
1. Buffer 추상 클래스의 네 필드
모든 버퍼의 부모는 java.nio.Buffer예요. 코드를 열어보면 핵심은 네 개의 int 필드뿐이에요.
public abstract class Buffer {
private int mark = -1;
private int position = 0;
private int limit;
private final int capacity;
// ...
long address; // direct buffer를 위한 네이티브 주소
}
이 네 필드는 다음과 같은 불변식을 지켜요.
0 <= mark <= position <= limit <= capacity
capacity: 버퍼가 가질 수 있는 최대 요소 수. 생성 후엔 바뀌지 않아요.limit: 읽고 쓸 수 있는 경계. 보통flip()호출 후엔 마지막으로 쓴 위치를 가리켜요.position: 다음 읽기/쓰기 위치.get/put을 호출할 때마다 자동으로 증가해요.mark:mark()로 저장해 둔 위치.reset()이 여기로 돌려놔요. 초기값은-1이고 "정의되지 않음"을 의미해요.
address 필드는 좀 특이해요. 본래 direct buffer만 필요한 값인데, OpenJDK 주석을 보면 "JNI GetDirectBufferAddress의 속도를 위해 여기에 올려뒀다"고 적혀 있어요. 즉 네이티브 코드에서 한 번의 메모리 접근으로 주소를 읽을 수 있게 부모 클래스에 끌어올린 거예요. 작은 디자인 결정이지만 JNI 호출의 호출당 비용을 줄이려는 의도가 보여요.
다음과 같이 그릴 수 있어요.
flowchart LR
S[start: 0] --- P[position]
P --- L[limit]
L --- C[capacity end]
M[mark, optional] -.-> P
flip()은 한 줄로 표현하면 limit = position; position = 0; mark = -1이에요. 쓰기 모드에서 읽기 모드로 전환할 때 자주 쓰는 메서드인데, 내부적으로는 그저 세 필드의 값을 바꾸는 게 전부예요. 어떤 메모리도 이동하지 않아요.
2. HeapByteBuffer — 그냥 byte[]를 감싼 것
가장 단순한 구현이에요. 이름 그대로 JVM heap 위에 byte[]를 잡고, 그 위에서 position/limit을 움직이며 인덱싱해요.
ByteBuffer buf = ByteBuffer.allocate(1024);
// buf instanceof HeapByteBuffer == true
OpenJDK의 HeapByteBuffer 생성자는 대략 이렇게 생겼어요(설명을 위해 단순화했어요).
HeapByteBuffer(int cap, int lim) {
super(-1, 0, lim, cap, new byte[cap], 0);
}
부모 ByteBuffer에 hb라는 byte[] 필드가 있어요. heap buffer는 이 배열을 채우고 비우는 게 전부예요. get(int i)는 hb[ix(i)]로 끝나고, put(int i, byte x)는 hb[ix(i)] = x로 끝나요. 여기서 ix(i)는 i + offset이고, offset은 slice·duplicate 시 부모 버퍼와 공유 영역을 가리키기 위한 보정값이에요.
heap buffer의 장점은 명확해요.
- 할당이 빨라요.
new byte[cap]한 번이면 끝이라 native call이 없어요. - GC가 알아서 회수해줘요. 일반 객체와 똑같아요.
- 짧고 작은 버퍼에 적합해요.
단점도 있어요. GC가 객체를 옮길 수 있다는 점이에요. JVM의 generational GC는 살아 있는 객체를 한 영역에서 다른 영역으로 복사(compact)해요. 그런데 OS가 파일이나 소켓에 직접 쓰려면 메모리가 그 자리에 고정돼 있어야 해요. 이 충돌을 어떻게 해결할까요?
JVM은 heap buffer로 I/O를 할 때 임시 direct buffer로 한 번 복사한 뒤 OS에 넘겨요. sun.nio.ch.IOUtil에 그 로직이 들어 있어요. 결국 heap buffer로 네트워크 I/O를 하면 한 번 더 복사가 발생해요. direct buffer를 쓰면 이 복사를 건너뛸 수 있다는 게 핵심이에요.
3. DirectByteBuffer — Unsafe로 잡은 off-heap 주소
direct buffer는 JVM heap이 아니라 그 바깥 — OS가 관리하는 네이티브 메모리 — 에 공간을 잡아요. OpenJDK의 DirectByteBuffer 생성자는 단순화하면 이렇게 동작해요.
DirectByteBuffer(int cap) {
super(-1, 0, cap, cap);
Bits.reserveMemory(cap, cap); // 회계 처리
long base = UNSAFE.allocateMemory(cap); // 실제 메모리 할당
UNSAFE.setMemory(base, cap, (byte) 0);
address = base;
cleaner = Cleaner.create(this, new Deallocator(base, cap, cap));
}
세 부분으로 나뉘어요.
Bits.reserveMemory(cap, cap)— JVM 차원에서 "off-heap 메모리를 cap 바이트 더 쓸 거예요" 라고 회계를 기록해요. 한도(-XX:MaxDirectMemorySize)를 넘으면 여기서 막혀요.UNSAFE.allocateMemory(cap)—sun.misc.Unsafe(JDK 9+에서는jdk.internal.misc.Unsafe)가 운영체제의malloc을 호출해서 진짜 메모리를 잡고 시작 주소를 돌려줘요.Cleaner.create(this, ...)— 이 버퍼 객체가 GC에 의해 phantom-reachable 상태가 되면 등록된Deallocator가 실행되도록 해요. 곧 자세히 풀게요.
direct buffer의 get/put은 byte[] 인덱싱이 아니라 Unsafe를 통한 메모리 접근이에요.
public byte get(int i) {
return UNSAFE.getByte(ix(i)); // 네이티브 주소에서 직접 읽음
}
여기서 ix(i)는 address + i예요. 즉 OS가 보는 진짜 주소 위에서 한 바이트씩 읽고 쓰는 거예요. GC와 무관해요.
이게 왜 중요할까요? OS가 네트워크 카드나 디스크에 데이터를 쓰라고 요청받았을 때, 데이터가 fixed address에 있다면 JVM 메모리에서 한 번 더 복사하지 않고 그대로 DMA(Direct Memory Access)로 넘길 수 있어요. 흔히 말하는 "zero-copy"의 첫걸음이에요. 대용량 네트워크 서버나 메시지 브로커가 direct buffer를 선호하는 이유예요.
다만 할당 자체는 heap buffer보다 비싸요. native call이 들어가고, OS가 페이지를 확보해야 하니까요. 그래서 보통은 direct buffer를 풀(pool)에 쌓아 두고 재사용해요. Netty의 PooledByteBufAllocator가 정확히 이 일을 해요.
4. Cleaner와 PhantomReference — GC가 모르는 메모리를 GC가 풀어주는 트릭
direct buffer의 가장 흥미로운 부분이 여기예요. UNSAFE.allocateMemory로 잡은 메모리는 GC가 모르는 영역이에요. JVM의 mark-sweep 알고리즘은 heap 위에 있는 객체만 추적하니까요. 그럼 누가 이 메모리를 해제해줄까요?
답은 Cleaner예요. OpenJDK 21 시점에서 direct buffer는 jdk.internal.ref.Cleaner 를 사용하고 있어요. java.lang.ref.Cleaner가 아니라 그보다 오래된 내부 구현이에요(JDK-8344332에서 java.lang.ref.Cleaner로 이주 작업이 진행 중이지만, 작성 시점 기준 마스터에는 아직 들어가지 않았어요).
Cleaner는 본질적으로 PhantomReference예요. phantom reference는 다음과 같이 동작해요.
- 참조하는 객체가 더 이상 strong하게도, soft하게도, weak하게도 도달할 수 없어요. 즉 GC가 회수할 준비를 마쳤어요.
- GC는 phantom-reachable 상태를 감지하면 reference를 등록된
ReferenceQueue에 넣어요. - JVM의 ReferenceHandler 스레드가 이 큐를 폴링하면서 등록된 cleanup 코드(
Deallocator)를 실행해요.
direct buffer의 경우 Deallocator는 단순해요.
private static class Deallocator implements Runnable {
private long address;
private long size;
private int capacity;
public void run() {
if (address == 0) return;
UNSAFE.freeMemory(address); // 진짜 해제
address = 0;
Bits.unreserveMemory(size, capacity); // 회계 복구
}
}
흐름을 그려보면 이래요.
flowchart TD
A[DirectByteBuffer created] --> B[Cleaner registered with ReferenceQueue]
B --> C[Application uses the buffer]
C --> D[All strong refs dropped]
D --> E[GC detects phantom-reachable]
E --> F[Cleaner enqueued]
F --> G[ReferenceHandler thread runs Deallocator]
G --> H[Unsafe.freeMemory and Bits.unreserveMemory]
여기서 중요한 함정이 하나 있어요. Deallocator가 실행되는 시점은 GC가 결정해요. 즉 direct buffer 객체가 unreachable해진 직후가 아니라, GC가 한 번 더 돌아 phantom-reachable로 강등되고 ReferenceHandler가 큐를 비울 때까지 미뤄져요. 그 사이에 새 direct buffer를 또 만들고 있으면, 사용 중인 off-heap 메모리는 잠시 한도를 초과할 수 있어요.
이 시간차가 OutOfMemoryError: Direct buffer memory가 생기는 가장 흔한 원인이에요. 다음 절에서 그 메커니즘을 더 깊이 봐요.
5. Bits.reserveMemory와 MaxDirectMemorySize
JVM은 off-heap 메모리를 무한정 잡게 두진 않아요. -XX:MaxDirectMemorySize 옵션으로 상한을 정해두고, 모든 allocateDirect 호출은 그 상한을 통과해야 해요. 회계를 담당하는 게 java.nio.Bits.reserveMemory예요.
이 메서드는 대략 이렇게 동작해요.
static void reserveMemory(long size, int cap) {
// 1. 낙관적 시도
if (tryReserveMemory(size, cap)) return;
// 2. 실패 시 GC 유도
System.gc();
// 3. 짧게 sleep 후 재시도, 최대 9번
for (int i = 0; i < MAX_SLEEPS; i++) {
if (tryReserveMemory(size, cap)) return;
Thread.sleep(...); // 점진적으로 길어짐
}
throw new OutOfMemoryError("Direct buffer memory");
}
이 코드에 두 가지 미묘한 점이 숨어 있어요.
첫째, System.gc()가 명시적으로 호출돼요. 보통 System.gc()는 권장되지 않지만, 여기서는 어쩔 수 없어요. phantom reference 해제를 강제해야 unreachable이 된 direct buffer들의 native 메모리가 풀려서 한도가 회복되거든요. 그래서 운영 환경에서 -XX:+DisableExplicitGC 플래그를 켜두면 direct buffer 해제가 지연돼 OOM이 더 잘 나요. 둘은 함께 봐야 해요.
둘째, 짧은 sleep으로 재시도해요. 최악의 경우 약 511ms까지 기다린 뒤 OutOfMemoryError를 던져요. 짧은 시간에 많은 작은 direct buffer를 빠르게 할당하면 이 sleep이 누적되면서 throughput이 급락할 수 있어요. mechanical sympathy 메일링 리스트에서 이 부분이 "generally bad code"라고 비판받기도 했지만, 호환성 때문에 큰 구조 변경 없이 유지되고 있어요.
요약하면 direct buffer는 이런 회계 구조를 가져요.
flowchart LR
A[allocateDirect cap] --> B{reserveMemory}
B -- success --> C[Unsafe.allocateMemory]
B -- fail --> D[System.gc]
D --> E{retry up to 9 times}
E -- success --> C
E -- still fail --> F[OutOfMemoryError Direct buffer memory]
C --> G[buffer ready]
운영에서 direct buffer OOM이 나면 점검할 것들이에요.
-XX:MaxDirectMemorySize를 명시적으로 설정했나요? 기본값은 보통 heap size와 비슷하게 잡히지만, 컨테이너 환경에서는 예상과 다를 수 있어요.-XX:+DisableExplicitGC가 켜져 있지는 않나요? 켜져 있다면 direct buffer가 영원히 풀리지 않아요.- direct buffer를 짧은 주기로 만들고 버리고 있진 않나요? Netty처럼 풀링하는 게 정답이에요.
6. MappedByteBuffer — mmap을 ByteBuffer로 감싼 것
FileChannel.map을 호출하면 OS의 mmap() 시스템 콜이 호출돼 파일의 한 영역을 프로세스의 가상 주소 공간에 매핑해요. Java는 그 매핑된 영역을 가리키는 MappedByteBuffer를 돌려줘요.
try (FileChannel ch = FileChannel.open(path, READ, WRITE)) {
MappedByteBuffer mbb = ch.map(MapMode.READ_WRITE, 0, ch.size());
mbb.put(0, (byte) 42);
mbb.force();
}
MappedByteBuffer는 DirectByteBuffer의 부모 클래스라고 앞에서 잠깐 짚었어요. OpenJDK 마스터의 MappedByteBuffer.java는 abstract sealed class로 선언돼 있고, 자식으로 DirectByteBuffer만 허용해요. 즉 실제로 우리가 받는 인스턴스는 DirectByteBuffer인데, 그 안에서 매핑된 영역인지 아닌지를 구분하는 게 fd(FileDescriptor) 필드의 유무예요.
핵심 메서드 두 개예요.
force()— 변경 사항을 디스크로 flush 해요. 내부적으로msync()시스템 콜을 호출해요.load()— 매핑된 영역을 미리 물리 메모리로 끌어와요. 페이지 폴트로 인한 지연을 줄이고 싶을 때 써요.isLoaded()— 페이지가 물리 메모리에 있는지 확인해요. 다만 공식 문서에도 "hint일 뿐"이라고 적혀 있어요.
매핑된 메모리는 Unsafe.allocateMemory로 잡은 것과 같은 방식으로 Cleaner에 의해 해제돼요. 다만 해제 시점에 호출되는 게 freeMemory가 아니라 munmap()이라는 차이가 있어요. 그리고 FileChannel을 닫아도 매핑은 풀리지 않아요. 매핑을 푸는 건 오직 MappedByteBuffer 인스턴스 자체가 GC되는 순간이에요. 이 부분은 공식 javadoc에 명시돼 있어요.
Once established, a mapping remains in effect until the MappedByteBuffer object is garbage collected.
그래서 매핑된 파일을 다시 짧은 시간 안에 삭제·재생성하려는 경우, Windows 같은 OS에서는 매핑이 풀릴 때까지 파일이 잠겨 있어요. 이게 Kafka가 Windows에서 빌드를 권장하지 않는 역사적인 이유 중 하나예요.
flowchart TD
A[FileChannel.map] --> B[Native mmap call]
B --> C[MappedByteBuffer instance]
C --> D[Direct memory access via Unsafe]
D --> E[Page fault loads page from disk]
E --> F[Application reads or writes]
F --> G[force calls msync]
C --> H[GC unmaps via munmap on Cleaner]
MappedByteBuffer가 강력한 이유는 OS의 페이지 캐시를 그대로 활용하기 때문이에요. 작은 파일이라면 일반 read()/write() 호출보다 큰 이득은 없지만, 수십 GB 단위로 커지는 파일을 다룰 때 mmap은 "필요한 페이지만 swap-in 한다"는 OS의 가상 메모리 관리 능력을 그대로 빌려와요. 그래서 검색 인덱스, 컬럼 스토어, 큐의 로그 세그먼트가 mmap 기반으로 설계되곤 해요.
7. slice, duplicate, asReadOnlyBuffer — 같은 메모리를 다른 뷰로 보기
ByteBuffer의 큰 장점은 같은 메모리를 여러 개의 view로 나눠 볼 수 있다는 점이에요.
ByteBuffer src = ByteBuffer.allocateDirect(1024);
ByteBuffer view = src.slice(); // position부터 limit까지를 0~remaining 으로 보는 새 뷰
ByteBuffer dup = src.duplicate(); // capacity 전체를 같은 메모리로 보는 새 뷰
ByteBuffer ro = src.asReadOnlyBuffer(); // 같은 메모리를 읽기 전용으로
이들의 공통점은 새로운 메모리를 할당하지 않는다는 거예요. heap buffer라면 같은 byte[]를, direct buffer라면 같은 address를 공유하면서 position/limit/offset만 다른 값을 가져요.
direct buffer의 slice는 특히 주의해야 해요. 원본 버퍼가 unreachable이 되면 Cleaner가 동작해서 native 메모리를 해제하는데, slice가 살아 있다면 그 메모리는 어떻게 될까요? OpenJDK는 이 문제를 풀기 위해 slice가 원본 direct buffer에 대한 strong reference를 갖도록 설계했어요. DirectByteBuffer의 att(attachment) 필드가 그 역할이에요. slice를 만들 때 att = original로 세팅하면, slice가 살아 있는 한 원본도 GC되지 않아요. 결과적으로 원본의 Cleaner는 slice까지 모두 unreachable이 된 후에야 동작해요.
이건 작지만 중요한 디테일이에요. native 메모리를 직접 다루는 라이브러리(예: Aeron, Chronicle)는 이 attachment 체인을 명확히 인지하고 설계해야 해요.
8. 언제 heap, 언제 direct를 써야 할까
지금까지의 내용을 정리하면 둘의 트레이드오프가 명확해져요.
| 항목 | HeapByteBuffer | DirectByteBuffer |
|---|---|---|
| 할당 비용 | 빠름 (new byte[]) |
느림 (malloc + 회계) |
| GC 부담 | 일반 객체와 동일 | 객체 자체는 가볍지만 native 메모리 회수 지연 |
| I/O 효율 | 임시 direct buffer로 한 번 복사됨 | 그대로 OS에 전달, zero-copy 가능 |
| 디버깅 | byte[] 그대로 보임 |
네이티브 주소라 일반 디버거로는 어색함 |
| OOM 메시지 | Java heap space |
Direct buffer memory |
대략적인 가이드라인이에요.
- 짧고 작은 버퍼는 heap이 유리해요. 할당이 잦으면
malloc비용이 누적돼요. - 장기간 유지되며 I/O에 자주 쓰이는 큰 버퍼는 direct가 유리해요. Netty의
ChannelBuffer나 Kafka 클라이언트의 send/receive buffer가 여기에 해당해요. - direct buffer는 풀링이 거의 필수예요. 매번 새로 만들면 할당 비용과 GC 지연이 누적돼요.
MaxDirectMemorySize를 명시적으로 설정하세요. 모니터링도 함께요.jcmd <pid> VM.native_memory같은 명령으로 native memory tracking을 켜면 추적할 수 있어요.
OpenJDK 자체의 NIO 구현도 이 트레이드오프를 인지하고 있어서, 작은 heap buffer로 I/O를 자주 호출하면 thread-local cached direct buffer를 재사용해요. sun.nio.ch.Util.getTemporaryDirectBuffer가 그 일을 해요. 즉 heap buffer를 쓴다고 항상 매번 복사 비용이 추가되는 건 아니지만, 그렇다고 zero-copy의 이득이 그대로 따라오는 것도 아니에요.
9. 정리
ByteBuffer를 한 줄로 요약하면 "byte[]에 대한 추상화 + native 메모리에 대한 추상화 + 메모리 매핑에 대한 추상화" 세 가지를 하나의 API 뒤에 숨긴 클래스예요. 그래서 단순해 보이지만 내부에는 다음과 같은 결정들이 쌓여 있어요.
- 모든 버퍼는 네 개의
int필드로 상태를 표현해요. 메모리 이동 없이 view를 자유롭게 만들 수 있는 이유예요. - heap buffer는
byte[]의 얇은 래퍼, direct buffer는Unsafe.allocateMemory로 잡은 off-heap 영역의 래퍼예요. - direct buffer의 해제는
PhantomReference기반Cleaner가 담당해요. GC가 도는 시점에 의존하므로 회계는 별도의Bits.reserveMemory/unreserveMemory로 관리해요. MappedByteBuffer는 결국 direct buffer의 한 종류이고, 다만mmap된 영역을 가리킨다는 점만 달라요.force로 디스크에 flush, GC가 돌아야 매핑이 풀려요.slice/duplicate는 새 메모리를 만들지 않고 같은 영역에 대한 다른 view를 만들어요. attachment 체인으로 직접 참조를 끊지 않도록 보호돼요.
NIO를 쓰는 라이브러리를 깊게 들여다볼 때 — Netty의 ByteBuf, Kafka의 RecordBatch, Lucene의 MMapDirectory — 이 내부 구조를 알고 있으면 코드의 의도가 훨씬 잘 보여요. 다음에 allocateDirect 한 줄을 마주쳤을 때, 그 뒤에서 Unsafe가 malloc을 호출하고 Cleaner가 그 끈을 잡고 있다는 그림이 떠오른다면 이 글의 목적은 달성한 거예요.
참고자료
- Java SE 21 ByteBuffer Javadoc
- Java SE 21 Buffer Javadoc
- Java SE 21 MappedByteBuffer Javadoc
- OpenJDK
Buffer.javasource - OpenJDK
MappedByteBuffer.javasource - JDK-8344332: Migrate DirectByteBuffer to use java.lang.ref.Cleaner (OpenJDK mailing list)
- Bits.reserveMemory discussion on mechanical-sympathy
-XX:MaxDirectMemorySizeJVM Options reference (Oracle)

