Java NIO

자바 IO 프로세스

  1. 프로세스가 커널에 파일 읽기 명령을 내림
  2. 커널은 시스템콜(read())을 사용해 디스크 컨트롤러가 물리적 디스크로부터 읽어온 파일 데이터를 커널 영역안의 버퍼에 쓴다.
  3. 모든 파일 데이터가 버퍼에 복사되면 다시 프로세스 안의 버퍼로 복사
  4. 프로세스 안의 버퍼 내용으로 프로그래밍

자바 기존 IO 단점

커널 버퍼의 데이터를 프로세스 안으로 다시 복사하기 때문에 I/O 프로세스 세번째 과정은 비효율적이다. 커널영역에 바로 접근할 수 있다면 버퍼를 복사하는 CPU를 낭비하지 않고 GC관리를 따로 하지 않아도 IO를 사용할 수 있게 된다.

IO 프로세스를 거치는 동안 작업을 요청한 쓰레드는 블로킹 되어 속도가 늦춰진다.

커널 Buffer

커널 버퍼란 운영체제가 관리하는 메모리 영역에 생성되는 버퍼공간으로 자바는 외부데이터를 가져올 때 OS의 메모리 버퍼에 먼저 담은 후 JVM 내의 버퍼에 한번 더 옮겨줘야 하기 때문에 시스템 메모리를 지겁 다루는 C언어에 비해 입출력이 느리다.

이러한 단점을 개선하기 위해 나온 ByteBuffer 클래스의 allocateDirect() 메서드를 사용하면 커널 버퍼를 사용할 수 있다. 그 외에 만들어지는 버퍼는 모두 JVM 내에 생성되는 버퍼

NIO(New Input/Output)

  • 기존 IO의 단점을 개선하기 위해 JDK 4부터 추가된 패키지
  • 현재는 JDK 7에서 한번 더 버전업하여 NIO.2 API와 함께 제공되고 있는 패키지. (NIO.2 API는 java.io와 java.nio 사이의 일관성 없는 클래스 설계를 바로 잡고, 비동기 채널등의 네트워크 지원을 대폭 강화
NIO 패키지 설명
java.nio 다양한 버퍼 클래스
java.nio.channels 파일 채널, TCP 채널, UDP 채널 등의 클래스
java.nio.channels.spi java.nio.channels 패키지를 위한 서비스 제공자 클래스
java.nio.charset 문자셋, 인코더, 디코더 API
java.nio.charset.spi java.nio.charset 패키지를 위한 서비스 제공자 클래스
java.nio.file 파일 및 파일 시스템에 접근하기 위한 클래스
java.nio.file.attribute 파일 및 파일 시스템의 속성에 접근하기 위한 클래스
java.nio.file.spi java.nio.file 패키지를 위한 서비스 제공자 클래스

IO와 NIO 차이

구분 IO NIO
입출력방식 스트림방식 채널방식
버퍼방식 논버퍼 버퍼
비동기방식 지원안함 지원
블로킹/논블로킹방식 블로킹방식 지원 블로킹/논블로킹 지원

스트림과 채널

  • IO는 스트림기반으로 입력과 출력 스트림이 구분되어 있어 데이터를 읽기 위해서는 입력스트림을 생성하고, 출력하기 위해서는 출력스트림을 생성해야한다.
  • NIO는 채널기반으로 스트림과 달리 양방향 입출력이 가능하다. 따라서 입력과 출력을 위한 별도의 채널을 만들 필요가 없다.

논버퍼와 버퍼

  • IO는 스트림에서 읽은 데이터를 즉시 처리한다. 출력스트림이 1바이트를 쓰면 입력스트림이 1바이트를 읽는다. 그래서 성능을 보완하게 위해 보조스트림 BufferedInputStream, BufferedOutputStream을 연결해 사용한다.
  • NIO는 읽은 데이터를 버퍼에 저장한다. 기본적으로 버퍼를 사용해서 입출력을 하기 때문에 IO보다 높은 성능을 가진다.

블로킹 논블로킹

  • IO는 입력스트림의 read()나 출력스트림의 write()를 호출하면 데이터가 입출력되기 전까지 쓰레드는 블로킹된다. IO 쓰레드가 블로킹되면 다른 일을 할 수 없고 블로킹을 빠져나오기 위해 인터럽트도 할 수 없다.
  • NIO는 블로킹/논블로킹 특징을 모두 가지고 있다. 하지만 일반 IO와 다르게 NIO의 블로킹은 스레드를 인터럽트함으로써 블로킹에서 빠져나올 수 있다. 또한 NIO는 입출력 작업이 준비된 채널에 한해서 작업 스레드가 처리하기 때문에 논블로킹 특징도 가지게 된다.

NIO 핵심요소

명칭 설명
Buffer 입출력 데이터를 임시로 저장할 때 사용
Charset 바이트 데이터와 문자 데이터를 인코딩/디코딩할 때 사용
Channel 데이터가 통과하는 스트림을 의미
Selector 하나의 쓰레드에서 다중 채널로부터 들어오는 입력 데이터를 처리할 수 있도록 해주는 멀티플렉서
NIO 논블로킹 입출력의 핵심요소

버퍼(Buffer)

  • NIO의 버퍼 역시 보조스트림에서 사용했던 BufferedInputStream, BufferedOutputStream과 기능이 동일. 마찬가지로 바이트를 버퍼에 저장했다가 한번에 출력해주는 역할을 하게 된다.
  • NIO의 버퍼는 데이터 타입에 따라 그리고 버퍼가 사용하는 메모리의 위치에 따라 종류가 나뉘게 된다.

데이터 타입에 따른 버퍼

종류 Direct/NonDirect
버퍼여부
채널 사용여부
ByteBuffer 둘다 가능 ReadableByteChannel과 WritableButeChannel을 통해 데이터 입출력
MappedByteBuffer byte 가능(특정영역에 메모리 매핑)
CharBuffer 둘다 가능 사용 불가능
DoubleBuffer 둘다 가능 사용 불가능
FloatBuffer 둘다 가능 가능
IntBuffer 둘다 가능 가능
LongBuffer 둘다 가능 가능
ShortBuffer 둘다 가능 가능

Direct/NonDirect Buffer

구분 DirectBuffer NonDirectBuffer
사용공간 OS 메모리 JVM 힙메모리
버퍼의 생성속도 느림 빠름
버퍼의 크기 작음
I/O 성능 높음 낮음
USE 한번 생성한 뒤 재사용할 경우 빈번하게 계속해서 사용해야 할 경우

Buffer 속성 및 메서드

  • 속성
  private int mark = -1;
  private int position = 0;
  private int limit;
  private int capacity;
속성 설명
mark reset() 사용시 돌아갈 인덱스를 가진다.
position 버퍼에서 다음에 읽거나 쓰는 위치 인덱스값. 0부터 시작
limit 실제 사용할 수 있는 버퍼의 크기. 최초 버퍼를 만들었을 때에는 capacity와 같은 값을 가진다. 읽기를 시작할 때 flip() 메서드로 limit을 position의 위치로 이동하고, position은 0으로 바꾼다. 이후 position을 limit까지 순회하며 읽는다.
capacity 버퍼의 용량으로 버퍼의 총 크기
  • Buffer클래스 메서드
메서드 설명
clear() 모든 속성값 초기화
rewind() position와 makr 값 초기화
flip() limt을 position 으로 설정 후 position은 0 으로 이동. mark 초기화
reset() position을 mark 위치로 이동
mark() 현재 위치를 mark에 저장
capacity() 버퍼의 전체 크기 리턴(capacity 리턴)
  • 기타 주요 메서드 (ByteBuffer클래스)
메서드 설명
allocate(int capacity) capacity 크기의 논다이렉트 버퍼를 생성
allocateDirect(int capacity) ByteBuffer 클래스에만 있는 메서드로 다이렉트 버퍼를 생성
asXXXBuffer() 다이렉트 버퍼 생성은 ByteBuffer 클래스만 가능하지만 해당 함수를 사용하면 ByteBuffer를 다른 유형의 버퍼로 변환이 가능
wrap(byte[] array) 바이트 배열을 사용하여 버퍼를 생성
getXXX() /putXXX() 클래스의 getter/setter와 같은 쓰임새. get/put에는 상대적인 것과 절대적인 것이 존재하는데, 매개변수가 있을 경우 절대적(해당 posotion에 직접 세팅)

채널(Channel)

  • 채널은 스트림의 향상된 버전으로 단방향이 아닌 양방향으로 접근이 가능하다. 또한 스트림과 다르게 비동기적으로 닫고 중단할 수 있다.

채널의 종류

종류 설명
FileChannel 파일 입출력 채널
Pipe, SinkChannel 파이프에 데이터를 출력하는 채널
Pipe, SourceChannel 파이프로부터 데이터를 입력받는 채널
ServerSocketChannel 클라이언트의 연결 요청을 처리하는 서버 소켓 채널
SocketChannel 소켓과 연결된 채널
DatagramChannel DatagramSocket과 연결된 채널

NIO 파일 입출력 FileChannel

  • java.nio.channels.FileChannel 클래스를 이용하면 파일 읽기와 쓰기가 가능하다.
  • 동기화 처리가 되어있어서 thread-safe 하다.
  // FileChannel 클래스의 정적 메서드 open()을 사용하여 생성
  public static FileChannel open(Path path, OpenOption... options) throws IOException {
    Set<OpenOption> set;
    if (options.length == 0) {
      set = Collections.emptySet();
    } else {
      set = new HashSet<>();
      Collections.addAll(set, options);
    }
    return open(path, set, NO_ATTRIBUTES);
  }
  // StandardOpenOption enum 상수들로 FileChannel 객체의 옵션 부여
  public enum StandardOpenOption implements OpenOption {
    /** * 읽기 전용으로 열기 */
    READ,
    /** * 쓰기 전용으로 열기 */
    WRITE,
    /** * 파일 끝에 데이터를 추가 (WRITE, CREATE 와 함께 사용) */
    APPEND,
    /** * 파일을 0 바이트로 잘라냄 (WRITE 와 함께 사용) */
    TRUNCATE_EXISTING,
    /** * 파일이 없다면 새 파일로 생성. */
    CREATE,
    /** * 새 파일을 만든다. 이미 있으면 예외를 발생시킨다. */
    CREATE_NEW,
    /** * 파일 채널을 닫을때 파일을 삭제 (임시 파일용) */
    DELETE_ON_CLOSE,
    /** * Sparse 파일 */
    SPARSE,
    /** * 파일의 내용과 메타 데이터의 모든수정사항을 동기화해야 함. */
    SYNC,
    /** * 파일 내용의 모든 수정사항을 동기화해야 함 */
    DSYNC;
  }

파일 생성 예제

  public class NioStudy {
    public static void main(String[] args) {
      try(FileChannel fileChannel = FileChannel.open(
          Paths.get("C:/Users/cys77/test.txt"), 
          StandardOpenOption.CREATE_NEW, 
          StandardOpenOption.WRITE)) {

      } catch (Exception e) {
        e.printStackTrace();
      }
    }
  }

파일 읽기와 쓰기

  • FileChannel의 read(), write()를 사용할 수 있다.
  • read()의 매개변수는 크기를 설정한 ByteBuffer 객체, 리턴값은 읽어온 데이터의 바이트값. 더이상 읽어올 데이터가 없으면 -1을 반환
  • write()의 매개변수는 쓸 ByteBuffer 객체, 리턴값은 ByteBuffer의 데이터 바이트로 작성된 바이트
  int bytesCount = fileChannel.read(ByteBuffer 객체);
  
  int bytesCount = fileChannel.write(ByteBuffer 객체);

파일 비동기 채널 AsynchronousFileChannel

  • FileChannel의 read(), write()는 파일 입출력동안 블로킹이 된다. 따라서 다른 작업을 하기 위해서는 별도의 스레드를 만들어서 작업해야 한다.
  • 작업의 수가 많으면 스레드 수가 많아져 문제가 될 수 있기 때문에 자바에서는 AsynchronousFileChannel을 따로 제공한다.
  • 스레드풀을 만들어서 블로킹이 필요한 read(), write() 작업은 스레드풀이 담당한다. read(), write() 메서드는 스레드풀에게 작업을 맡기고 바로 리턴.
  • 스레드풀을 사용하면 과도한 스레드의 생성을 방지할 수 있다.
  • 스레드풀의 작업 스레드가 파일 입출력을 완료하게 되면 callback() 메서드 자동 호출
  • write(), read() 메서드를 호출하자 마자 return 하여 제어권을 넘겨주므로 논블로킹이라 할 수 있다.

TCP 논블로킹 채널

  • ServerSocketChannel, SocketChannel은 블로킹 방식도 지원하지만 논블로킹 방식도 지원
  • 논블로킹 방식은 connect(), accept(), read(), write() 메서드에서 블로킹이 없다.
  • 클라이언트의 연결요청이 없으면 accept()는 즉시 null을 리턴한다. 그리고 클라이언트가 데이터를 보내지 않으면 read()는 즉시 0을 리턴하고, 파라미터로 전달한 ByteBuffer에는 어떤 데이터도 저장되지 않는다. 논블로킹 방식에서 다음 코드는 클라이언트가 연결요청을 하지 않으면 무한루프를 돌게 된다.
  while(true) {
    SocketChannel socketChannel = serverSocketChannel.accept();
    ...
  }
  • accept() 메서드가 블로킹되지 않고 바로 리턴되기 때문에 클라이언트가 연결 요청을 보내기 전까지 while 블록 내의 코드가 쉴새 없이 실행되어 CPU가 과도하게 소비되는 문제점이 발생한다.
  • 따라서 논블로킹은 이벤트 리스너 역할을 하는 셀렉터(Selector)를 사용한다.
  • 논블로킹 채널에 Selector를 등록해 놓으면 클라이언트의 연결요청이 들어오거나 데이터가 도착할 경우, 채널은 Selector에 통보한다.
  • Selector는 통보한 채널들을 선택해서 작업 스레드가 accept() 또는 read() 메서드를 실행해서 즉시 작업을 처리하도록 한다.

논블로킹, Selector

  • Selector는 멀티 채널의 작업을 싱글 스레드에서 처리할 수 있도록 해주는 멀티플렉서 역할을 한다.
  • 채널은 Selector에 자신을 등록할 때 작업 유형을 키(SelectionKey)로 생성하고 Selector의 관심키셋(interest-set)에 저장시킨다.
  • 클라이언트가 처리요청을 하면 Selector는 관심키셋에 등록된 키 중에서 작업 처리 준비가 된 키를 선택된 키셋(selected-set)에 별도로 저장한다. 그리고 작업 스레드가 선택된 키셋에 있는 키를 하나씩 꺼내여 키와 연관된 채널 작업을 처리하게 된다.
  • 작업 스레드가 선택된 키셋에 있는 모든 키를 처리하게 되면 선택된 키셋은 비워지고, Selector는 다시 관심키셋에서 작업 처리 준비가 된 키들을 선택해서 선택된 키셋을 채운다.

  ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();

  // ServerSocketChannel 논블로킹 설정
  serverSocketChannel.configureBlocking(false);
  

  SocketChannel socketChannel = SocketChannel.open();

  // SocketChannel 논블로킹 설정
  socketChannel.configureBlocking(false);

  // 각 채널 register(Selector sel, int ops(채널의 작업유형)) 메서드를 사용해 Selector에 등록
  // ServerSocketChannel이 Selector에 자신의 작업 유형을 등록
  SelectionKey selectionKey = serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);

  // SocketChannel이 Selector에 자신의 작업 유형을 등록
  SelectionKey selectionKey = socketChannel.register(selector, SelectionKey.OP_CONNECT);
  SelectionKey selectionKey = socketChannel.register(SelectionKey.OP_READ);
  SelectionKey selectionKey = socketChannel.register(SelectionKey.OP_WRITE);

  • ServerSocketChannel은 작업 유형이 OP_ACCEPT 하나인데, SocketChannel은 상황에 따라서 읽기 작업과 쓰기 작업을 번갈아가며 작업 유형을 변경
  • SocketChannel의 작업은 세 가지, OP_CONNECT(서버 연결 요청 작업), OP_READ(읽기 작업), OP_WRITE(쓰기 작업)로 지정
  • 주의할 점은 동일한 SocketChannel로 두 가지 이상의 작업 유형을 등록할 수 없다. 즉, register()를 두 번 이상 호출할 수 없다. 작업 유형이 변경되면 이미 생성된 SelectionKey를 수정
  SelectionKey key = socketChannel.keyFor(selector);
  • Selector를 구동하기 위해 select() 메서드를 호출.
  • select() 는 관심키셋에 저장된 SelectionKey로부터 작업 처리 준비가 되었다는 통보가 올 때까지 대기(블로킹)
  • 최소한 하나의 SelectionKey로부터 작업 처리 준비가 되었다는 통보가 오면 리턴. 이때 리턴 값은 통보를 해온 SelectionKey의 수
// 작업스레드 유형별 채널 작업 처리

	Thread thread = new Thread() {
		@Override
		public void run() {
			while (true) {
				try {
					int keyCount = Selector.select();
					if (keyCount == 0) {
						continue;
					}
					Set<SelectionKey> selectedKeys = selector.selectedKeys();
					Iterator<SelectionKey> iterator = selectedKeys.iterator();
					while (iterator.hasNext()) {
						SelectionKey selectionKey = iterator.next();
						
            if (selectionKey.isAcceptable()) { /* 연결 수락 작업 처리 */ }
            else if (selectionKey.isReadable()) { /* 읽기 작업 처리 */}
            else if (selectionKey.isWritable()) { /* 쓰기 작업 처리 */ }
                        
						iterator.remove();
					}
				} catch (Exception e) {
					break;
				}
			}
		}
	};
	thread.start();

Categories:

Updated:

Comments