java 다이렉트 버퍼에 대하여

2025. 7. 11. 16:45·Server

[ Direct Buffer 탄생 배경 ]


기존 io 에서 불편했던 것

 

초기 버전 Java에서는 전통적인 I/O 에서 소켓에 바이트를 write 하는 방식을 보자

  1. 유저레벨의 자바 애플리케이션 힙 영역에 전달할 데이터 생성 한다.
  2. 유저레벨의 네이티비 메모리(물리 메모리) 공간에 고정된 버퍼를 하나 만들어서 여기다 전달할 데이터를 복사한다. 즉 유저레벨 → 유저레벨로의 복사다.
  3. 위에서 만든 물리 메모리 버퍼의 참조를 시스템 콜로 넘긴다. 시스템콜은 이 버퍼의 데이터를 커널레벨의 소켓 버퍼로 옮긴다.  이건 유저레벨 → 커널레벨로의 복사다.

 

왜 힙영역에서 물리메모리 영역으로 복사하나?

힙 메모리에서 바로 커널 메모리로 복사하면 되는거 아닌가!?

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
'Server' 카테고리의 다른 글
  • 프로세스 종료와 시그널
  • kotlin coroutine 에 대하여
  • Java의 BLOCKED 상태와 I/O 대기로 인한 일시정지 상태
  • Multi Plexing 에 대하여
WildDevmon
WildDevmon
『앗! 야생의 개발몬(이)가 나타났다!』
  • WildDevmon
    야생의 개발몬
    WildDevmon
  • 전체
    오늘
    어제
    • 분류 전체보기 (35)
      • Server (5)
      • 알고리즘 문제풀이 (27)
      • 회고 (3)
  • 블로그 메뉴

    • 홈
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    백준 10610
    백준 1114
    백준 17281
    1114백준
    graceful shutdown
    2020 카카오 인턴
    컴공 취준
    ai개발자
    선발 명단
    백준1188
    백준 고층 건물
    자바 최소공배수
    백준 30
    백준 1034
    백준 18405
    자바 스레드 상태
    백준2262
    자바 프로세스 종료
    백준14465
    다이렉트 버퍼
    백준 1027
    백준15954
    소수&팰린드롬
    백준 램프
    우아한 종료
    백준 경쟁적 전염
    소가 길을 건너간 이유 5
    통나무 자르기
    백준1238
    네카라쿠배 취업
    자바 최대공약수
    네이티브 메모리
    terminationGracePeriodSeconds
    백준1747
    경력 서류
    카카오 필요스펙
    자바 물리 메모리
    preStop
    백준 인형들
    백준 5347
    파이썬
    direct buffer
    백준6980
    커널 스레드 상태
    코틀린 코루틴
    백준 1114 파이썬
    백준 어린 왕자
    백준 파티
    합승 택시 요금
    백준 1004
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
WildDevmon
java 다이렉트 버퍼에 대하여
상단으로

티스토리툴바