반응형

[NIO] JAVA NIO의 ByteBuffer와 Channel 클래스 사용하기!

기존의 Java IO는 다른 언어에 비해 매우 느리다는 이야기가 많이 있습니다. 내부적으로 어떻게 돌아가는지 대략적으로나마 파악한다면 그럴 수 밖에 없었다는 사실을 알게 되실겁니다. 하지만 jdk1.3부터는 Java IO의 한계를 보완한 Java NIO를 사용하여 I/O에서 속도 향상을 낼 수 있습니다. 그러나 NIO의 사용법은 기존 I/O와는 매우 달라 배우기가 생각만큼 쉽지는 않습니다. 이번 포스팅에서는 Java NIO에 대해 알아보고, 예제를 통해 FileHandling의 Performance를 향상시키는 간단한 예제를 다뤄 NIO에 쉽게 접할 수 있도록 하겠습니다. 생각보다 길어져서 포스팅을 세 개로 나누겠습니다.

차근차근 포스팅하도록 하겠습니다. 제가 미흡한 점이 많습니다. 혹시 내용상 오류나 오탈자를 발견하신 분은 바로 댓글로 태클 걸어주시면 감사하겠습니다^^

이전 포스팅에서 기존 Java IO의 단점과 NIO가 이런 단점을 어떻게 보완했는지에 대해서 자세히 알아봤습니다. 이제 실제 NIO 사용방법에 대해 간단히 알아보겠습니다. 사실, 라이브러리의 모든 것을 자세히 알아보다간 포스팅만 길어지고 별로 도움도 안되기 때문에 간단히 사용법만 알아보고 예제 코드를 소개해 보도록 하겠습니다.

1. NIO의 Buffer클래스

image0

이전 포스팅에서도 말씀드렸지만, NIO에서 지원하는 많은 Buffer 클래스 중 ByteBuffer 클래스만 Direct Buffer를 지원합니다. 다시 말해서, 커널 버퍼에 직접 접근할 수 있는 NIO의 장점을 이용하기 위해서는 ByteBuffer의 allocateDirect()라는 메소드를 이용해서 ByteBuffer를 만들어 내야 합니다. (allocate()메소드를 이용하면 Direct Buffer가 아닌 일반 Buffer가 만들어집니다) 다음을 꼭 기억하도록 합시다!

  • ByteBuffer만이 Direct Buffer가 가능!
  • ByteBuffer.allocateDirect()메소드를 사용해야 Direct Buffer가 생성됨!

따라서 Direct Buffer, 즉 커널 버퍼를 직접 사용하기 위해서는 CharBuffer와 같은 익숙한 데이터 타입의 Buffer를 이용하지 못하고, 불편하더라도 어쩔 수 없이 ByteBuffer를 이용하여야 합니다. 생각보다 ByteBuffer를 사용하는 것이 어렵진 않으니 걱정하진 마세요.

사실 ByteBufffer나 다른 데이터 타입의 Buffer나 사용법은 매우 비슷하고, NIO를 잘 다루기 위해선 특히 ByteBuffer를 잘 다뤄야 하기 때문에 ByteBuffer를 중점적으로 소개하도록 하겠습니다. 사용법은 대동소이하니 ByteBuffer만 잘 익히신다면 다른 종류의 Buffer를 사용하는 것은 크게 어렵지 않으실겁니다.

1.1. ByteBuffer 생성 방법!

  1. ByteBuffer buf1 = ByteBuffer.allocate(10); // direct buffer를 이용하는 것이 아님.
  2. ByteBuffer buf2 = ByteBuffer.allocateDirect(10); // 커널 버퍼를 직접 다루는 버퍼!
  3. buf2.clear();
  4. ...

1.2. ByteBuffer의 네 가지 포인터!

Buffer 에는 현재 쓰거나 읽을 위치, 유효하게 읽을 수 있는 위치, 현재 용량의 위치 등을 나타내는 포인터가 네가지가 있습니다. position, limit, capacity, mark 로 붙여진 이 네가지 포인터에 대해서 빠삭하게 숙지하고 계셔야 Buffer를 잘 사용하실 수 있습니다. 다음은 이 네 가지 포인터에 대한 간략한 설명입니다.

  • **position **: 현재 읽을 위치나 현재 쓸 위치를 가리킵니다. ByteBuffer에서 get()함수로 읽기를 시도할 경우 position위치부터 읽기 시작하며, put()함수로 ByteBuffer에 쓰기를 시도할경우 position 위치부터 쓰기를 시작합니다.. 읽거나 쓰기가 진행될 때마다 position의 위치는 자동으로 이동합니다.
  • **limit **: 현재 ByteBuffer의 유요한 쓰기 위치나 유효한 읽기 위치를 나타냅니다. 다시 말해, “이 버퍼는 여기까지 읽을 수 있습니다” 혹은 “여기까지 쓸 수 있습니다”를 나타냅니다. 헷갈리시죠? 자세한 사용법은 아래서 알아보도록 합시다. 다르게 말하면 “여기서부터는 쓸 수 없습니다”, “여기서부터는 읽을 수 없습니다” 라고 표현 가능합니다.
  • capacity : ByteBuffer의 용량을 나타냅니다. 따라서, 항상 ByteBuffer의 맨 마지막을 가리키고 있습니다. 그 때문에 position과 limit와는 달리 그 위치랄 바꿀 수가 없죠^^
  • mark : 편리한 포인터입니다. 특별한 의미가 있는 것은 아니고, 사용자가 마음대로 지정할 수 있습니다. 특별히 이 위치를 기억하고 있다가 다음에 되돌아가야할 때 사용합니다. 이 포인터에 대해선 차차 사용할 일이 있을 때 사용하실테고, 이 포스팅에선 자세히 다루지는 않겠습니다.

위의 포인터 중 값을 사용자가 지정할 수 있는 position, limit, mark 에는 getter함수와 setter 함수가 있습니다. 특이하게도 일반적인 getter함수와 setter함수와는 다르게 get/set으로 시작하지 않습니다! position의 경우엔 position()이 getter이고, position(int newPosition) 이 setter입니다. limit도 마찬가지입니다만, mark만 좀 다릅니다만, 여기서는 소개하진 안겠습니다.

여튼, 위의 네 가지 포인터간에는 다음과 같은 룰이 적용됩니다.

  1. **0<=mark<=position<=limit<=capacity**

이 룰을 어기면서 position이나 limit, mark를 setter로 강제로 지정한다면 Exception이 발생합니다.

image1

위 그림이 개념도 입니다. 읽거나 쓰기 시작하면 position에서 부터 읽거나 쓰는 것이 발생하죠. 일단 이 부분에 대해서는 그냥 이런게 있구나~하고 넘어가시면 되겠습니다. 직접 사용해보면서 어떤 것인지 알아가는 것이 확실하니까요.

1.3. ByteBuffer에 읽고 쓰기!

ByteBuffer 에 읽고 쓰는 함수에는 get()과 put() 이 있습니다. 기본적으로는 byte배열을 읽고 씁니다. 그 외에 putInt(), getInt() 등 다양한 타임에 대한 get/set을 지원합니다. 당연히 int의 경우 4byte를 사용하게 되죠. order()함수로 빅엔디안, 리틀엔디안 방식을 지정할 수 있습니다만, C/C++와는 다르게 java에서는 기본적으로 빅엔디안 방식을 사용하기 때문에 네트워크 프로그래밍을 할 때도 byteOrder를 바꿀 일이 거의 없습니다.

2. NIO의 Channel 클래스

NIO의 Channel은 Buffer에 있는 내용을 다른 어디론가 보내거나 다른 어딘가에 있는 내용을 Buffer로 읽어들이기 위해 사용됩니다. 예를 들면 네트워크 프로그래밍을 할 때 Socket을 통해 들어온 내용을 ByteBuffer에 저장하기 위해서나, ByteBuffer로 Packet을 작성 후 Socket으로 흘려 보낼 때 Channel을 사용합니다. 이런 Channel을 ServerSocketChannel 이나 Socket Channel 이라고 합니다. ServerSocketChannel이나 SocketChannel의 경우 Selector를 이용하여 Non-Blocking 하게 입출력을 수행 할 수 있지만, FileChannel은 Blocking만 가능합니다. 이 점은, 운영체제나 시스템 마다 File 입출력시 Non-Blocking을 지원해주지 않는 시스템이 있어 그런 것이라고 합니다. FileChannel은 Blocking 모드만 가능합니다! 이에 관해선 이번에 다루지 않을 Selector와 매우 깊은 관련이 있습니다. 이전 포스팅에 소개해 드렸던 Non-Blocking Server를 만드는 것과 관련이 깊으니 다음에 한번 소개해 보도록 하죠.

일단 지금 관심을 가지고 이야기 할 것은 FileChannel입니다. FileChannel은 바로 File에 있는 내용을 ByteBuffer로 불러오거나 ByteBuffer에 있는 내용을 File에 쓰는 역할을 합니다. 이에 대해서 자세히 알아봅시다. 그전에 꼭 알아야 할것은, Channel은 직접 인스턴스화 할 수가 없다! 입니다. 직접 생성자를 이용해서 인스턴스화하는 것이 아니라, OutputStream이나 InputStream에서 getChannel() 메소드를 이용하여 만들어내야 합니다. 다음을 꼭 기억합시다!

  • Channel은 직접 인스턴스화 할 수가 없다!
  • OutputStream/InputStream에서 만들어야한다!

FileChannel을 얻는 방법은 다음과 같습니다.

  1. FileInputStream fis = new FileInputStream("test.txt");
  2. FileChannel cin = fis.getChannel();
  3. FileOutputStream fos = new FileOutputStream("test.txt");
  4. FileChannel cout = fos.getChannel();
  5. RandomAccessFile raf = new RandomAccessFile("test.txt", "rw");
  6. FileChannel cio = raf.getChannel();

위에서 보시는 것과 같이 OutputStream 이나 InputStream을 통해 FileChannel을 얻으실 수 있습니다. 단순히 OutputStream이나 InputStream 외에도 RandomAccessFile과 같이 FileHandling하는 객체에도 getChannel()이라는 메서드가 있다면 FileChannel을 얻을 수 있습니다. FileInputStream이나 OutputStream의 경우, 파일포인터가 읽거나 쓰면 무조건 증가 합니다. 따라서 순차적으로 읽을 때 적당합니다. 하지만 파일 내용을 이리저리 탐색하면서 처리해야할때는 Stream을 이용하면 매우 불편하고 효율적이지도 않습니다. 따라서 이런 경우엔 파일 임의의 지점에서 읽거나 쓸 수 있는 RandomAccessFile 클래스를 이용하여야 합니다. 제세한건 java api문서를 확인합시다!

FileChannel에서 읽고 쓰는 방법은 다음과 같습니다.

  1. FileInputStream fis = new FileInputStream("input.txt");
  2. FileOutputStream fos = new FileOutputStream("output.txt");
  3. ByteBuffer buf = ByteBuffer.allocateDirect(10);
  4. FileChannel cin = fis.getChannel();
  5. FileChannel cout = fos.getChannel();
  6. cin.read(buf); // channel에서 읽어 buf에 저장!
  7. buf.flip();
  8. cout.write(buf); // buf의 내용을 channel에 저장!

보시는 것과 같이 ByteBuffer에 있는 내용을 읽고 쓸 수가 있습니다. read함수를 쓰면, position위치에서부터 limit 위치까지의 내용을 FileInputStream의 내용으로 채워 넣습니다. write함수를 쓰면, position위체에서부터 limit위치까지의 내용을 FileOutputStream에 출력합니다. 이미지로 설명하면 다음과 같습니다.

image2

image3

한 가지 의문점이 생겨야 좋은데요, 만약 InputStream으로 만든 FileChannel에서 read를 하지 않고 write를 수행하면 어떻게 될까요? 반대로, OutputStream에서 만들어낸 FileChannel에서 write를 하지 않고 raed를 하는 경우 어떻게 될까요? 위의 경우 Exception이 발생합니다. 한번 확인해 보시구요, 다음을 정리하도록 합시다.

  • InputStream으로 만들어낸 FileStream에선 read 만 할 수 있다! (write하는 경우 Exception 발생!)
  • OutputStream으로 만들어낸 FileStream에선 write 만 할 수 있다! (read하는 경우 Exception 발생!)

이건 FileChannel을 만들어낼때 사용한 객체의 특성때문이라고 생각하시면 될 것 같습니다.

그런데, RandomAccessFile의 경우에는 어떨까요. RandomAccessFileseek로 탐색한 파일포인터 위치에서 읽거나 쓸 수 있는 객체입니다. 당연하게도, read/wrtie 모두 수행 가능합니다. 하지만 seek으로 설정한 파일포인터 부터 읽거나/쓰기가 가능합니다.

3. ByteBuffer와 Channel 을 이용한 File 읽고 쓰기

간단하게 RandomAccessFile과 ByteBuffer, FileChannel을 이용하여 File의 읽고 쓰기의 방법에 대해 알아보겠습니다. 다음 코드를 보시죠.

  1. RandomAccessFile raf = new RandomAccessFile("sample.txt", "rw");
  2. FileChannel channel = raf.getChannel();
  3. ByteBuffer buf = ByteBuffer.allocateDirect(10);
  4. buf.clear();
  5. raf.seek(10); // 파일의 10째 바이트로 파일포인터 이동
  6. channel.read(buf); // channel에서 읽어 buf에 저장!buf.flip();
  7. raf.seek(40); // 파일의 40째 바이트로 파일포인터 이동
  8. channel.write(buf); // buf의 내용을 channel에 저장!
  9. channel.close();
  10. raf.close();

위 프로그램의 내용은, sample.txt에 있는 내용중 10번째 바이트부터 10개의 바이트를 읽어 ByteBuffer에 저장 후, 방금 읽어드린 10개의 바이트를 파일의 40번째 바이트부터 출력하는 예제입니다. 매우 간단한 프로그램 이지만, 이부분만 잘 이해하신다면 FileChannel을 이용하여 더 빠른 File 입출력을 구현하시는데에는 큰 어려움이 없으실 겁니다. 참고로 덧붙여서 말씀드리면, 실제로 그냥 byte배열을 이용하는 것보다 위 프로그램이 좀 더 좋은 퍼포먼스를 보여줍니다.

위에서 쓴 함수 중, clear()함수는 당연히 아시겠고, flip()함수는 ByteBuffer에 저장한 후 그 데이터를 읽기 위해서 반드시 써줘야 하는 함수입니다. limit를 현재 position으로 설정한 후, position을 0으로 설정하는 함수인데, 그 원리를 잘 생각해보세요. flip()을 쓰지 않으면 position이 쓰기를 마친 지점에 그대로 있습니다. 이럴 경우 buffer에서 read를 수행하면 방금 write 한 것이 읽혀지는게 아니라 쓰고난 다음 index부터 읽혀집니다.

 

반응형
LIST
반응형

[NIO] JAVA NIO의 ByteBuffer와 Channel로 File Handling에서 더 좋은 Perfermance 내기!

기존의 Java IO는 다른 언어에 비해 매우 느리다는 이야기가 많이 있습니다. 내부적으로 어떻게 돌아가는지 대략적으로나마 파악한다면 그럴 수 밖에 없었다는 사실을 알게 되실겁니다. 하지만 jdk1.3부터는 Java IO의 한계를 보완한 Java NIO를 사용하여 I/O에서 속도 향상을 낼 수 있습니다. 그러나 NIO의 사용법은 기존 I/O와는 매우 달라 배우기가 생각만큼 쉽지는 않습니다. 이번 포스팅에서는 Java NIO에 대해 알아보고, 예제를 통해 FileHandling의 Performance를 향상시키는 간단한 예제를 다뤄 NIO에 쉽게 접할 수 있도록 하겠습니다. 생각보다 길어져서 포스팅을 세 개로 나누겠습니다.

  • 기존 Java NIO의 단점과 NIO에서 어떻게 단점을 보완했는가? [[NIO] JAVA NIO의 ByteBuffer와 Channel로 File Handling에서 더 좋은 Perfermance내기!][related0]
  • Java NIO의 Class들과 이들의 기본적인 사용법에 대해 알아보자. [[NIO] JAVA NIO의 ByteBuffer와 Channel 클래스 사용하기!][related1]
  • 실제 FileHandling하는 간단한 예제와 실제로 Performance를 비교해보자. [[NIO] JAVA NIO와 일반 I/O로 구현한 파일 큐를 이용하여 파일 입출력 Performance 비교!][related2]

차근차근 포스팅하도록 하겠습니다. 제가 미흡한 점이 많습니다. 혹시 내용상 오류나 오탈자를 발견하신 분은 바로 댓글로 태클 걸어주시면 감사하겠습니다^^

1. 기존 JAVA IO 왜 느리다고 했을까?

Java가 다른 언어보다 느리다는 이야기가 많이 있습니다. 연산이 중요한 작업(CPU-Intensive)이라 C++로 짜는 것이 최선이거나, CPU레벨에서 제공하는 방법을 이용하면 최적화가 가능하지만 Java로 짜면 이용하지 못하는 작업들이 있습니다. 이런 작업들은 Java 같은 언어로 짜기에는 퍼포먼스가 너무 안나와서 문제가 됩니다. 이런 작업들은 Java가 아닌 C++같은 언어로 작성해야겠죠. 하지만 대부분의 비즈니스 로직은 Java로도 충분한 퍼포먼스를 낼 수 있습니다. 일반적인 경우에 Java가 다른 언어보다 느리다는 말은 아마 I/O와 Swing 때문이 클 것 입니다.

Java 프로그래머라면 Java API로 IO를 많이 다뤄보셨을겁니다. java.io 패키지의 클래스로서, byte배열의 입출력을 담당하는 OuputStream, InputStream 외에 문자타입 자료의 입출력을 담당하는 WirterReader가 그것입니다. 물론 데코레이터패턴으로 다양한 방법으로 이용할 수 있는 클래스들도 있죠.

몇 가지 예시를 살펴보죠.

image0 image1

실제 모양새를 상당히 추상화 시켜놓긴 했습니만 이해하기는 쉬우실 겁니다.

어쨋든 위와 같이 File에 문자 기반 I/O를 사용하기 위해선 File path FileWriterFileReader를 만들고 추가 기능을 위해 PrintWirter, PrintReader등의 클래스, 버퍼기능을 추가하여 속도향상을 하기 위해선 Buffered라는 접두어가 붙은 클래스를 이용하면 되었습니다. 참으로 데코레이터패턴을 효과적으로 쓴 케이스라고 할 수 있겠네요. 덕분이 처음 배우는 사람도 그나지 어렵지 않게 사용 할 수 있었습니다.

하지만 이런 기존 Java I/O는 상당히 느리고 비효율적이라는 평가를 많이 받았습니다. 실제로 그럴수밖에 없는 이유를 살펴보면 크게 두 가지입니다. 첫 번째 이유는 OS에서 관리하는 커널 버퍼에 직접 접근할 수 없었던 것이고, Blocking I/O여서 매우 비효율적이었다는 것이 두 번째 이유입니다. 이에 대해 자세해 살펴 보도록 하죠.

1.1. 기존 자바 IO는 커널 버퍼를 직접 핸들링 할 수 없어서 느리다!

기존 자바 IO에서는 커널 버퍼를 직접 접근하는 소위 Direct Buffer를 핸들링 할 수가 없었습니다. 소켓이나 파일에서 Stream이 들어오면 커널 버퍼에 쓰여지게 되는데, Code상에서 이에 접근 할 수 있는 방법이 없었기 때문입니다. 따라서 JVM이 JVM 내부의 메모리에 불러온 후에야 이 데이터에 접근 할 수 있었는데 “커널에서 JVM내부 메모리로 복사한다”라는 오버헤드가 있었기 때문에 느렸던 거죠. JVM 내부의 메모리라고 하면 int, char변수의나 byte[] 등 자료형 이겠죠. int, char, long 같은 primitive type 은 당연히 JVM내부에서 프로세스별로 할당된 스택에 저장되겠고, 배열은 JVM 내부 힙 메모리에 저장되겠죠. 말로만 하면 어려우니 그림과 함께 설명하도록 하겠습니다.

image2

기존의 Java IO가 디스크에서 파일을 읽을 때의 과정은 다음과 같습니다.

  • Process(JVM)이 file을 읽기 위해 kernel에 명령을 전달
  • Kernel은 시스템 콜(read())을 사용하
  • 디스크 컨트롤러가 물리적 디스크로 부터 파일을 읽어옴
  • DMA를 이용하여 kernel 버퍼로 복사
  • Process(JVM)내부 버퍼로 복사

따라서 다음 과 같은 문제점 이 생길 수 있죠.

  • JVM 내부 버퍼로 복사할 때, CPU가 관여
  • 복사 Buffer 활용 후 GC 대상이 됨
  • 복사가 진행중인 동안 I/O요청한 Thread가 Blocking

위 와 같은 오버헤드가 생길 수 있습니다. 이에 대해 자세히 알아보도록 하겠습니다.

소중한 CPU 자원이 낭비되기 때문에 느리다.

CPU가 관여해버기리 때문에, 버퍼가 복사하는 것 자체가 리소스를 잡아먹게 됩니다. 큰 오버헤드죠. 물리적 디스크에서 커널영역으로 복사하는 것은 DMA의 도움으로 CPU가관여하지 않기 때문에, 오버헤드가 매우 적습니다. 따라서, 만약 CPU 자원을 사용하여 내부 버퍼로 복사하지 않고 직접 커널 버퍼를 사용한다면 중요한 CPU 자원을 다른 곳에 써서 더 효율적으로 프로그래밍이 가능하겠죠. 디스크나에서 커널버퍼에 복사하는 과정은 CPU가 관여하지 않고 DMA가 해줍니다. CPU가 관여하지 않는다는 것은 CPU의 자원을 다른 곳에 쓸 수 있다는 것을 의미하죠.

내부 버퍼로 사용한 메모리가 GC 대상이 되기 때문에 느리다.

그리고 기존 Java I/O에서 내부 버퍼로 사용한 데이터 변수 - 기본적으로는 배열이 되겠죠 - 가 GC의 대상이 됩니다. 일단 버퍼로 사용하고 난 후에는 필요가 없어지기 때문이죠. Java에서 GC는 오버헤드입니다. 물론 JVM에 나올 때마다 GC의 성능이 매우 좋아지고 있긴 합니다만여전히 C나 C++에 비해 오버헤드이기 때문에 코딩을 할 때 최대한 피해야 합니다.

마지막 Blocking에 관한 부분은 다음 절에서 이야기 해보도록 하겠습니다.

1.2. 기존 자바 IO는 Blocking IO 라서 느리다!

Thread에서 블로킹이 발생하기 때문에 느리다.

위의 그림에서, 복사해올 때, I/O 요청한 Process, 정확히는 Thread가 블로킹됩니다. OS에서는 디스크를 읽는 효율을 높이기 위해 파일에서 최대한 많은 양의 데이터를 커널 버퍼에 저장합니다. 따라서 기존 Java I/O 에서는 커널 버퍼에서 JVM 내부 버퍼로 복사하는 동안 다른 작업을 못하게 됩니다. 만약 또 커널 버퍼에 직접 접근 할 수 있다면 Thread가 복사하는 시간에 다른 작업을 할 수 있겠죠.

기존 Java I/O로는 끔찍하게 비효율적인 Server Program을 만든다.

Blocking 관련된 문제점은 이 뿐만이 아닙니다. 기존 Java I/O를 이용한 Server-Client Network 프로그램을 만들 때 더더욱 심각한 문제점을 야기합니다. 이에 대해선 간단한 예제와 함께 알아보도록 하죠.

  1. ServerSocket server = new ServerSocket(10001);
  2. System.out.println(“접속을 기다립니다.”);
  3. while(true){
  4. Socket sock = server.accept();
  5. Service service = new Service (sock);
  6. service.start();
  7. serviceList.add(service);
  8. }

일번적으로 Java에서 소켓 프로그래밍을 할 때, 위와 같이 Server를 작성하죠. 아래는 Service라는 클래스를 정의한 코드입니다.

  1. class Service extends Thread {
  2. private Socket socket = null;
  3. private OuputStream out = null;
  4. private InputStream in = null;
  5. public Service(Socket socket)
  6. {
  7. this.socket = socket;
  8. out = socket.getOutputStraem();
  9. in = socket.getInputStream();
  10. }
  11. public run()
  12. {
  13. while(true) {
  14. String request = in.read();
  15. String response = processReq(request);
  16. out.write(response);
  17. }
  18. }
  19. }

기존의 Java로는 위와 같이 Server 프로그램을 작성했습니다. 위와 같이 작성하게되면 다음과 같은 특성이 있습니다.

  • 클라이언트가 접속할 때마다 블로킹됩니다.
  • 클라이언트 접속할 때마다 Thread가 생성됩니다.

이런 특성이 어떤 문제를 발생시키는지 알아보도록 하겠습니다.

기존 I/O의 Server는 블로킹 되므로 느리다.

클라이언트가 접속을 하게되면 새로운 Thread를 생성해야합니다. 자바에서 Thread를 생성하는건 c나 c++에 비하여 비교적 쉽지만 내부적으로는 매우 복잡한 프로세스를 가지고 있습니다. 따라서 Thread 생성하는 것은 비교적 시간이 오래 걸리는 오버헤드가 발생하는 작업입니다. 만약 한 클라이언트가 접속 후 짧은 시간 후에 다른 클라이언트가 Server에 접속을 시도한다면 Connection이 맺어지기 위해선 바로 앞서 접속한 클라이언트의 Thread가 모두 생성되기를 기다려야 합니다. 이렇게 waiting 해야 한다는 점에서 blocking I/O라고 볼 수가 있습니다.

이 뿐만 아니라 Service 클래스 내부에서 Socket resquest와 response를 사용할때도 blocking 됩니다. 이 점은 위에서도 언급했던 내용인데, socket에서 들어온 패킷을 읽기위해 read()함수를 호출하면 I/O를 요청 한 것이므로 해당 Thread가 블로킹 됩니다.

클라이언트 접속할 때마다 Thread가 생성됩니다.

클라이언트가 접속할 때마다 Thread가 생성되는데, 클라이언트가 접속 종료 후에는 Thread가 사용 중지가 되므로 GC대상이 됩니다. Client가 빈번히 접속하고 접속을 끊는 네트워크 환경에서 큰 문제가 될 수가 있겠죠. 앞서 말했듯이 GC는 Java에서 매우 큰 오버헤드입니다. 물론 ThreadPool을 사용한다면 Thread가 GC되는 것에 대한 오버헤드는 줄일 수 있습니다만 여전히 또 다른 문제가 발생합니다. ThreadPool은 Pool이라는 패턴입니다. Thread를 미리 N개를 생성해놓고 관리하고 있다가, Thread가 필요하게 되면 ThreadPool에서 하나를 꺼내 씁니다. 자원을 모두 사용하면 GC해버리는 것이 아니라 ThreadPool에 다 사용했음을 알리면 다음 번에 다시 꺼내 쓸 수 있습니다. Java의 장점이나 단점중 하나인 GC의 오베헤드를 줄이기 위한 방법 중 하나로 꽤 자주 쓰이는 패턴입니다.

만약 서버에 매우 많은 수의 클라이언트가 접속을 시도하게 된다면 그 만큼의 Thread가 필요하게 됩니다. 그만큼 서버의 자원이 낭비되는것이죠.

위와 같은 이유로 기존의 Java I/O는 C나 C++과 같이 직접 시스템 콜을 사용하여 입출력을 하는 언어보다 느립니다. 후에 또다시 간단히 설명하겠지만, C나 C++에서는 select()와 같은 시스템콜을 사용하기 때문에 서버 프로그램에서 클라이언트마다 Thread를 만들 필요가 없습니다. Java NIO에서는 select() 시스템콜을 간접적으로나마 사용할 수 있도록 지원해줘서 Thread가 많이 필요하지 않으면서도 좋은 성능을 내는 서버를 만들 수 있도록 기술을 제공하고 있습니다. 하지만 jdk1.3부터 새로 생긴 java.nio 패키지의 NIO가 기존 Java I/O의 문제점을 어떻게 해결했는지 알아보도록 하겠습니다.

2. NIO는 왜 기존 Java I/O 더 빠른가?

위에서 기존의 Java I/O의 단점 대해 살펴봤습니다. 위와 같은 문제점이 있는 Java I/O는 jdk 1.3에 와서야 그 단점을 보완하는 NIO가 등장하게 되었습니다. 시스템 서비스를 사용하지도 못하고 여기저기 헛점 투성이인 Java I/O 의 문제점이 jdk 1.3에서야 보완된 이유는 Java의 철학 때문 이겠죠. Java의 가장 중요한 특성인 플랫폼 간의 이식성, 다시말해 Once Write, Run AnyWay! 를 지키기 위해서 각 OS별로 system call이나 커널을 직접 이용하는 것은 기술적으로 매우 어려운 일이었습니다. jdk 1.3에서야 통일된 인터페이스로 각 시스템 별로 네이티브 언어를 이용하여 그 기능을 구현해주는 노가다가 완성된거죠. 여튼, 각설하고 NIO가 어떻게 Java I/O를 단점을 보완했는지에 대해서 간단히 알아보도록 하겠습니다. 자세한건 다음 포스팅에서 설명할 것이기 때문에 내용이 짧아 질 것 같군요^^

2.1. NIO는 Direct Buffer 로 커널 버퍼를 직접 핸들링하기 때문에 빠르다!

기존 Java I/O에서의 JVM 내부 메모리로의 복사문제를 해결하기 위해 NIO에서는 커널 버퍼에 직접 접근할 수 있는 클래스를 제공해줍니다. Buffer클래스들이 그것인데요, 내부적으로 커널버퍼를 직접 참조하고 있습니다. 일종의 포인터 버퍼라고 볼수있는데 운영체제가 제공해주는 효율적인 I/O 핸들링 서비스를 이용 할 수 있게 해줍니다. 따라서 위에서 발생한 복사문제로 인해 CPU자원의 비효율성, I/O 요청 Thread가 Blocking 되는 문제점 등이 해결될 수 있는 것이죠.

image3

위 그림을 살펴보면 Buffer에는 여러가지 자료형을 지원합니다만, DirectBuffer는 [ByteBuffer][1]만 지원합니다. 따라서 커널 버퍼를 직접 사용하고 싶다면 불편하더라도 ByteBuffer만 사용해야합니다. Buffer를 만드는 방법은 다음과 같습니다.

  1. ByteBuffer buf = ByteBuffer.allocate(10);
  2. ByteBuffer directBuf = ByteBuffer.allocateDirect(10);

아랫줄의 코드와 같이 사용하여야 커널 버퍼를 직접 이용하는 것입니다. 윗 줄은 기존 방식과 같은 거죠. JVM내부에 메모리가 할당 됩니다.

Direct Allocate [ByteBuffer][1]의 put(), get(), position(), flip(), clear() 등의 메소드로 커널 버퍼를 핸들링 할 수 있습니다. 자세한건 다음 포스팅에서 알아보도록 하겠습니다.

이번 포스팅에서는

NIO에서는 커널 버퍼를 직접 이용할 수 있게 해주는 Buffer Class를 지원한다! 커널 버퍼를 직접 이용할수 있는건 ByteBuffer.directAllocate(SIZE); 로 생생된 ByteBuffer뿐이며, 다른 Buffer들은 기존의 방식과 똑같다.

만 기억하시면 됩니다.

2.2. NIO에서 System Call을 간접적으로 사용가능하게 해주기 때문에 빠르다!

위 에서 살펴보았던 서버프로그램의 예제를 기억하시나요? 엄청난 수의 Thread가 생성되고 GC가 되어야 했죠. NIO에서는 이점을 해결 했습니다. c나 c++로 만들어진 Server Program은 Thread를 생성하지 않고도 많은 수의 클라이언트를 처리할 수 있습니다. 이를 가능케 해주는 것이 OS 레벨에서 지원하는 Scatter/Gather 기술과 Select() 시스템 콜입니다. Scatter/Gather은 시스템콜의 수를 줄이는 기술인데요, 덕분에 I/O를 빠르게 만들 수 있죠. c나 c++에서는 이런 OS수준의 기술들을 이용하여 I/O속도를 향상시켜왔지만 java에서는 이런 시스템에서 제공하는 기술을 사용할 수 있는 방법이 없었죠. 하지만 NIO에서는 가능합니다. 이런 것을 가능하게 해주는 Class가 바로 ChannelSelector입니다.

 

반응형
LIST
반응형

[NIO] JAVA NIO와 일반 I/O로 구현한 파일 큐를 통한 파일 입출력 Performance 비교!

기존의 Java IO는 다른 언어에 비해 매우 느리다는 이야기가 많이 있습니다. 내부적으로 어떻게 돌아가는지 대략적으로나마 파악한다면 그럴 수 밖에 없었다는 사실을 알게 되실겁니다. 하지만 jdk1.3부터는 Java IO의 한계를 보완한 Java NIO를 사용하여 I/O에서 속도 향상을 낼 수 있습니다. 그러나 NIO의 사용법은 기존 I/O와는 매우 달라 배우기가 생각만큼 쉽지는 않습니다. 이번 포스팅에서는 Java NIO에 대해 알아보고, 예제를 통해 FileHandling의 Performance를 향상시키는 간단한 예제를 다뤄 NIO에 쉽게 접할 수 있도록 하겠습니다. 생각보다 길어져서 포스팅을 세 개로 나누겠습니다.

차근차근 포스팅하도록 하겠습니다. 제가 미흡한 점이 많습니다. 혹시 내용상 오류나 오탈자를 발견하신 분은 바로 댓글로 태클 걸어주시면 감사하겠습니다^^

1. 파일 큐 (File Queue)

자료구조에 나오는 큐(queue)에 대해선 잘 아실겁니다. 선입선출의 특성을 가지는 Collection이죠. 다양한 방법으로 구현이 가능합니다. 이번 포스팅에서 고려하고자 하는건 파일 큐(File Queue)입니다. 말 그대로 큐에 들어가는 내용을 파일에 넣자는 거죠.

파일 큐(File Queue)는 파일에 내용을 저장하는 큐를 의미한다!

image0

일반적으로 메모리상에 내용을 저장하는 것이 속도가 빠르다는 것은 자명한 사실입니다.  파일 입출력은 당연히 메모리 입출력보다 느리지요. 하지만, 메모리는 휘발성이라는 위험부담이 있습니다. 빠르면 좋겠지만, 시스템의 다운에도 큐에 저장하는 내용이 매우 중요한 경우에 파일 큐를 쓰게 됩니다.

  • 파일 큐는 메모리 큐보다 느리다.
  • 파일 큐는 시스템의 다운에도 안전하지만 메모리 큐는 시스템이 다운되면 큐에 쌓인 내용이 날아가 버릴 위험성이 있다. (메모리는 휘발성, 파일은 안전함)
  • 시스템 다운에도 내용이 보전되어야 할 중요한 자료를 저장하는 큐는 파일 큐로 만든다. ex) 은행에서 금전 거래에 대한 이벤트 정보 (이벤트 큐에 넣겠죠) 그 외에도 많은 곳에 적용 될 수 있습니다. 어쨋든 큐는 요청과 처리를 비동기적으로 처리하면서 속도를 올릴 때 꼭 필요한 방법이니까요.

위의 문제 때문에 파일 큐가 필요하다는 것은 자명한 사실입니다.

2. 파일 큐 (File Queue)를 구현하기

파일큐의 구현 방법을 아주 간단히 설명하겠습니다. 일단 파일 큐는 다음 인터페이스를 구현할 생각입니다.

  1. public interface ByteFileQue {
  2. public boolean open() throws IOException
  3. public int put(byte[] srcBuf);
  4. public byte[] get() throws IOException;
  5. public byte[] peek();
  6. public int size();
  7. public boolean close();
  8. public boolean isClosed();
  9. }

open()은 파일을 오픈, close()는 파일을 닫는 메서드입니다. put(), get() 함수는 각각 바이트 배열을 큐에 넣고 빼는 메서드입니다. 간단한 인터페이스죠.

각각 ByteFileQue를 구현할 두 클래스가 있습니다. 각각 SimpleByteFileQue, NIOByteFileQue 라고 이름을 짓겠습니다. 보시면 당연히 아시겠지만, SimeByteFileQue는 일반 java I/O로 구현한 파일 큐이고, NIOByteFileQue는 NIO로 구현한 파일 큐입니다. 두가지 모두 RandomAccessFile을 이용하여 구현 하면 됩니다. RandomAccessFile을 NIO를 이용하여 입출력하는 방법은 이전 포스팅을 참고합시다.

파일의 맨 앞부분에 헤더를 만듭니다. 헤더에는 tail, haed의 파일 포인터 위치와 size에 대한 위치를 저장합니다. 따라서 get할때에는 tail에 대한 위치를 읽어 seek(tail) 과 같은 방법으로 파일포인터를 이동한후 정해진 길이만큼 파일을 읽어 반환을 하면 됩니다. 한 가지 주의할 점은 반드시 ByteBufferallocateDirect를 이용하여 할당해야 한다는 점입니다.

  • 자바상에서 DMA의 도움을 받을 수 있는 Direct Buffer를 사용하려면 ByteBuffer를 사용하여야 함!
  • ByteBuffer.allocateDirect()메소드를 사용해야 Direct Buffer가 생성됨!

쓰레드간 동기화 부분도 고려해야 되는데, synchronized 블록이나 synchronized 메서드를 사용하는 방법과, ReentrantLock을 사용하는 방법이 있습니다. 테스트 해보았는데, 두 방법 모두 비슷한 퍼포먼스를 내므로, 둘 중 아무 방법을 사용하셔도 무방합니다. 하지만 [ReentrantLock][1]은 프로세스간 동기화를 유지시켜주는 FileLock과 함께 사용할 수 없습니다. 따라서 synchronized 블록 혹은 synchronized 메서드로 동기화 하는 방법을 추천해드립니다.

FileLock은 jdk1.4부터 지원되는 클래스입니다. 기존 jdk에서는 JVM상 돌아가는 프로세스간 파일 동기화를 지원해주지 않았습니다. 때문에, 따로 파일을 둬서 파일을 읽는 중인지 혹은 그렇지 않은지를 표시하여 동기화를 유지하였다고 합니다. 매우 불편한 방법이죠. 하지만 이제는 자바에서 FileLock으로 매우 쉽게 프로세스간 동기화를 구현할 수 있습니다. 다만 FileLock은 쓰레드간 동기화를 유지시켜주지는 않습니다.

자세한 것은 각자 구현해 보도록 합시다.

2. NIO 파일 큐와 일반 파일 큐의 속도 차이 비교하기

생각보다 확연하게 차이가 납니다. 속도 차이를 나타내는 자료를 봅시다.

아래 자료는 1분간 get 메서드와 put 메서드가 몇개의 자료를 넣고 뺄 수 있는지에 대한 수행 자료입니다. NIO쪽이 월등히 많다는 것을 볼 수 있습니다. 일반 FileQueue는 put 함수를 반복하여 수행했을때 1분동안 38만건 정도 put할 수 있었고, Nio FileQueue는 130만 건 정도 수행 할 수 있었네요. 엄청나게 월등하죠.

image1

아래 자료는 각각 Thread를 두개를 돌려 한 쪽 쓰레드에선 Queue에 자료를 넣고, 다른 쪽 큐에서는 자료를 꺼내는 작업을 반복시킨 것입니다. 따라서 쓰레드간 동기화시 Blocking 되는 overhaed까지 포함된 결과 입니다. 넣고 꺼내는 자료는 100개부터 1,000,000개 까지 수행하였습니다. 걸리는 시간의 단위는 밀리새컨드 입니다. 위에서 put/get만 수행할 때만큼 속도 차가 나지는 않죠. 이것은 Thread 동기화시 blocking 현상때문입니다. 속도가 빠른만큼 blocking 될 확률이 높죠. 그럼에도 불구하고 NIO가 여전히 월등한 퍼포먼스를 보여줍니다.

image2

보시는 바와 같이 NIO가 월등히 좋은 퍼포먼스를 내는 것을 보실 수 있습니다.

반응형
LIST
반응형
자바 성능을 결정짓는 코딩 습관과 튜닝이야기 - 읽고- (5)
* 반복구문 사용 시 유의 할점
- for, switch ~ case, while 등이 있으면 while의 경우 무한반복의 위험이 있으므로, switch ~ case 경우 검색 조검이 많이 들어가므로 for 문을 사용하라.
- for문의 경우 반복해서 고정적인 값을 가질때는 for문 밖에서 정의해서 사용하는것이 속도 향상에는 도움이 된다.
.EX)for문의 사이즈 a.length, b.size() 의 경우 for문 상단에서 객체를 사용 받아서 사용한다.
- 불필요한 반복 메소드 요청을 파악하여 for문 밖에서 정의 한다.

 

반응형
LIST

+ Recent posts