버퍼는 읽고 쓰기가 가능한 메모리 배열로 NIO에서는 데이터를 입출력하기 위해 항상 사용합니다.
NIO의 버퍼는 저장되는 데이터 타입에 따라서 분류할 수 있고, 어떤 메모리를 사용하느냐에 따라서 다이렉트와 넌다이렉트로 분류할 수 있습니다.
데이터 타입에 따른 버퍼
- ByteBuffer: byte 데이터가 저장되는 버퍼입니다.
- MappedByteBuffer: 파일 내용에 랜덤하게 접근하기 위해서 파일의 내용을 메모리와 맵핑시킨 버퍼입니다.
- CharBuffer ~ DoubleBuffer: 각 데이터 타입이 저장되는 버퍼입니다.
넌다이렉트와 다이렉트 버퍼
버퍼가 사용하는 메모리의 위치에 따라서 넌다이렉트 버퍼와 다이렉트 버퍼로 분류됩니다. 넌다이렉트 버퍼는 JVM이 관리하는 힙 메모리 공간을 이용하고 다이렉트 버퍼는 운영체제가 관리하는 메모리 공간을 이용합니다.
구분 | 넌다이렉트 버퍼 | 다이렉트 버퍼 |
사용하는 메모리 공간 | JVM 힙 메모리 | 운영체제의 메모리 |
버퍼 생성 시간 | 버퍼 생성이 빠르다 | 버퍼 생성이 느리다 |
버퍼의 크기 | 작다 | 크다 |
입출력 성능 | 낮다 | 높다 |
다이렉트 버퍼는 운영체제의 메모리를 할당받기 위해 운영체제의 네이티브 C 함수를 호출해야 하고 여러 가지 잡다한 처리를 해야 하므로 상대적으로 버퍼 생성이 느립니다. 그렇기 때문에 자주 생성하기보다는 한 번 생성해 놓고 재사용하는 것이 적합합니다.
넌다이렉트 버퍼는 입출력을 위해서 임시 다이렉트 버퍼를 생성하고 넌다이렉트 버퍼에 있는 내용을 임시 다이렉트 버퍼에 복사합니다. 그러고 나서 임시 다이렉트 버퍼를 사용해서 운영체제의 native I/O를 수행합니다. 따라서 직접 다이렉트 버퍼를 이용하는 것보다는 입출력 성능이 낮습니다.
중요한 것은 다이렉트 버퍼가 채널을 이용해서 버퍼의 데이터를 읽고 저장할 경우에만 직접 운영체제의 native I/O를 수행한다는 것입니다. 만약 채널을 사용하지 않고 ByteBuffer의 get()/put() 메소드를 사용하면 이 작업은 대부분 JNI를 호출해서 native I/O를 수행하기 때문에 JNI 호출이라는 오버헤드가 발생합니다. 그렇기 때문에 오히려 넌다이렉트 버퍼의 get()/put() 메소드 성능이 더 좋게 나올 수도 있습니다.
ByteBuffer byteBuffer = ByteBuffer.allocateDirect(100).order(ByteOrder.nativeOrder());
각 데이터 타입별로 넌다이렉트 버퍼를 생성하기 위해서는 각 Buffer 클래스의 allocate()와 wrap() 메소드를 호출하면 되고, 다이렉트 버퍼는 ByteBuffer의 allocateDirect() 메소드를 호출하면 됩니다.
1. allocate()
JVM 힙 메모리에 넌 다이렉트 버퍼를 생성합니다.
ByteBuffer byteBuffer = ByteBuffer.allocate(100);
2. wrap()
각 데이터 타입별 Buffer 클래스는 모두 wrap() 메소드를 가지고 있습니다. wrap() 메소드는 이미 생성되어 있는 자바 배열을 랩핑 해서 Buffer 객체를 생성합니다. JVM 자바 배열은 힙 메모리에 생성되므로 wrap() 또한 넌다이렉트 버퍼를 생성합니다. 두 번째 wrap() 메소드는 배열의 일부만을 가지고 Buffer를 생성합니다.
byte[] byteArray = new byte[100];
ByteBuffer byteBuffer = ByteBuffer.wrap(byteArray);
or
ByteBuffer byteBuffer = ByteBuffer.wrap(byteArray, 0, 50);
3. allocateDirect()
운영체제가 관리하는 메모리에 다이렉트 버퍼를 생성합니다. 이 메소드는 ByteBuffer 클래스에서만 제공합니다. 각 타입별 다이렉트 버퍼를 생성하고 싶다면 우선 allocateDirect() 메소드로 버퍼를 생성한 다음 asXXXBuffer() 메소드를 이용해서 타입별 Buffer를 얻으면 됩니다.
// 100 byte 저장 가능
ByteBuffer byteBuffer = ByteBuffer.allocateDirect(100);
// char는 2byte 이므로 50개의 문자 저장 가능
CharBuffer charBuffer = ByteBuffer.allocateDirect(100).asCharBuffer();
System.out.println(charBuffer.capacity());
>> out
50
4. byte 해석 순서 (ByteOrder)
앞쪽 바이트부터 처리하는 것을 빅-엔디안이라고 하고, 뒤쪽 바이트부터 처리하는 것을 리틀-엔디안이라고 합니다. JVM은 JRE가 설치된 어떤 환경이든 빅-엔디안으로 동작하도록 되어 있습니다. 다음은 현재 운영체제의 종류와 ByteOrder를 출력하는 예제입니다.
public class SystemByteOrder {
public static void main(String[] args) {
System.out.println("운영체제 종류 :" + System.getProperty("os.name"));
System.out.println("네이티브의 바이트 해석 순서 :" + ByteOrder.nativeOrder());
}
}
>> out
운영체제 종류 :Windows 10
네이티브의 바이트 해석 순서 :LITTLE_ENDIAN
ByteOrder가 다른 경우에 JVM이 알아서 처리해주기는 하지만 그래도 운영체제의 ByteOrder에 맞게 JVM의 ByteOrder를 변경해주는게 성능에 도움이 됩니다. allocateDirect() 메소드로 다이렉트 버퍼를 생성하고 order(ByteOrder.nativeOrder()) 를 호출해주면 운영체제의 ByteOrder에 맞게 변경할 수 있습니다.
ByteBuffer byteBuffer = ByteBuffer.allocateDirect(100).order(ByteOrder.nativeOrder());
버퍼 다루기
1. 버퍼의 위치 속성
속성 | 설명 |
position | 현재 읽거나 쓰는 위치값. 0부터 시작하며, limit 보다 큰 값을 가질 수 없다. position == limit이 된다면 더 이상 데이터를 쓰거나 읽을 수 없다. |
limit | 버퍼에서 읽거나 쓸 수 있는 위치의 한계를 나타냄. capacity 보다 작거나 같다. |
capacity | 버퍼의 최대 데이터 개수를 나타냄. 인덱스 값이 아니라 수량임을 주의해야 한다. |
mark | reset() 메소드를 실행했을 때에 돌아오는 위치를 지정하는 인덱스로서 mark() 메소드로 지정할 수 있다. 주의할 점은 반드시 position 이하의 값으로 지정해주어야 한다. |
읽고 쓰는 작업은 다음과 같은 순서로 이루어집니다.
- 데이터를 저장합니다.
- 데이터의 크기만큼 position이 뒤로 이동합니다.
- 데이터를 읽기 위해서 flip() 메소드를 호출합니다.
- limit이 position 위치로 이동하고 position이 인덱스 0으로 설정됩니다.
- mark() 메소드를 호출하면 현재 position 위치를 마킹할 수 있습니다.
- reset() 메소드를 호출하면 position을 마킹해둔 위치로 이동합니다.
- 마킹이 없는 상태에서 reset() 메소드를 호출하면 InvalidMarkException이 발생할 수 있습니다.
- 데이터를 한 번 더 읽고 싶다면 rewind() 메소드를 호출해서 position을 인덱스 0으로 설정할 수 있습니다.
- position이나 limit이 mark 보다 작은 값으로 조정되면 mark는 자동으로 제거됩니다.
- clear() 메소드를 호출하면 position, limit, mark가 초기화됩니다. 데이터는 유지됩니다.
- compact() 메소드를 호출하면 position ~ limit 사이의 데이터가 인덱스 0에 복사됩니다. 복사된 데이터의 끝 뒤에 position이 위치하게 됩니다.
2. put(), get()
절대적, 상대적인 put(), get() 이 있습니다. 절대적이라는 것은 position과 같은 버퍼 위치 속성을 사용하지 않고 데이터를 읽고 쓰는 것을 의미합니다. 반면에 상대적이라는 것은 위치 속성을 이용한다는 의미입니다. put(), get()에 index 매개 변수가 없으면 상대적 메소드를 의미합니다. 상대적 메소드는 위치 속성 사용에 따라서 예외가 발생할 수 있으니 주의해야 합니다.
3. 버퍼 변환
ByteBuffer를 String으로 변환
Charset charset = CharSet.forName("UTF-8");
String data = charset.decode(byteBuffer).toString();
String을 ByteBuffer로 변환
Charset charset = CharSet.forName("UTF-8");
ByteBuffer byteBuffer = charset.encode(stringData);
IntBuffer를 ByteBuffer로 변환
// 랩핑
int[] data = new int[] {10, 20};
IntBuffer intBuffer = IntBuffer.wrap(data);
// 생성
ByteBuffer byteBuffer = ByteBuffer.allocateDirect(intBuffer.capacity() * 4); // (1)
for(int i = 0; i < intBuffer.capacity(); i++) {
byteBuffer.putInt(intBuffer.get(i)); // (2)
}
// position 초기화
byteBuffer.flip();
- (1): int는 4바이트 이므로...
- (2): putInt()를 사용했다. 데이터 타입에 맞게 사용..
ByteBuffer를 int[ ]로 변환
참고로 ByteBuffer에서 asIntBuffer() 메소드로 얻은 IntBuffer에서는 바로 int[ ]를 추출할 수 없습니다. 랩핑한 배열이 없는 상태이기 때문에 불가능합니다.
public class ByteBufferTransferTest {
public static void main(String[] args) {
// 랩핑
int[] data = new int[] {10, 20};
IntBuffer intBuffer = IntBuffer.wrap(data);
// 생성
ByteBuffer byteBuffer = ByteBuffer.allocateDirect(intBuffer.capacity() * 4);
for(int i = 0; i < intBuffer.capacity(); i++) {
byteBuffer.putInt(intBuffer.get(i));
}
// 데이터 읽은 후 position 초기화
byteBuffer.flip();
// IntBuffer 생성
IntBuffer readIntBuffer = byteBuffer.asIntBuffer();
int[] readData = new int[readIntBuffer.capacity()];
// get()
readIntBuffer.get(readData);
System.out.println(Arrays.toString(readData));
}
}
>> out
[10, 20]
다른 데이터 타입의 버퍼들 간의 변환도 비슷하게 수행하면 됩니다.
'JAVA' 카테고리의 다른 글
예외 처리와 SQLException (0) | 2022.10.24 |
---|---|
Java.nio Selector의 필요성 (0) | 2022.09.20 |
[기본 시리즈] JAVA 스레드에 대하여 (0) | 2022.09.08 |
[기본 시리즈] java.io 자바 기본 네트워킹 TCP/IP (0) | 2022.09.06 |
[기본 시리즈] java.io 입력 스트림과 출력 스트림 (with 보조 스트림) (0) | 2022.09.02 |