Java NIO

5 분 소요

Java NIO(Non-blocking I/O)

Java NIO 는 JDK 1.4 에 적용된 패키지로 기존의 Java I/O 느린 단점을 보완하기 위해 추가되었다.

기존 Java IO

기본적으로 Java 에서는 C/C++ 처럼 메모리를 직접 관리하거나 OS 수준의 시스템 콜(커널 라이브러리)을 직접 사용 할 수 없다.

  • JNI 등을 이용하는 방법은 논외로 둔다.

기존의 Java IO 는 커널 버퍼를 직접 접근할 수 없었다.
그래서 소켓이나 파일 IO 가 발생하면 JVM 은 커널의 버퍼 영역에서 JVM 내부 메모리로 해당 데이터를 불러온 후에 접근이 가능했기 때문에 커널 버퍼의 데이터를 JVM 내부 버퍼로 복사하는 오버헤드 가 존재했음.

java-nio_bytebuffer-2.jpeg
출처 : http://eincs.com/2009/08/java-nio-bytebuffer-channel-file/

위 그림은 기존의 Java IO 가 디스크에서 파일을 읽는 과정

  • JVM 프로세스는 file 을 읽기 위해 커널에 명령을 전달
  • 커널은 시스템 콜을 사용하여 DMA(Direct Memory Access) 를 호출
  • DMA 는 디스크 컨트롤러를 이용해 디스크에서 파일을 읽어 커널 버퍼로 복사
  • JVM 은 커널 버퍼의 내용을 내부 버퍼에 복사

DMA(Direct Memory Access)
DMA 는 CPU 와 독립적으로 메모리에 접근 할 수 있게 하는 시스템의 기능
PIO(Programmed I/O) 는 CPU 가 주변장치와 데이터를 주고 받는 방식으로 CPU 리소스를 사용하는 오버헤드가 존재
이를 극복하기 위해서 DMA 가 개발되었다.
CPU 가 DMA 컨트롤러를 호출 > DMA 컨트롤러가 주변장치 데이터를 읽은 후 메모리 전송 > 전송완료 후 CPU 에게 완료 신호 송신

이런 과정은 다음과 같은 오버헤드가 있음

  • JVM 내부 버퍼로 복사하는 과정에서 CPU 리소스 사용하기 때문에 오버 헤드
    • 커널 버퍼 > JVM 버퍼로 데이터를 복사하는 일은 CPU 를 사용하게 됨 (PIO)
    • DMA 를 이용해 CPU 자원을 사용하지 않으면 CPU 자원을 다른 곳에서 사용할 수 있음
  • JVM 내부 버퍼는 사용 후 GC 대상
  • 복사를 진행하는 동안 I/O 를 요청한 thread 는 blocking 됨
    • OS 는 디스크에서 읽는 효율을 높이기 위해 최대한 많은 양의 데이터를 커널 버퍼에 저장함
    • 따라서 커널 버퍼에서 JVM 내부 버퍼로 데이터를 복사하는 동안 해당 thread 는 다른 일을 할 수 없음

따라서 이런 오버헤드를 줄이기 위해서 커널 버퍼를 JVM 에서 직접 사용하기 위해서 NIO 가 등장

Java NIO

NIO 핵심 용어

  • Channel
  • Buffer
  • Selector

Channel

  • 압출력 중 1개만 가능한 단방향 채널과 입출력 모두 가능한 양방향 채널을 지원하는 클래스
    • 기존의 Stream 기반 IO 에서는 읽기 or 쓰기만 가능했음 (단방향)
  • blocking 과 non-blocking 모두 지원
    • non-blocking IO 를 위해서 데이터를 요청한 스레드와 데이터가 있는 버퍼 사이에서 동작 가능

Channel 클레스

  • FileChannel : 파일 입출력
  • DatagramChannel : UDP 네트워크 입출력
  • SocketChannel : TCP 네트워크 입출력
  • ServerSocketChannel : TCP 연결 수신을 대기 하고 각 연결에 대해 SocketChannel 을 생성

nio-channel.png
출처 : https://velog.io/@tkadks123/Java-NIO%EC%97%90-%EB%8C%80%ED%95%B4-%EC%95%8C%EC%95%84%EB%B3%B4%EC%9E%90-12

NIO 는 Channel 을 통해 데이터 입출력을 처리함

  • 입출력 요청 스레드는 채널에 데이터 입력 요청
  • 채널은 해당 데이터를 버퍼에 추가
  • 스레드는 버퍼에서 데이터를 확인

Java NIO Scatter/Gather

  • Scatter/Gather 는 채널에서 데이터 read/write 에서 사용하는 개념

Scattering Read

  • 하나의 채널은 여러 버퍼로 데이터를 읽음
  • 따라서 채널은 데이터를 여러 버퍼로 분산함
ByteBuffer head = ByteBuffer.allocate(128);
ByteBuffer body = ByteBuffer.allocate(1024);
ByteBuffer[] byteBuffers = {head, body};

channel.read(byteBuffers);
  • 위 예시는 고정된 크기의 헤더와 본문이 있을 경우 분산 읽기를 수행하는 예제
  • channel.read() 에서는 버퍼가 발생하는 순서대로 데이터를 채움
  • head 를 먼저 채우고 body 영역을 채운다
  • 즉, 고정된 크기에 버퍼를 이용하여 분산 읽기가 가능함

Gathering Write

  • 여러 버퍼의 데이터를 단일 채널에 쓰기
  • 따라서 채널은 여러 버퍼의 데이터를 하나의 채널로 수집 가능
ByteBuffer head = ByteBuffer.allocate(128);
ByteBuffer body = ByteBuffer.allocate(1024);
ByteBuffer[] byteBuffers = {head, body};

channel.write(byteBuffers);
  • channel.write() 를 통해 버퍼의 내용을 순서대로 쓰기를 수행
  • 실제로 버퍼는 사용하는 만큼만 기록됨
    • head 버퍼는 128 바이트 할당했지만 실제로 58 바이트만 사용중이면 58 바이트만 기록됨
  • 따라서 동적 데이터에 대한 처리도 가능함

Scatter/Gather
각 버퍼를 개별적으로 매핑하여 전송하지 않고 여러 버퍼 영역에 한번에 전송할 수 있는 DMA 기술
물리적으로 인접하지 않은 여러 버퍼가 있을때 논리적으로 하나의 버퍼로 처리할 수 있도록 도와주는 기법
시스템 콜 호출 빈도를 줄이기 위해서 사용한다

Buffer

  • 버퍼는 채널과 상호작용할 때 사용되며 채널에서 데이터 읽거나 쓸 때 사용함

Buffer 클래스

  • ByteBuffer
  • MappedByteBuffer
  • CharBuffer
  • ShortBuffer
  • IntBuffer
  • LongBuffer
  • FloatBuffer
  • DoubleBuffer

Direct Buffer vs Non Direct Buffer

Direct Buffer

  • 커널 버퍼를 사용하는 방식으로 위에서 말한 오버헤드를 방지할 수 있음
  • 다만 Byte 형식으로만 사용할 수 있음
ByteBuffer buffer = ByteBuffer.allocateDirect(10);
  • 버퍼의 할당 및 해제 비용이 높음
  • 위 방식을 사용한 객체의 래퍼런스는 JVM Heap 에 생성되어 GC 되지만 커널 버퍼 영역은 JVM 이 아닌 커널에서 제어를 수행함
  • 따라서 IO 영향을 많이 받는 life time 긴 버퍼에 대해서만 할당하는 것이 좋음

Non Direct Buffer

  • 기존 Java IO 처럼 JVM 내부 메모리에 버퍼를 복사하는 방식을 사용함
  • ByteBuffer.allocateDirect 를 제외한 모든 버퍼가 Non Direct Buffer
// heap used
ByteBuffer buffer = ByteBuffer.allocate(10);

일반적인 버퍼 사용

  1. 버퍼에 데이터 쓰기
  2. 쓰기모드에서 읽기 모드로 전환 (buffer.flip())
  3. 버퍼에 데이터 읽기
  4. 읽은 데이터를 버퍼에서 지운다. (buffer.clear() / buffer.compact())
    1. buffer.clear() : 버퍼 전체를 비움
    2. buffer.compact() : 읽은 데이터만 지움

버퍼의 속성

  • Capacity : 버퍼의 고정된 크기로 버퍼가 가득차지 않도록 버퍼를 비워야 함
  • Position
    • 처음 0에서 시작하며 읽기와 쓰기에서 다른 의미를 갖음
    • 쓰기 수행시 다음에 쓰여질 위치를 의미
    • 읽기 수행시 읽을 위치를 의미
    • 쓰기 모드에서 읽기 모드로 전환되면 위치는 0으로 재설정됨
  • Limit
    • 쓰기 모드에서는 Buffer 에 쓸 수 있는 데이터의 한계
    • 읽기 모드에서는 읽을 수 있는 데이터의 제한을 의미
    • 읽기 모드로 전환될 때 쓰기 모드의 쓰기 위치로 제한이 설정됨

Selector

  • 하나의 스레드로 여러 채널을 관리하고 처리할 수 있는 클래스 (Reactor 패턴)

등장 배경

  • Selector 이전에는 각 채널에 응답을 기다리기 위해서 스레드와 채널을 1:1 매핑
  • 따라서 각 스레드는 blocking 된 상태로 채널의 응답을 기다린다
  • 하지만 채널이 매우 많아지면 채널 응답을 대기하는 스레드도 늘어나기 때문에 리소스 낭비가 발생한다

이런 단점을 보완하기 위해서 1개의 스레드로 여러 채널을 관리할 수 있는 Selector 가 도입됨

멀티 플렉싱

  • 하나의 채널을 통해 둘 이상의 데이터(시그널)를 전송하는데 사용되는 기술
  • 물리적 장치의 효율성을 높이기 위해 최소한의 물리적인 요소만 사용해서 최대한의 데이터를 전달하기 위해서 사용하는 기술

IBM 비동기 bokcing I/O multiplexing.png

  • 어플리케이션에서 커널에 IO 요청을 보내면 커널은 IO 미완료 상태를 전송
  • 어플리케이션은 커널의 응답을 계속 select 한다
  • select 의 결과가 유의미한 값이 나오면 어플리케이션은 커널로 부터 IO 처리를 시작한다
  • IO 전체가 blocking 되는 것이 아니라 커널의 응답이 blocking 된 것이다.

Java NIO Selector

java_nio_selector.png
출처 : http://tutorials.jenkov.com/java-nio/selectors.html

  • Java 의 셀렉터는 커널 라이브러리인 select() 를 사용하기 위해서 지원하는 클래스
    • 실제로 OS 환경에 따라 지원하는 select() 가 다름 (select, poll, epoll, etc..)
  • 채널을 셀렉터에 등록하고 select() 호출하면 채널 들 중에서 준비가 완료된 채널을 가져올 수 있음
    • 반환할 채널이 없다면 block 됨
  • 셀렉터에 의해서 채널이 반환되면 스레드는 해당 채널을 이용해 이벤트 처리 가능

Selector 장점

  • 단일 스레드로 여러 채널을 처리하기 때문에 스레드 수가 적어도 된다
  • 스레드 간 전환은 OS 비용이 많이 들고 메모리를 차지하기 때문에 스레드가 적을 수록 좋음

Reference