힙 내외 메모리 아키텍처 이해
자바 애플리케이션은 일반적으로 힙(heap) 영역에 객체를 할당하며, 이는 JVM에 의해 가비지 컬렉션(GC) 대상이 됩니다. 반면 힙 외부 메모리(off-heap memory)는 JVM 힙 바깥쪽, 즉 운영체제가 직접 관리하는 네이티브 메모리 공간에 데이터를 저장합니다. 이 방식은 특히 고성능 I/O 처리나 대규모 캐시 시스템에서 중요한 역할을 합니다.
힙 기반 vs 네이티브 기반 버퍼
자바 NIO는 ByteBuffer라는 추상화를 통해 두 가지 유형의 버퍼를 제공합니다:
- HeapByteBuffer: JVM 힙 상에 할당되며, GC 주기에 따라 회수됩니다. 배열 기반으로 구현되어 접근이 빠르지만, 네이티브 I/O 호출 시 복사 과정이 필요합니다.
- DirectByteBuffer: 운영체제의 네이티브 메모리에 직접 할당되며, JNI를 통해 접근됩니다. GC 영향을 최소화하고 zero-copy 전송이 가능하지만, 할당 비용이 큽니다.
DirectByteBuffer의 내부 동작 메커니즘
ByteBuffer.allocateDirect()를 호출하면 다음과 같은 절차가 수행됩니다:
ByteBuffer buffer = ByteBuffer.allocateDirect(1024 * 1024); // 1MB 할당
JVM 내부에서는 먼저 Bits.reserveMemory()를 통해 힙 외부 메모리 사용량 제한(-XX:MaxDirectMemorySize)을 확인합니다. 초과 시 시스템 전체 GC(System.gc())를 트리거하여 해제 가능한 DirectBuffer를 정리하려 시도합니다.
메모리 할당 프로세스
- 전역 카운터인
totalCapacity가 허용치를 넘었는지 검사 - 넘었을 경우, 명시적 GC 실행 및 잠시 대기
- 여전히 부족하면
OutOfMemoryError("Direct buffer memory")발생 - 통과 시
sun.misc.Unsafe.allocateMemory()로 실제 네이티브 메모리 할당 - 해제 로직을 담은
Cleaner객체 생성 및 등록
Cleaner와 자동 정리 메커니즘
DirectByteBuffer은 자신이 참조하는 네이티브 메모리를 안전하게 해제하기 위해 Cleaner 클래스를 사용합니다. 이 클래스는 PhantomReference를 확장하며, 가비지 컬렉션 과정에서 다음과 같은 순서로 작동합니다:
- DirectByteBuffer 인스턴스가 더 이상 강한 참조를 받지 않게 되면, GC 대상이 됨
- GC 후 Cleaner 객체가 ReferenceQueue에 삽입됨
- "Reference Handler"라는 특수 스레드가 큐를 감시 중이며, Cleaner 타입 감지 시
clean()메서드 호출 Deallocator가 실행되어unsafe.freeMemory()로 메모리 해제
public void run() {
for (;;) {
Reference<Object> ref = waitForReferencePendingList();
if (ref instanceof Cleaner) {
((Cleaner)ref).clean(); // 자동 해제
}
}
}
Zero-Copy와 성능 최적화
네트워크 또는 파일 전송 시 불필요한 복사를 줄이는 것이 핵심입니다. 전통적인 read()/write() 방식은 다음과 같은 단계를 거칩니다:
- DMA: 디스크 → 커널 버퍼
- CPU: 커널 버퍼 → 유저 버퍼
- CPU: 유저 버퍼 → 소켓 버퍼
- DMA: 소켓 버퍼 → NIC
반면 FileChannel.transferTo()는 운영체제의 sendfile() 시스템 콜을 활용해 중간 복사를 생략합니다:
try (FileChannel in = FileChannel.open(source);
SocketChannel out = SocketChannel.open(target)) {
in.transferTo(0, in.size(), out);
}
Linux 2.4 이상에서는 더 나아가 소켓 버퍼에 물리 주소 정보만 기록하고, NIC가 직접 DMA로 데이터를 가져가는 gather-send 방식을 지원하여 CPU 복사를 완전히 제거합니다.
실제 사례: OHC 캐시 프레임워크의 설계 철학
OHC(Off-Heap Cache)는 Cassandra에서 파생된 오프 힙 캐시 솔루션으로, ByteBuffer.allocateDirect()를 사용하지 않는 이유는 다음과 같습니다:
- 전역 동기화 오버헤드: JDK의 DirectBuffer 관리는 전역 synchronized 리스트를 사용하므로 다중 스레드 환경에서 경합이 발생합니다.
- 비효율적인 해제 타이밍: 메모리 부족 시 Full GC를 유발하며, 이는 지연 시간에 큰 영향을 미칩니다.
- 메모리 관리 제어 부족: 개발자는 언제, 어떻게 메모리를 해제할지 직접 제어할 수 없습니다.
이에 OHC는 JNA 또는 JNI를 통해 직접 malloc()/jemalloc()을 호출함으로써:
- 메모리 할당/해제를 명시적으로 제어
- jemalloc과 같은 고성능 할당기를 통합 가능
- 글로벌 락 없이 동시성 보장
IAllocator allocator = "jna".equals(type) ?
new JNANativeAllocator() : new UnsafeAllocator();
long address = allocator.allocate(size);
// ... 사용 후
allocator.free(address);
적절한 사용 시나리오
힙 외부 버퍼는 모든 상황에 적합하지 않습니다. 다음 조건에 해당할 때 효과적입니다:
- 대용량 버퍼 (예: 1MB 이상)
- 장시간 유지되는 버퍼
- 빈번한 I/O 전송이 필요한 경우
- 지연 시간 민감한 시스템
반면 짧은 생명주기의 소규모 버퍼에는 HeapByteBuffer가 여전히 최선의 선택입니다. 복사 비용은 미미하며, 할당/해제 오버헤드가 훨씬 낮기 때문입니다.
결론: 성능과 안정성의 균형
힙 외부 메모리는 강력한 도구이지만, 그만큼 책임도 큽니다. Netty, OHC, Chronicle 등 고성능 프레임워크들은 이를 적절히 활용하여 GC 압력을 줄이고 지연 시간을 최소화합니다. 그러나 대부분의 일반 애플리케이션에서는 기본 제공되는 HeapByteBuffer로 충분하며, 성능 프로파일링을 통해 진짜 병목이 어디에 있는지를 파악하는 것이 중요합니다.