[ Direct Buffer 탄생 배경 ]
기존 io 에서 불편했던 것
초기 버전 Java에서는 전통적인 I/O 에서 소켓에 바이트를 write 하는 방식을 보자
- 유저레벨의 자바 애플리케이션 힙 영역에 전달할 데이터 생성 한다.
- 유저레벨의 네이티비 메모리(물리 메모리) 공간에 고정된 버퍼를 하나 만들어서 여기다 전달할 데이터를 복사한다. 즉 유저레벨 → 유저레벨로의 복사다.
- 위에서 만든 물리 메모리 버퍼의 참조를 시스템 콜로 넘긴다. 시스템콜은 이 버퍼의 데이터를 커널레벨의 소켓 버퍼로 옮긴다. 이건 유저레벨 → 커널레벨로의 복사다.
왜 힙영역에서 물리메모리 영역으로 복사하나?
힙 메모리에서 바로 커널 메모리로 복사하면 되는거 아닌가!?
Java의 가비지 컬렉터는 힙 메모리를 자유롭게 이동 및 압축시키기 때문에, 메모리 주소가 고정되어 있지 않았다.
GC 수행 중에 이동되거나 해제될 수도 있기 때문에, 자바에서 네이티브 함수를 호출할 때 JVM은 전달할 데이터를 별도의 고정된 메모리 공간에 복사한 후 시스템 콜을 수행해야 했다.
Direct Buffer 탄생
이런 불필요한 복사 문제를 해결하고자 Java 1.4에서 새로운 I/O 패키지(NIO)가 도입되면서 Direct Buffer 개념이 함께 등장했다. java.nio.ByteBuffer.allocateDirect() 메서드로 생성되는 DirectByteBuffer는 JVM 힙 밖의 연속된 메모리 영역(native memory)에 데이터를 저장한다. 즉 처음부터 물리메모리에 버퍼공간을 만들어서 read/write에 활용하는 것이다.
Direct Buffer를 사용하면 JVM이 운영체제의 I/O 호출 시 중간 복사 과정 없이 이 메모리 영역을 직접 전달할 수 있다. 그 결과 커널이 해당 버퍼에 직접 읽고 쓸 수 있게 되어, 이전처럼 자바 힙에서 네이티브 메모리로 데이터를 복사하는 불필요한 작업을 줄일 수 있다
이때 Direct Buffer가 할당하는 메모리는 JVM의 GC 관리 영역 밖의 네이티브 메모리다
자바 객체인 ByteBuffer 자체는 여전히 힙에 생성되지만, 그 내부에서 네이티브 메모리를 가리키고 있을 뿐입니다. 이러한 구조 덕분에 JVM Heap 상의 객체를 통한 I/O만으로는 불가능했던 고성능 I/O를 달성할 수 있게 되었다. 요약하면, Java NIO의 Direct Buffer는 기존 I/O의 한계를 돌파하기 위해 탄생했으며, GC에 안전하게 의존하면서도 성능을 높일 수 있는 절충안으로서 등장한 것이다
Direct Buffer 효과
위에서 설명했듯이 유저레벨에서 유저레벨로 복사가 1회 줄기 때문에 빠르다! 성능이 좋다.
추가로 GC의 범위에 벗어나 있어서 GC로 인한 지연을 줄이는 효과까지 있다
[ Direct Buffer 생성 ]
생성 방법
// 생성시 사용하는 코드
ByteBuffer buf = ByteBuffer.allocateDirect(1024);
// ByteBuffer.allocateDirect 내부적으로 결국 아래 메서드 호출
long base = UNSAFE.allocateMemory(size);
ByteBuffer.allocateDirect() 를 통해 생성 가능하다. 메서드 내부에서 UNSAFE.allocateMemory() 를 사용한다.
참고로 UNSAFE.allocateMemory는 JVM이 제공하는 가장 낮은 수준의 native memory 할당 함수 이다. C의 malloc(size) 과 거의 1:1로 대응된다.
ByteBuffer.allocateDirect() 는 ByteBuffer 객체를 반환하여 JVM 추적이 가능하다.
하지만 UNSAFE.allocateMemory() 는 메모리 주소(native memory의 시작 주소) 반환한다. GC에 의해 메모리해제되지 않고 직접 freeMemory()로 해제해야 한다. JVM 추적도 안되며, 매우 위험하다.
(+) sun.misc.Unsafe
- "안전하지 않은" 기능을 제공하는 내부 전용 API
- 접근하려면 리플렉션 또는 --add-exports java.base/sun.misc=ALL-UNNAMED 등의 옵션 필요
- JDK 22부터는 공식적으로 대체할 수 있*MemorySegment API (Foreign Memory Access API) 가 정식 API로 채택
버퍼 풀
- Direct Buffer는 생성 비용이 높고 해제 타이밍도 예측하기 어렵기 때문에, 한번 생성한 버퍼를 버퍼 풀처럼 캐싱하거나 재사용하는 방식이 일반적이다.
- 사용자가 명시적으로 캐시 로직을 넣지 않더라도, JDK 내부에서 이미 기본적인 캐시 로직을 갖추고 있다.
- 예를 들어 sun.nio.ch.IOUtil을 통한 read, write 호출 시, 내부적으로 Direct Buffer를 스레드 로컬(bufferCache)에 캐시해 두고 재사용하는 구조를 확인할 수 있다.
Direct Buffer는 생성 비용
JVM 힙 메모리는 JVM 내부 포인터 연산으로 즉시 할당되므로 빠르다.
다이렉트 버퍼는 OS native 메모리를 사용하기 위해 malloc/mmap 시스템 콜을 호출해야 한다.
시스템 콜은 유저 모드에서 커널 모드로 진입하며 CPU 모드 전환 비용이 발생한다.
이 과정에서 레지스터 및 스택 상태 보존, TLB 및 캐시 플러시 등으로 인한 오버헤드가 추가된다.
따라서 다이렉트 버퍼는 힙 메모리 대비 할당 및 해제 비용이 상대적으로 높다.
[ Direct Buffer 사용 ]
어디에 쓰면 좋을까?
Direct Buffer는 커널과 애플리케이션 간 데이터 복사가 빈번한 환경에서 효과적이다.
이는 네트워크 송수신, 파일 시스템 I/O처럼 대용량 데이터를 주고받는 과정에서 자주 발생한다.
Direct Buffer는 데이터 복사 단계와 GC 부담을 줄이기 위해 설계된 구조이므로,
네트워크 통신, 디스크 I/O 등에서 성능 최적화에 적합하다.
추천 사용 방식
- 대용량 데이터 처리 또는 복사 작업이 자주 반복되는 환경에서 사용 권장. GC 시 대용량 데이터 복사 오버헤드를 줄이는 데 도움된다.
- DirectBuffer를 빈번히 생성/해제하기보다는 버퍼 풀을 도입해 재사용하는 것이 효율적이다. 버퍼 풀을 사용하면 할당/해제 비용 및 Cleaner 처리 지연 문제를 완화할 수 있다. (이후에 추가 설명 한다)
MaxDirectMemorySize
- -XX:MaxDirectMemorySize는 JVM이 사용할 수 있는 Direct Buffer 네이티브 메모리의 최대값을 지정하는 옵션
- 명시적으로 설정하지 않으면 Direct 메모리 한도를 힙 최대 크기와 동일하게 설정
- DirectByteBuffer를 allocateDirect로 생성할 때, JVM은 즉시 현재까지 할당된 DirectBuffer 용량의 합과 MaxDirectMemorySize 한도를 비교합니다. 할당 시점에 한도 초과 여부를 판단하며, 초과하면 즉시 OOM을 던진다.
[ Direct Buffer 회수 ]
메모리 해제: 어떻게 동작하나
- DirectBuffer로 확보한 네이티브 메모리는 JVM의 가비지 컬렉터가 직접 관리하지 않는다.
- DirectBuffer를 생성하면 JVM 힙메모리에 네이티브 메모리 주소를 가리키고 있는 DirectByteBuffer 객체가 생성되고, 내부적으로 Cleaner가 연결된다.
- 이 Cleaner는 PhantomReference 방식으로 네이티브 메모리를 가리킨다.
- GC가 DirectByteBuffer 객체를 수거할 때, 연결된 Cleaner가 작동하여 Unsafe.freeMemory()를 호출해 네이티브 메모리를 해제한다.
사용자 코드에서 직접 해제 가능 여부
- 기본적으로는 사용자 코드에서 Cleaner를 직접 호출해 즉시 해제하는 것은 불가능하다.
- Cleaner와 DirectByteBuffer는 java.base 모듈 내부 전용 클래스이며, Java 9부터 모듈 경계로 인해 같은 모듈(java.base) 내에서만 접근이 가능하다.
- 따라서 같은 JDK 내부 클래스에 있는 SocketChannelImpl, IOUtil 에서는 Cleaner를 직접 호출해 명시적으로 해제할 수 있다. 그러나 외부 애플리케이션 코드에서는 JVM 옵션 없이 접근할 수 없으며, 일반적으로는 GC가 발생해 Cleaner가 호출되기를 기다려야 네이티브 메모리가 해제된다.
(+)Netty같은 라이브러리는 그럼 어떻게 메모리를 해제 하냐
- java.base 모듈 내부 클래스만 직접 Cleaner을 통해 메모리해제가 가능하다고 했다. 그러면 Netty 같은 외부 라이브러리들은 DirectByteBuffer를 어떻게 해제하냐?
- 리플렉션을 사용해 우회하여 Cleaner의 메서드를 호출한다. PlatformDependent0, CleanerJava9 참고
문제점
- 힙에있는 DirectByteBuffer 객체가 GC에 수거되면 Cleaner가 동작한다고 했다. 하지만 여러 방식으로(스레드 로컬 등)에 의도적으로 참조를 유지하면 GC 대상이 아니기 때문에 GC로 수거되지 않는다. 즉 Cleaner가 호출되지 않으며 네이티브 메모리는 영원히 해제되지 않을 수 있다.
- 또한 GC가 오지 않거나, 참조가 유지된 상황에서 다이레트 버퍼가 계속 할당되면 네이티브 메모리는 증가하지만 GC는 이를 감지하지 못한다. 그 결과, 자바 힙은 여유가 있음에도 불구하고 네이티브 메모리가 부족해지고 DirectBuffer 할당 실패하여 OOM 발생 가능
(+) Cleaner
- Cleaner는 내부적으로 PhantomReference의 일종으로, DirectByteBuffer 객체를 phantom reference로 추적합니다. PhantomReference는 객체가 완전히 소멸되기 직전에 어떤 동작을 수행할 수 있게 해줍니다. GC가 실행되어 어떤 DirectByteBuffer 객체를 더 이상 참조하는 곳이 없다고 판단하면 (즉 phantom-reachable 상태), 해당 Cleaner가 참조 큐(ReferenceQueue)에 enqueue됩니다. 이후 JVM의 Reference Handler 스레드가 이 Cleaner를 발견하여 clean() 메서드를 호출함으로써, ByteBuffer에 연관된 네이티브 메모리를 실제로 해제합니다 . 이 과정은 GC의 "콜렉션 후 단계(post-collection phase)에서 비동기적으로 이뤄집니다 Cleaner의 내부 구현은 Unsafe.freeMemory를 호출하여 해당 버퍼의 메모리를 free하는 코드로 구성되어 있습니다. 한 번 clean이 수행되면 Cleaner는 제거되고(double-clean 방지), Bits.unreserveMemory 등을 통해 JVM이 추적하던 DirectBuffer 용량도 감소시킵니다. 그러면 다음 번 allocateDirect 시에 그만큼 한도가 회복된 것으로 간주됩니다
[ 모니터링 ]
JMX (BufferPoolMXBean)
- 현재 Direct ByteBuffer의 개수, 총 용량, 실제 사용 메모리를 알 수 있다.
- 장점: 표준 API로 오버헤드가 적고 쉽게 접근 가능하며, 현재 할당된 DirectBuffer의 총량을 빠르게 파악할 수 있다.
- 단점: 이 수치는 NIO Buffer를 통해 할당된 메모리만 집계하며, 짧은 순간의 피크를 놓칠 수 있고(폴링 간격 문제) 누가 메모리를 잡고 있는지 상세 추적은 어렵다.
jcmd 및 Native Memory Tracking (NMT)
- jcmd로 JVM 네이티브 메모리 사용량을 상세히 조사할 수 있다.
- -XX:NativeMemoryTracking=[summary|detail] 옵션을 활성화한 경우 jcmd <PID> VM.native_memory summary 등의 명령으로 JVM 프로세스의 메모리 소비를 범주별로 출력한다
- NMT의 상세 모드(detail)로 추적하거나, JVMTI 기반 네이티브 메모리 프로파일러를 이용하면 어떤 스레드/스택에서 DirectBuffer를 많이 할당했는지 추적 가능할 때도 있습니다. 이를 통해 어떤 코드 경로에서 과도한 DirectBuffer 할당이 일어나는지 파악해 최적화할 수 있다.
- 장점: 힙, 클래스 메타, 스레드, 코드캐시, NIO 등 모든 영역의 메모리 사용량을 종합적으로 파악 가능하며, 누수가 의심되는 경우 어떤 영역이 증가하는지 확인할 수 있다.
- 단점: NativeMemoryTracking 옵션은 기본적으로 꺼져 있음. 약간의 오버헤드가 있음.
'Server' 카테고리의 다른 글
프로세스 종료와 시그널 (3) | 2025.08.02 |
---|---|
kotlin coroutine 에 대하여 (3) | 2025.07.26 |
Java의 BLOCKED 상태와 I/O 대기로 인한 일시정지 상태 (0) | 2025.07.12 |
Multi Plexing 에 대하여 (0) | 2025.07.07 |