반응형

자바 메모리 누수의 원인과 처방 IT

2005.06.26. 17:33

복사 http://blog.deogtae.com/20014163212

번역하기 전용뷰어 보기

정확한 메모리 사용량 측정법
========================

자바 프로그램의 실제 메모리 사용량은 시스템의 작업 관리자에서 나오는 메모리 사용량으로는
측정의 정확도가 매우 떨어진다.
따라서, 개발자 수준에서 메모리 사용량을 측정하고 개선하기 위해서는,
자바 어플리케이션의 메모리 사용량은 디버그 출력으로 totalMemory() - freeMemory()를
출력하거나, OptimizeIt과 같은 개발도구로 측정하는 것이 좋다.

 

메모리 누수(leak)의 심각성
=======================

자바에서는 GC에 의해 메모리가 자동 관리되어 memory leak가 없다고 하지만,
사실은 memory leak가 발생할 수 있다.
그 이유는 실제로 사용되지 않는 객체의 reference를 프로그램에서 잡고 있으면
그 객체는 GC에 의해 처리되지 않고 프로그램내에서도 접근하여 사용될 수 없는
사실상 쓰레기로서 메모리(보다 정확하게는 주소 공간)를 점유하게 된다.
그러한 메모리 누수 현상이 있으면 창을 열고 닫을 때마다 그리고 문서를 열고 닫을 때마다
지속적으로 메모리가 증가되어 성능 저하뿐만 아니라 결국에는 메모리 오류 발생으로
프로그램이 종료되는 심각한 현상이 발생한다.

 

자바 GC 알고리즘
=============

 

이와 같은 자바의 메모리 누수 현상에 대한 정확한 진단과 처방은 때로는 GC 알고리즘에 대한 보다 정확한 이해를 필요로 한다.
자바의 GC 알고리즘은 reference counting을 사용하지 않아서 객체간에 cyclic reference가 생겨도 GC되지 않는 문제가 없는 완벽한 방법이지만 일정 시간의 GC 시간을 필요로 한다는 단점을 가지고 있다.
즉, 자바 heap의 모든 객체는 현재 사용중인 객체와 사용되고 있지 않는 객체, 2가지로 나뉘어지며,
사용중인 객체중에서도 사실상 사용되지 않는 객체가 있을 수 있으며 이는 메모리 누수에 해당한다는 것이다.


현재 사용중인 객체란 다음과 같은 루트 참조 (객체가 아님)들로부터 직간접적으로 참조가 되는 (reachable한) 모든 객체를 의미하며, 나머지 객체는 모두 쓰레기 객체이고, 요즘 JVM은 이러한 쓰레기 객체를 완벽하게 수거하므로 (옛날 버전의 JVM은 그렇지 않았음) 이 단계에서의 메모리 누수는 없다.


이와 같은 루트 참조는 다음과 같이 크게 3가지가 존재한다.

1. static 변수에 의한 객체 참조
2. 모든 현재 자바 스레드 스택내의 지역 변수, 매개 변수에 의한 객체 참조
3. JNI 프로그램에 의해 동적으로 만들어지고 제거되는 JNI global 객체 참조

 

이를 직관적으로 이해하는 방법은 다음과 같다.
GC 알고리즘에서 현재 사용중인 객체의 의미는 현재 생성된 객체들중에서 현재 이후에 참조되어 사용될 가능성이 있는 모든 객체를 의미한다.
객체는 직접 참조되지 않고 항상 변수를 통하여 참조가 가능하다.
static 변수는 프로그램 어디서든 사용할 수 있으므로 static 변수에 의해 참조되는 객체와 그 객체로부터 직간접적으로 참조되는 모든 객체는 언제든 사용될 가능성이 있는 객체라서 사용중인 객체이다.

 

자바에서 현재 실행중인 (각 스레드별로) 모든 메소드내에 선언된 지역 변수와 매개변수에 의해 참조되는 객체와  그 객체로부터 직간접적으로 참조되는 모든 객체는 참조되어 사용될 가능성이 있으며, 이 뿐만 아니라 caller 메소드로 return된 후에는 caller 메소드에서 참조하고 있는 지역변수, 매개변수에 의해 참조되는 객체와  그 객체로부터 직간접적으로 참조되는 모든 객체 또한, 참조되어 사용될 가능성이 있다.


따라서, 각 자바 스레드의 스택 프레임내에 있는 모든 지역변수와 매개 변수에 의해 참조되는 객체와 그 객체로부터 직간접적으로 참조되는 모든 객체들이 참조되어 사용될 가능성이 있다는 것이다.

또한, JNI 네이티브 C 함수내에서도 JNI 함수를 사용하여 자바 객체를 생성할 수 있다.
이때 생성된 자바 객체에 대한 참조를 int 값등으로 변환시켜 C 함수내의 지역 변수, 매개 변수, 전역 변수로 참조하더라도 이는 자바 가상 머쉰의 영역을 벗어나는 것으로서, 즉 자바 스레드 스택이 아닌 네이티브 스택이어서 자바 가상 머쉰의 스레기 수거 기능이 동작하지 못한다. 따라서, 자바의 static 변수나 지역 변수, 매개 변수에 의해 참조되지 않으면서 쓰레기 수거되지 않고 C 변수를 통하여 지속적으로 자바 객체를 접근할 수 있도록 JNI C 함수를 호출하여  JNI global reference로 JVM내에 등록시킬 수 있으며, 물론 등록 해제도 가능하다.


따라서, 자바의 사용되는 메모리란 사용될 가능성이 있다는 것일뿐이므로 논리적으로도 정확하게 사용되고 있는 객체가 아닌 사실상의 쓰레기 객체가 있을 수 있으며 이러한 객체들이 자바나 닷넷 프로그램의 메모리 누수 현상을 초래하는 것이다.
이와 같이 사살상의 쓰레기인지 아닌지는 기계적인 검출이 사실상 곤란하여 툴의 도움을 받을 수 있을지라도 프로그래머가 로직을 이해하여 파악해야 한다.
그렇지 않은 객체들은 어떠한 방법으로도 참조할 수 있는 수단이 없어서 확실하게 쓰레기 객체라는 것을 의미하며, 최근 버전의 자바 가상 머쉰은 이런 확실한 쓰레기 객체는 확실하게 수거해서 재사용되게 해준다.

 

메모리 누수 검출을 위한 개발 도구 사용법
===================================

 

OptimizeIt등의 도구로 보면, 전체 루트 참조 목록을 볼 수 있고 이로부터 참조되는 객체들을 모두 따라갈 수 있으며, 루트 참조들은 위에서 지적한 바와 같이 3가지 중에 1가지로 구분되어 확인할 수 있다.
또한, 특정 객체 참조를 참조하는 객체들을 따라갈 수도 있다.
메모리 누수 검출을 위해서는 메모리 누수 원인이 되는 요주의(?) 대상 객체 (창 객체나 Document 객체등)를 참조하는 객체들을 따라가서 일단 루트까지 따라가야 한다. 루트가 아닌 일반 객체에서 그래프가 끝나는 경우가
많은 데 이는 객체 참조 그래프에서 cycle이 생성되어 끝난 것이며 이러한 객체는 루트 참조가 아니므로 메모리 누수와 관련이 없어서 무시하면 된다.
일반적으로 특정 객에 이를 수 이를 수 있는 루트 참조는 몇개 정도로만 압축되므로 이들 루트 참조들을 위주로 조사를 해보면 되는 것이다.


메모리 누수 원인, 처방, 개발자들의 오해
===================================

 

메모리 누수 원인을 파악할 때 개발자들이 흔히 잘못하는 실수는 객체와 클래스(혹은 코드)의 차이를 명확히 구분하는 것이다. 스레기 수집은 객체들간의 참조 관계로부터 파악되는 것이므로 클래스 구조나 패키지 구조와 별로 관계가 없다. 또한, 이와 같은 메모리 누수가 GUI 어플리케이션과 같이 객체들간에 상호 참조가 많은 경우에는 하나의 객체 참조를 null 처리해주지
않은 실수가 전체 창, 혹은 전체 문서의 메모리 누수로 이어지는 경우가 많다.
그 이유는 Frame에서 부터 시작하여 Frame내에 포함되는 모든 UI 컴포넌트들은 parent 변수와 childs 변수를 통하여 상호 참조하게 되어 전체가 한 덩어리가 되어 이중 한개의 UI 컴포넌트에 대한 참조가 남아있어도 전체 Frame과 여기에 포함된 모든 UI 컴포넌트의 객체들이 사용중인 객체가 되는 것이다.
뿐만 아니라 이벤트 리스너 등록등으로 인하여 UI 컨트롤에서 UI 컨트롤이 아닌 객체로의 참조가 남아서 메모리 누수가 더 확대될 수 있다.

비슷한 현상으로 Document, View 구조에서 Document 구조 또한 부모-자식 Element들간에 상호 참조되어 전체가 하나의 군집을 이루고 View 구조의 각 뷰 객체들이 Document를 참조하므로 이들중 1개의 객체라도 그 참조가 남아있으면 (루트 참조에 의해 직간접적으로 참조되면) 전체 Document 객체와 이로부터 직간접적으로 참조되는 모든 객체들의 메모리 누수로 확대된다.

일반적으로 static 변수를 사용하는 이유는 프로그램내에서 전역적으로 데이터를 공유할때 사용한다.

 

static 변수는 이와 같은 메모리 누수의 원인이 되는 경우가 많으므로 굳이 static 변수를 써야만 하는 상황이 아니라면 인스턴스 변수를 사용하도록 프로그래밍시에 유의해야 한다.
즉, GUI 어플리케이션의 경우, 창 객체 참조나 문서 객체 참조를 통하여 이러한 인스턴스 변수를 (직간접적으로) 접근하도록 하는 것이다.
만약, 그 static 변수가 필히 사용될 필요가 있다면 그 static 변수로부터 창 객체나 문서 객체에 참조로 이르는 참조 그래프내의 참조 경로상의 적정 시점에서 null 대입을 통하여 그 static 변수로부터 문서 객체(혹은 창 객체)에 이르는 모든 가능한 참조 경로를 끊어주어야 한다.
가령, static 변수에 의해 참조되는 Vector에 이벤트 처리를 위해서 문서 객체나 창 객체 혹은 이들에 대한 구성 객체를 등록한 경우에는 다음과 같이 2가지 해결방법이 있다.

 

1. 그 이벤트 처리기 목록에 등록되는 객체들이 특정 창 혹은 특정 문서가 열렸을 경우에만 유효한 객체들로 분리시켜 관리할 수 있는 경우에는 이벤트 처리기 목록에 등록되는 객체 참조를 저장하는 static 변수를 창 객체나 문서 객체를 통하여 접근할 수 있는 아마 인스턴스 메소드로 접근하게 될) 인스턴스 변수에 저장하는 것이 가장 이상적이며, 이 경우 창을 닫거나 문서를 닫을때  reference를 끊어주는 처리를 전혀 할 필요가 없어 안전하다.


2. 그 이벤트 처리기 목록에 등록되는 객체들이 특정 창 혹은 특정 문서와 관련없은 전역적인 객체들이거나 적절히 분류될 수 없다면 static 변수를 사용할 수 밖에 없고, 이 경우 static 변수에 의해 참조되는 Vector내의 관련없는 객체들을 창닫거나 문서닫을 때 제거해주는 처리를 해주어야 한다.

이와 같이 메모리 누수는 대부분의 경우 static 변수가 핵심적인 역할을 하므로, 이를 주의깊게 살펴보면 대부분의 메모리 누수 문제를 해결할 수 있을 것이다.

 

자바 스레드 스택의 지역 변수가 매개 변수에 의한 참조도 살펴볼 필요가 있는데, GUI 어플리케이션에서는 이러한 지역 변수나 매개 변수는 대개의 경우 메모리 누수 문제와 관계가 없다.
그 이유는 GUI 어플리케이션이란 main 메소드와 그 메소드로부터 직간접적으로 메소드들이 끊임없이 호출되는 일반 어플리케이션 모델이 아니라, main 메소드는 창을 연후에 종료되고 일반적으로는 스레드 스택이 모두 비어 있으며 이벤트 루프를 실행시키는 스레드만이 waiting하는 경우가 대부분이라서 스레드 스택에 남아있는 게 없다는 것이다.


하지만, GUI 어플리케이션이더라도 백그라운드 스레드를 사용하는 경우에는 문제가 될 수 있다.
이 백그라운드 스레드가 실행은 되지 않더라도 종료되지 않고 waiting하고 있다면 스레드 스택이 존재하고 지역변수, 매개변수가 살아있어서 이로부터 직간접적으로 참조되는 모든 객체는 사용중인 객체가 되기 때문에 메모리 누수 요인이 될 수 있다.
즉, 요약한다면 GUI 어플리케이션에서는 메모리 누수 원인을 조사하는 데 있어서 지역 변수와 매개변수에 대해서는 백그라운드 스레드만 조사해보면 된다는 것이다.

마지막으로, JNI 전역 참조에 의한 메모리 누수는 크게 2가지 요인이 있을 수 있다.

 

1. 네이티브 코드쪽에서 JNI 전역 참조를 해제하지 않는 버그로 인하여 메모리 누수 발생.
   자바의 GUI 관련 패키지는 이제는 많이 안정화되었고 변화도 많지 않으므로 자바의 GUI 관련 패키지내에서는 이러한 버그가 있을 가능성이 거의 없다.
   JVM에 내장되지 않은 혹은 자체 제작한 JNI 함수내에서 JNI 전역 참조를 해제하지 않는 버그로 인하여 메모리 누수 발생할 수 있는데, 네이티브 코드에서 JNI 전역 참조를 만드는 경우 또한 매우 드문 경우이다.

 

2. 네이티브 자원을 반환하는 dispose(), close() 메소드를 제때에 호출해주지 않아서 메모리 누수 발생.
   이러한 메소드는 기본적으로 JNI 전역 참조를 해제하는 것이 아니고 시스템 자원
   (시스템 그래픽스, 시스템 윈도우즈, 시스템 파일 descriptor등등)을 반납하는 경우가 대부분이다.
   이들이 사용하는 메모리는 자바힙이 아니라 네이티브 힙등 네이티브 메모리 영역으로서 자바 개발 도구등으로 검출되지 않아서 자바 메모리 누수와 직접적인 관련성이 없다.
   단, 이들 네이티브 자원과 관련된 코드가 JNI 전역 참조를 가지고 있어서 이들 자원이 해제되기 전에는 JNI 전역 참조를 해제하지 않아서 해당 자바 객체의 메모리 누수가 발생할 수 있으나,
   이러한 경우는 일반적으로 드물 것이다. 그 이유는 네이티브 자원은 자바 코드보다 더 low-level한 것으로서 이러한 자원을 관리하는 코드에서 자바 객체에 대한 전역 참조를 관리할 필요성이 대부분 없기 때문이다.


메모리 누수 원인을 조사할 때 다음과 같은 순서로 문제 발생 가능성, 누수 원인이 미치는 메모리 누수량, 문제 원인 진단의 용이성이 높으므로 이와 같은 순서로 누수 원인을 철저하게 조사하는 것이 개발 효율적이다.

 

1. static 참조
2. 백그라인드 스레드의 지역, 매개 변수
3. 네이티브 코드내의 JNI 전역 참조

 

캐쉬와 관련된 메모리 누수
=====================

 

캐쉬는 일반적으로 캐쉬 엔트리에 대한 접근을 위해 키와 value로 이루어지고 HashMap등으로 관리되며, 메모리를 추가로 사용하여 속도 성능 효율을 얻는 것이 주목적이다.
이 캐쉬의 각 엔트리는 언제든 제거되어도 상관없으며, 키나 키와 관련된 데이터로부터 원본 value 객체에 접근하여 언제든 원본 value에 접근하여 가져올 수 있다는 특징을 갖는다.
그러나, 보통 간단하게 구현된 캐쉬는 캐쉬가 자꾸 커지기만 할뿐 줄어들지 않아서 메모리 누수가 발생할 수 있다.


일반적으로 변수에 대입한 참조들은 모두 강 참조(strong reference)이다.
강 참조만으로 이루어진 HashMap에서는 메모리 부족 상태를 파악하여 캐쉬 엔트리에 대한 참조를 적절히 제거해주면 메모리 누수가 발생하지 않을 수 있으나 메모리 부족 상태를 파악하기 어렵고 추가 작업을 해야 하므로 이와 같이 처리하지 않는 경우가 대부분이다.

하지만, 약간의 코딩으로 이러한 메모리 누수를 해결할 수 있는 방법이 있다.
이러한 용도로 사용할 수 있는 것이 SoftReference, WeakReference라는 것이 있다. (PhantomReference도 있는 데 잘 사용되지 않는다.)
SoftReference, WeakReference 객체를 사용하여 소프트 참조, 약 참조를 사용하면 이러한 참조 객체에 의해 참조되는 객체는 메모리가 부족해지면 JVM에 의해서 모두 null 참조로 바뀌고 쓰레기 수집된다.


예전에 실험해본 바에 의하면 소프트 참조와 약 참조 모두 할당된 자바 힙 크기가 부족해질 경우에만 비로서 쓰레기 수집되었다.
따라서, 메모리 누수는 없을지라도 사실상의 메모리 누수와 유사한 성능상 나쁜 효과를 초래할 수 있다.


소프트 참조와 약 참조는 최대 자바 힙 크기에 이를때까지 쓰레기 수집 되지 않고 최대 자바 힙 크기에 이를 때까지 메모리를 차지하여 현실적으로는 더 많은 메모리를 (더 정확히는 가상 주소 공간과 가상 메모리)를 사용하여 RAM 메모리 효율성이 떨어져서 성능 저하 효과를 가져올 수 있다.

문서 객체나 창 객체에 종속적인 캐쉬는 문서나 창이 닫힐때 캐쉬 전체를 null 대입함으로써 이와 같은 메모리 누수 문제 혹은 메모리 효율성 저하 문제를 해결할 수 있다.


Toolkit 클래스의 getImage 메소드 같은 경우에는 동일 URL에 있는 이미지를 여러번 참조하는 경우를 대비하여 효율성을 위해서 URL(혹은 파일 경로명)을 키로 하여 이미지 객체를 캐슁하여 사용한다.
이때, URL로부터 언제든 원본 이미지 데이터에 접근하여 이미지 객체를 생성할 수 있으므로 JVM 내에서는 소프트 참조로 구현되어 있으므로 메모리 부족시에는 자동으로 쓰레기 수집되므로 꼭 메모리 누수는 아니나, 상기에 언급한 이유로 메모리 효율성이 저하될 수 있다.
Toolkit 클래스의 getImage 같은 편의상 제공되는 메소드는 최적화에는 방해가 될 수 있으므로 사용하지 않는 것이 좋다.


문서내의 이미지들을 Toolkit 클래스의 getImage를 사용하지 않는다고 해도 JVM 전역적인 캐쉬를 만들어서 사용하면 메모리 누수나 메모리 효율성 저하 문제가 발생한다.
따라서, 주어진 문서내의 이미지만을 저장하는 캐쉬를 사용하고 그 문서가 닫힐 때 해당 문서 이미지 캐쉬에 대한 참조를 반환하면 이미지 캐쉬에 대한 메모리 문제를 해결할 수 있다.

이미지 캐쉬는 메모리를 많이 사용하므로 주의 대상이 된다.

 


자바힙 메모리 관리와 시스템 메모리 관리와의 관계
==========================================

 

일반적으로 윈도우즈에서 프로그램의 메모리 사용량은 작업 관리자를 열어서 메모리 사용량과 가상 메모리 사용량을 측정할 수 있는데, C 프로그램보다 자바 프로그램이 휠씬 많은 메모리를 사용하는 것으로 측정되는 경우가 많다.


그러나, 자바에서의 메모리 사용량이 MS 오피스와 같은 네이티브 어플리케이션만큼 메모리를 많이 사용한다고 해도 그 차이만큼 실제 메모리를 꼭 많이 쓰는 것은 아니다.
자바가 시스템으로부터 할당된 메모리중의 일부를 자바 힙이 사용하고 이 힙의 일부는 아직 자바 객체에 할당되지 않아서 실제 메모리로 할당될 가능성이 작고, 프로그램간 공유되는 메모리의 크기는 잘 측정되지 않기 때문이다.

 

MS사의 MSDN이나 Knowledge base를 뒤져봐도 메모리 사용량, 가상 메모리 사용량 및 이와 관련된 메모리 성능을 제대로 평가하는 데 필요한 많은 메카니즘들이 소개되어 있지 않다.
향후 이를 좀더 정확히 이해하고 자바의 GC 알고리즘의 메모리 성능 효과를 이해하게 되면
자바에서의 메모리 사용량이 그렇게 큰 문제는 아니라는 것을 명확하게 설명할 수 있을 것이며,
이를 뒷받침하는 구체적인 실험 결과를 제공할 수 있을 것이다.
만약, 실험에 의해서도 메모리 성능 문제가 심각하다면 정확한 이해를 바탕으로 이를 위한 개선 방안을 찾아볼 수 있다.

반응형
LIST
반응형

[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

+ Recent posts