12. NIO 개요 01.자바 IO는 느리다?

12. NIO 개요 01.자바 IO는 느리다?

블록킹 자바 IO 구 JVM, JRE1.4는 JVM 프로세스에서 커널을 거쳐 파일 디스크립터로 read() 처럼 시스템 콜을 간접적으로 이용한다. 풀어보자면 자바에서 파일 읽기를 시도하면 제일 먼저 커널에 명령을 전달한다. 커널은 시스템 콜(read())을 사용해서 디스크 컨트롤러가 물리적 디스크로부터 읽어온 파일 데이터를 커널 영역 안의 버퍼로 저장한다. 그 후 모든 파일 데이터가 커널 안의 버퍼로 복사되면 JVM(프로세스) 안의 버퍼로 복사를 시작한다.

이과정에서 두가지 비효율적인 부분이 있다.

  1. 커널 영역 버퍼에서 프로세스 영역 안의 버퍼로 데이터를 복사한다는 점이다. 물리적 디스크에서 커널 영역의 버퍼로 데이터를 저장하는 것은 디스크 컨트롤러가 DMA 기술을 이용해 CPU의 도움없이 처리할 수 있다. 하지만 커널 영역에서 프로세스 영역 버퍼로의 데이터 전달은 CPU가 관여해야 하기 때문에 느리다.

  2. 디스크 컨트롤러에서 커널 역역의 버퍼로 데이터를 복사하는 동안 프로세스 영역은 블록킹 된다. 운영체제는 효율을 높이기 위해 최대한 많은 양의 데이터를 커널 영역의 버퍼에 저장한 후 프로세스 영역의 버퍼로 전달한다. 따라서 디바이스의 파일 데이터를 커널 영역 안의 버퍼로 모두 복사할 때까지 자바 프로세스 ( 파일 읽기를 요청한 자바 스레드)가 블록킹된다.

이러한 이유로 시스템 콜을 직접 사용하는 C나 C++ 등의 저수준 언어에 비해 자바의 IO가 느리다.

03. IO 향상을 위한 운영체제 수준의 기술

  1. buffer 버퍼(buffer)는 데이터를 효율적으로 전달하기 위한 객체이다. 데이터를 한 개씩 여러번 반복적으로 전달하는 것보다는 중간에 버퍼를 두고 그 버퍼에 데이터를 모아 한 번에 전달하는 것이 훨씬 효율적이다. 그렇기 때문에 데이터를 전송하는 곳에서는 대부분 버퍼를 기본적으로 사용한다.

3가지 테스트 케이스

  1. 파일을 복사하는 간단한 코드에서 1byte씩 복사하는 방법.
  2. 2048byte씩 복사하는 방법
  3. 파일 크기 만큼 복사하는 방법.

위 케이스는 1에서 3으로 갈수록 속도가 빨라진다.

  1. Scatter / Gather 자바 프로그램 안에서 버퍼 세 개를 만들어 사용하는 경우, 동시에 각각 읽고 쓰는 작업을 수행할 때 각각, 세번의 시스템 콜이 일어날 것이다. 이는 비효율적인 것으로 이를 해결하기 위해 운영체제 수준에서 지원하는 기술이 Scatter/Gather이다.

Scatter/Gather를 사용하면 시스템 콜을 한 번만 호출한다. 대신 시스템 콜을 한 번 호출할 때마다 사용할 버퍼의 주소 목록을 넘겨줌으로서 운영체제에서는 최적화된 로직을 사용해 주어진 버퍼들로부터 순차적으로 데이터를 읽거나 쓴다.

3개의 버퍼가 있을 경우 커널 영역의 버퍼에서 나갈때 들어올때 분산 처리 방식을 이용하는 것 같다. 아직 이해는 되지 않는다.

또한 NIO에서는 성능 향상을 위해 운영체제에서 지원하는 Scatter/Gather 기능을 이용하기 위해 ScatteringByteChannel과 GatheringByteChannel 인터페이스를 사용한다.

  1. 가상 메모리 가상 메모리는 프로그램이 사용할 수 있는 주소 공간을 늘리기 위해 운영체제에서 지원하는 기술이다. 운영체제는 가상 메모리를 페이지라는 고정된 크기로 나누고 각 페이지는 메모리가 아닌 디스크에 먼저 저장된다. 그리고 실제 프로그램이 실행되는데 필요한 페이지의 가상주소만을 물리적 메모리주소로 바꿔, 실제 메인 메모리에 올려놓는 것이다.

이러한 가상 메모리를 사용할때 생기는 장점으로는 두가지가 있다.

  1. 실제 물리적 메모리 크기보다 큰 가상 메모리 공간을 사용할 수 있다는 점이다.
  2. 여러 개의 가상 주소가 하나의 물리적 메모리 주소를 참조함으로써 메모리를 효율적으로 사용할 수 있게 해준다는 것이다.

프로그램 상에서 JVM이 커널 영역의 버퍼에서 복사하는 과정을 생각해 볼때 이 가상 메모리를 이용하게 되면 커널 영역에 저장하는 동시에 유저 영역에서 저장하는 것과 같은 효과를 볼수 있다. JVM과 커널 영역이 같은 주소를 참조하게 된다면 말이다. 이는 느린 자바 IO를 해결할 수 있는 중요한 단서가 된다.

가상 메모리의 장점 중 하나가 “실제 물리적 메모리 크기보다 큰 가상 메모리 공간을 사용할 수 있다”는 것이다. 이를 실현하려면 메모리 페이징이라는것을 사용해야 한다. 메모리 페이징은 일반적인 메모리 페이지 사이ㅣ즈인 1024, 2048 등의 하나로, 운영체제에서 사용하는 페이징 크기로 물리적 디스크에 저장된다. 그리고 이들 각각은 물리적 메모리의 특정 주소를 참조하고 있다가 특정 페이지 부분이 필요하다면 디스크에 저장된페이지를 물리적 메모리에 읽어들여 사용한다.

  1. 메모리 맵 파일 간단한 워드 프로그램을 만들었다고 가정하자면 워드 프로그램 특성상 수행중에 파일을 읽고 쓰는 작어이 빈번할 것이다. 그럴때마다 비용이 값비싼 시스템 콜과 더불어 불필요한 커널 영역과 유저 영역 버퍼 간의 복사가 이루어질 것이다. 또한 복사의 댓가로 많은 가비지가 생긴다. 또 이러한 가비지는 가비지 컬렉터가 빈번하게 호출되는데 이 가비지 컬렉터가 가비지를 수거하는 것은 상당히 느린 작업이다. 이런 이유로 파일의 크기가 커지면 저장하거나 읽을 데이터의 크기가 클수록 성능은 나빠진다.

이러한 문제점을 해결하기 위해 운영체제에서 지원하는 것이 Memory-mapped IO다. Memory-mapped IO는 파일 시스템의 페이지들과 유정 영역의 버퍼를 가상 메모리로 맵핑 시킨다.

메모리 맵 파일을 사용하면 많은 장점을 얻을수 있다.

  1. 프로세스가 파일 데이터를 메모리로서 바라보기 때문에 read(), write() 시스템 콜을 할 필요가 없다는 점이다. 프로세스가 파일 데이터를 변경하면 별도의 입출력 과정을 거치지 않고 변경된 부분을 물리적 디스크에 자동으로 반영하게 되고 커널 영역에서 유저 영역으로 버퍼를 복사할 필요도 없다.
  2. 매우 큰 파일을 복사하기 위해 많은 양의 메모리를 소비하지 않아도 된다는 것이다. 파일 시스템의 페이지들을 메모리로서 바라보기 때문에 그때그때 필요한 부분만을 실제 메모리에 올려놓고 사용하면 되므로 효율적으로 메모리를 사용할 수 있다.

NIO에서는 byteBuffer를 상속하는 MappedByteBuffer라는 클래스가 있는데, 바로 메모리 맵 파일과 관련해서 사용되는 버퍼다.

  1. 파일 락 파일 락은 스레드의 동기화와 거의 비슷한 개념이다. 한 프로세스가 어떤 파일에 락을 획득했을 때, 다른 프로세스가 그 파일로 동시에 접근하는것을 막거나 또는 접근하는 방식에 제한을 두는 것이다. 이런 경우 파일의 전체 또는 일부분을 잠궈서 사용하는데 이때 바이트 단위로 계산해서 파일의 잠글 부분을 계산한다. 이렇게 파일의 일부분만을 잠궈서 사용함으로써 락이 설정되지 않은 파일의 다른 위치에서 여러 프로세스들이 동시에 다른 작업을 할 수 있게 되는 것이다.

파일 락은 크게 공유(shared) 락과 베타(exclusive) 락, 두가지로 나눌 수 있다. 일반적으로 공유 락은 읽기 작업에 사용되고 베타 락은 쓰기 작업에 사용된다. 파일 락이 사용되는 가장 대표적인 프로그램은 데이터베이스다. 데이터베이스의 테이블에 저장된 데이터를 읽는 것은 여러 사람이 동시에 접근해서 읽어간다고 별다른 문제가 발생하지 않는다. 이러한 작업에서는 공유 락을 사용한다. 이 경우 읽기 상태에서 읽기 요청이 들어오는 경우 락을 건네 주지만 읽기 상태에서 쓰기 요청이 들어오는 경우 이를 거부한다.

테이블에서 데이터를 수정하거나 삭제한다고 생각해보자. 스레드의 예처럼 동시에 공유 데이터에 접근해서 수정하려고 한다면 문제가 발생할 것이다. 파일 락은 NIO의 파일 채널과 파일 락에서 다루는 부분이다.

출처 : 김성박 송지훈 공저, 『 자바 IO & NIO 네트워크 프로그래밍』, 한빛미디어(2004.9.30), 12장 인용