
Java 1.4 NIO에 추가된 ByteBuffer에 대한 글입니다.글이 너무 길어 주의를 요합니다.
I/O가 발생하면 운영체제는 기본적으로 kernel buffer로 데이터를 복사합니다. 이 과정은 DMA(Direct Memory Access)가 담당합니다.
Java NIO에는 kernel buffer에 담긴 데이터에 직접 접근하는 방법이 추가됐습니다.. 그 방법이 바로 제목의 ByteBuffer 입니다.
기존의 Java IO는 kernel buffer에 직접 접근이 불가능하기 때문에 JVM heap buffer로 데이터 복사가 필요합니다.
JVM heap buffer로의 데이터 복사는 다음과 같은 단점을 가집니다.
JVM heap buffer를 사용하는 대용량 I/O 요청이 많으면 GC가 잦아지고 성능저하로 이어질 수 있습니다.
위에서 설명한 단점을 개선하기 위해 Java NIO에는 ByteBuffer가 추가됐습니다.
ByteBuffer의 컨셉은 kernel buffer로의 직접 접근입니다.
ByteBuffer의 상속관계 다이어그램은 다음과 같습니다.

ByteBuffer의 구현체로는 DirectByteBuffer, HeapByteBuffer가 있습니다.(추상클래스인 MappedByteBuffer는 다음에..)
HeapByteBuffer는 이름을 보면 알 수 있듯 JVM heap buffer를 사용합니다.
HeapByteBuffer의 read 동작 순서를 확인해보겠습니다.
HeapByteBuffer의 생성 방법은 다음과 같습니다.
ByteBuffer.allocate(1024);
관련 코드를 조금만 더 추적해보겠습니다.
ByteBuffer.java

new HeapByteBuffer -> HeapByteBuffer.java

super -> ByteBuffer.java

여기서 보이는 hb가 ByteBuffer.java의 final byte[] hb;필드입니다. ByteBuffer.allocate(int capacity)에서 capacity로 지정한 만큼 byte array를 생성하는 걸 확인할 수 있습니다.
추상클래스 FileChannel.java를 상속받은 FileChannelImpl.java를 확인해보겠습니다.
FileChannelImpe.java

sun.nio.ch.IOUtil 클래스의 read 메서드를 타고 들어가보면 다음과 같은 read() 메서드를 확인할 수 있습니다.

①번을 보면 DirectBuffer의 하위클래스인 경우 readIntoNativeBuffer() 메서드를 실행하도록 되어있습니다. 이후 설명할 DirectByteBuffer가 해당됩니다. HeapByteBuffer는 해당사항이 아니므로 넘어갑니다. 다만 먼저 보자면 HeapByteBuffer도 ④에서 결국readIntoNativeBuffer()를 실행합니다. 여기서 HeapByteBuffer도 kernel buffer에 직접 접근하는 것을 알 수 있습니다.
②번을 보면 directIO 여부에 따라 검증코드와 ByteBuffer bb;에 다른 값이 할당되는데, FileInputStream, FileOutputStream, RandomAccessFile의 getChannel()로 FileChannel을 생성하는 경우 false가 들어가게 됩니다. 따라서 false의 경우만 확인하겠습니다.
그래도 궁금하니까. directIO에 true가 들어가는 케이스는 UnixChannelFactory를 통해 생성하는 경우인데, Flags.direct에 따라 true/false가 정해지게 됩니다. 아마 이 부분은 unix/linux의 open() 시스템 콜의 O_DIRECT flag로 보입니다.
다시 돌아와서 directIO가 false이기떄문에 ③번이 실행되면서 kernel buffer에 접근하는 DirectBuffer 할당됩니다. ③번에 대해서는 아래서 더 자세히 확인해보겠습니다.
이후 ④번에서 할당된 kernel buffer에서 데이터를 read하고, dts.put(bb);에서 JVM heap buffer에 데이터를 복사합니다.
사실 여기까지만 확인해도 HeapByteBuffer의 동작원리는 어느정도 파악이 됐습니다.
여기서부터 ③번에서 할당된 TemporaryDirectBuffer에 대해 조금 더 확인해보려고 합니다. 이 부분을 넘어가고 싶으신 분들은 DirectByteBuffer로 넘어가주세요.
sun.nio.ch.Util#getTemporaryDirectBuffer

①번을 보면 너무 큰 버퍼 사이즈를 원하는 경우, 새로운 DirectByteBuffer를 생성하는 것을 알 수 있습니다. 이 때 버퍼 사이즈 최대값은 jdk.nio.maxCachedBufferSize 프로퍼티로 정해집니다.
Ability to limit the capacity of buffers that can be held in the temporary buffer cache
The system property
jdk.nio.maxCachedBufferSizehas been introduced in JDK 9 to limit the memory used by the "temporary buffer cache". The temporary buffer cache is a per-thread cache of direct memory used by the NIO implementation to support applications that do I/O with buffers backed by arrays in the Java heap. The value of the property is the maximum capacity of a direct buffer that can be cached. If the property is not set, then no limit is put on the size of buffers that are cached. Applications with certain patterns of I/O usage may benefit from using this property. In particular, an application may see a benefit to using this property if it does I/O with large multi-megabyte buffers at startup but thereafter does I/O with small buffers. Applications that do I/O using direct buffers will not see any benefit to using this system property.ref: https://www.oracle.com/java/technologies/javase/9-enhancements.html
버퍼 사이즈가 설정한 최대값보다 작아 ②번으로 넘어가게 되면 캐싱된 ByteBuffer가 있는지 확인합니다. 이때 원하는 버퍼 사이즈보다 큰 사이즈의 ByteBuffer를 찾게됩니다. 캐싱된 ByteBuffer가 있으면 그대로 return 하고 없으면 새로운 DirectByteBuffer를 생성합니다.
DirectByteBuffer는 HeapByteBuffer와 달리 JVM heap buffer를 사용하지 않는 방법입니다.
ByteBuffer.java의 final byte[] hb;가 할당되지 않습니다.ByteBuffer.allocateDirect(1024);
ByteBuffer.java

DirectByteBuffer의 생성자

base = UNSAFE.allcateMemory(size);를 통해 새로운 kernel buffer의 메모리 블록을 할당합니다. 이 후 UNSAFE.setMemory(base, size, (byte) 0)를 통해 할당된 메모리 블록의 값을 0으로 초기화합니다.
UNSAFE는 jdk.internal.misc.Unsafe 클래스로 low-level의 명령을 수행하는 클래스입니다. 각종 native 메서드들이 포함되어 있습니다.(새롭게 알게된 재밌는 사실은 Unsafe 클래스와 native 메서드들은 모두 public 이라고 합니다.)
read할 때 DirectBuffer를 할당하는 HeapByteBuffer와 다르게, DirectByteBuffer 선언 시 메모리 블록을 할당하는 걸 알 수 있습니다.
위에서 사용한 FileChannelImpl.java과, sun.nio.ch.IOUtil 클래스의 read 메서드를 다시 확인해보겠습니다.
FileChannelImpe.java


①번에서 바로 kernel buffer에 있는 데이터를 DirectBuffer로 접근하는 걸 알 수 있습니다.
https://homoefficio.github.io/2016/08/06/Java-NIO%EB%8A%94-%EC%83%9D%EA%B0%81%EB%A7%8C%ED%81%BC-non-blocking-%ED%95%98%EC%A7%80-%EC%95%8A%EB%8B%A4/
http://eincs.com/2009/08/java-nio-bytebuffer-channel-file/
http://eincs.com/2009/08/java-nio-bytebuffer-channel/
http://eincs.com/2009/08/java-nio-bytebuffer-performance/
https://palpit.tistory.com/entry/Java-NIO-%EA%B8%B0%EB%B0%98-%EC%9E%85%EC%B6%9C%EB%A0%A5-%EB%B0%8F-%EB%84%A4%ED%8A%B8%EC%9B%8C%ED%82%B9-%EB%B2%84%ED%8D%BCBuffer
https://blogs.oracle.com/javamagazine/post/java-nio-nio2-buffers-channels-async-future-callback
https://blogs.oracle.com/javamagazine/post/java-nio-nio2-buffers-channels-async-future-callback
https://docs.oracle.com/javase/tutorial/essential/io/file.html
IO와 NIO, NIO.2 뭐가 좋아졌을까?
-> 버퍼를 사용한다는데(IO에서도 버퍼 썼던거같은데?)
-> JVM heap buffer와 kernel buffer 등...
여러 생각들을 하다보니 OS, 물리메모리와 가상메모리, JVM 등 동작원리에 대해 생각해봐야했습니다. CS 개념에 대해 잘 알려주신 널널한 개발자님 감사합니다.
누군가 말씀하셨던 내용입니다.(누군진 기억이 안나네요.) 사실 잘못된 지식때문에 혼동이 왔던게 어려움의 주 원인입니다. OS의 동작으로는 여기까지 확실한데 이후 어떻게 되는거지? JVM이 어떻게 하는거지? 등 자료를 계속 찾아보려고 해도 안나오더라구요. 당연하게도 잘못된 지식이었으니까 자료가 없던 거였습니다.처음부터 잘못된 지식을 알려줘서 열받지만 탓할수 없는 gpt
gpt 뿐만 아니라 교차검증을 위해 여러 가지 사용했습니다.(gpt, copilot, perplexity, claude, gemini) 하지만 같은 질문에 서로 다른 답변을 하기도 하고, 말이 바뀌기도 하고.. 참.. 힘들었네요.
여러 잘못된 지식들이 혼동된 상태가 되고 나니까, 그냥 코드를 직접 보면서 생각을 정리하는게 낫겠다는 생각이 들었습니다.
https://toss.tech/article/reactor-netty-memory-leak
https://effectivesquid.tistory.com/entry/Reference-Count%EB%A5%BC-%ED%86%B5%ED%95%9C-Netty%EC%9D%98-ByteBuf-memory-%EA%B4%80%EB%A6%AC
긴글 읽어주셔서 감사합니다.
오 처음 안 사실이네요 재밌게 읽었습니다 ㅎㅎ 감사합니다.
큰 틀에서는 이해했는데 내용이 어려워서 ㅋㅋ 한 번 더 읽어봐야겠네요