Home I/O 성능 개선을 위한 DirectBuffer
Post
Cancel

I/O 성능 개선을 위한 DirectBuffer

글을 작성하게 된 계기


DirectBuffer에 대해 학습한 내용을 정리하기 위해 글을 작성하게 되었습니다.





1. DirectBuffer


버퍼는 데이터가 한 곳에서 다른 곳으로 이동하는 동안 일시적으로 데이터를 저장하는 데 사용되는 메모리의 영역 입니다. 버퍼는 NIO 채널이 상호작용할 때 같이 중간에 데이터를 저장 할 때 사용되며, 여기서 데이터 읽거나 쓸 수 있습니다. 채널은 파일, 네트워크 등의 I/O 소스 등과의 연결을 나타내며, 채널에서 데이터를 읽는다는 것은 I/O 소스로부터 데이터를 가져오는 것을 말합니다. 이 과정에서 Non-DirectBufferDirectBuffer 개념이 등장합니다.

In computer science, a data buffer (or just buffer) is a region of a memory used to store data temporarily while it is being moved from one place to another.




기존의 Java I/O는 커널 버퍼에 직접 접근할 수 없었습니다. 따라서 I/O 가 발생하면 JVM은 커널의 버퍼 영역에서 JVM 내부 메모리로 해당 데이터를 불러온 후에 접근 했으며, 커널 버퍼의 데이터를 JVM 내부 버퍼로 복사 하는 오버헤드가 발생했습니다. 또한 데이터 복사 중인 쓰레드는 블로킹을 당했기 때문에 작업의 효율성이 좋지 못했습니다.

image





DirectBuffer는 이런 문제를 개선하기 위해 등장했습니다. DirectBuffer는 JVM 힙 외부의 네이티브 메모리를 사용하는 ByteBuffer 구현체입니다. 이는 JVM 외부의 네이티브 메모리에 데이터를 직접 저장 하여 커널 버퍼와의 중간 복사 과정을 생략하고, I/O 성능을 향상시킵니다. 이를 통해 데이터 복사에 소요되는 CPU 리소스를 줄이고 시스템 자원을 보다 효율적으로 사용할 수 있습니다. 또한 DirectBuffer는 NIO와 함께 사용할 때 효율적인 데이터 전송을 가능하게 하며, Non-blocking I/O 모델과 결합될 경우 더욱 높은 성능을 기대할 수 있습니다.

image





NIO 내부 구현을 보면 파일을 읽고 쓰는 과정에서 DirectBuffer 를 사용하는데, 이는 JVM 힙 메모리가 아닌 운영체제의 네이티브 메모리를 직접 사용 하며, 이를 통해 중간 메모리 복사 과정을 생략 해 시스템 호출 비용을 줄일 수 있습니다.

image

DirectBuffer는 JVM 내부에서 DirectByteBuffer가 구현하는 인터페이스로, 네이티브 메모리를 사용하는 버퍼를 의미합니다. ByteBuffer.allocateDirect()를 통해 생성되며, 이는 JVM 힙 외부의 메모리를 사용하여 성능을 향상시킵니다.







2. 코드 살펴보기


네이티브 I/O 연산 이나 FileChannel의 read/write 연산 을 할 때, 내부적으로 DirectBuffer를 사용하는데, 이를 자바 코드로 살펴보겠습니다. 코드를 보면 DirectBuffer의 자손이 아닌 경우에도 DirectBuffer를 반환 하는 것을 볼 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
public class IOUtil {

    ......

    static int write(FileDescriptor fd, ByteBuffer src, long position,
                     boolean directIO, boolean async, int alignment,
                     NativeDispatcher nd)
        throws IOException
    {
        // DirectBuffer 타입이면 바로 반환
        if (src instanceof DirectBuffer) {
            return writeFromNativeBuffer(fd, src, position, directIO, async, alignment, nd);
        }

        ......

        ByteBuffer bb;
        if (directIO) {
            Util.checkRemainingBufferSizeAligned(rem, alignment);

            // 아니라도 DirectByteBuffer 생성
            bb = Util.getTemporaryAlignedDirectBuffer(rem, alignment);
        } else {

            // 아니라도 DirectByteBuffer 생성
            bb = Util.getTemporaryDirectBuffer(rem);
        }
        try {
            ......
            return n;
        } finally {
            Util.offerFirstTemporaryDirectBuffer(bb);
        }
    }

    ......

    public static ByteBuffer getTemporaryAlignedDirectBuffer(int size,
                                                             int alignment) {
        if (isBufferTooLarge(size)) {
            return ByteBuffer.allocateDirect(size + alignment - 1)
                    .alignedSlice(alignment);
        }

        BufferCache cache = bufferCache.get();
        ByteBuffer buf = cache.get(size);
        
        ......

        // DirectByteBuffer 생성
        return ByteBuffer.allocateDirect(size + alignment - 1)
                .alignedSlice(alignment);
    }

    ......
}





메서드를 따라가면 마지막에 DirectByteBuffer를 반환하는 것을 볼 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
public abstract class ByteBuffer
    extends Buffer
    implements Comparable<ByteBuffer>
{
    ......

    // 이 부분
    public static ByteBuffer allocateDirect(int capacity) {
        return new DirectByteBuffer(capacity);
    }

    ......
}





즉, DirectBuffer를 사용해 중간 메모리 복사 과정을 생략 하여 I/O 작업의 효율성을 향상시킨 것입니다.

image





참고로 ByteBuffer는 생성되는 위치 를 기준 으로 JVM Heap 내에 생성되는 HeapByteBuffer 와 외부 Native 공간에 생성되는 DirectByteBuffer 로 나눌 수 있습니다. HeapByteBuffer를 사용하면 JVM의 GC에 관리되기 때문에 메모리 관리로부터 비교적 자유롭지만 이는 DMA(DirectMemoryAccess)를 활용할 수 없습니다.

image

DirectBuffer를 사용하면 JVM의 외부 메모리를 직접 다룰 수 있으므로, 큰 데이터 처리나 시스템과의 효율적인 상호작용에 유리합니다.





이를 정리하면 DirectByteBuffer를 사용해 Java의 전통적인 I/O 시스템에서 사용되던 스트림의 단점을 극복하며 성능을 개선 할 수 있었습니다. NIO에서는 버퍼 제어를 개발자가 직접 할 수 있어, 처리량이나 시스템 자원에 맞춰 더 세밀한 I/O 튜닝이 가능하기 때문입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Main {
    public static void main(String[] args) {
        try (
            final BufferedInputStream bis = new BufferedInputStream(new FileInputStream("input.txt"));
            final BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream("output.txt"))
        ) {
            byte[] buffer = new byte[1024];
            int length;
            // 버퍼 크기만큼 읽고 씀
            while ((length = bis.read(buffer)) != -1) {
                bos.write(buffer, 0, length);
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}





이를 대표적으로 활용하는 곳이 NIO 인데요, NIO는 스트림의 단점을 버퍼 기반의 I/O 처리 방식으로 극복했습니다. NIO는 버퍼를 활용해 데이터의 입출력을 처리하는데, 데이터는 먼저 버퍼에 저장되며, 이 버퍼를 통해 한 번에 여러 byte의 데이터를 읽거나 쓸 수 있습니다. 이로 인해 데이터의 위치 이동 없이 필요한 부분만을 효율적으로 읽고 쓸 수 있게 되어 성능 개선의 이점을 가져옵니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class Main {
    public static void main(String[] args) {
        try (
            final FileInputStream fis = new FileInputStream("input.txt");
            final FileOutputStream fos = new FileOutputStream("output.txt");
            final FileChannel inputChannel = fis.getChannel();
            final FileChannel outputChannel = fos.getChannel()
        ) {
            // 버퍼 크기 지정 (한 번에 여러 byte 처리)
            final ByteBuffer buffer = ByteBuffer.allocate(1024);

            // 읽기 → 쓰기
            while (inputChannel.read(buffer) != -1) {
                buffer.flip(); // 읽기 모드로 전환
                outputChannel.write(buffer);
                buffer.clear(); // 다음 데이터를 위해 초기화
            }

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





물론 스트림에서도 버퍼를 사용하기 때문에 전통적인 Java I/O 방식의 한계를 극복 했다. 정도로 이해하면 될 것 같습니다. BufferedInputStream, BufferedOutputStream 은 스트림에서도 버퍼를 사용하니까요.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Main {
    public static void main(String[] args) {
        try (
            final BufferedInputStream bis = new BufferedInputStream(new FileInputStream("input.txt"));
            final BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream("output.txt"))
        ) {
            int data;
            while ((data = bis.read()) != -1) {
                bos.write(data);
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}







3. 정리


버퍼는 데이터를 일시적으로 저장하는 공간이며, 크게 DirectBuffer와 Non-DirectBuffer로 나뉩니다. 이 중 DirectBuffer는 JVM 힙이 아닌 네이티브 메모리를 사용해 I/O 성능을 높입니다. 오랜만에 내용을 정리하며 복습해보니 기억이 더 확실해지네요.


This post is licensed under CC BY 4.0 by the author.