Java IO 변천사(조금만 더 깊이 - ByteBuffer)

MyeongJae Lee·2024년 10월 6일
1

Java IO 변천사

목록 보기
1/1
post-thumbnail

Java 1.4 NIO에 추가된 ByteBuffer에 대한 글입니다.글이 너무 길어 주의를 요합니다.

I/O 가 일어나면?

I/O가 발생하면 운영체제는 기본적으로 kernel buffer로 데이터를 복사합니다. 이 과정은 DMA(Direct Memory Access)가 담당합니다.

Java NIO에는 kernel buffer에 담긴 데이터에 직접 접근하는 방법이 추가됐습니다.. 그 방법이 바로 제목의 ByteBuffer 입니다.

Java IO

기존의 Java IO는 kernel buffer에 직접 접근이 불가능하기 때문에 JVM heap buffer로 데이터 복사가 필요합니다.

JVM heap buffer로의 데이터 복사는 다음과 같은 단점을 가집니다.

  • kernel buffer에서 JVM heap buffer로의 CPU를 사용한 데이터 복사로 오버헤드가 발생합니다.
  • JVM heap 사용으로 인해 GC의 대상이 됩니다.

JVM heap buffer를 사용하는 대용량 I/O 요청이 많으면 GC가 잦아지고 성능저하로 이어질 수 있습니다.

Java NIO

위에서 설명한 단점을 개선하기 위해 Java NIO에는 ByteBuffer가 추가됐습니다.

ByteBuffer

ByteBuffer의 컨셉은 kernel buffer로의 직접 접근입니다.

ByteBuffer의 상속관계 다이어그램은 다음과 같습니다.

ByteBuffer의 구현체로는 DirectByteBuffer, HeapByteBuffer가 있습니다.(추상클래스인 MappedByteBuffer는 다음에..)

HeapByteBuffer

HeapByteBuffer는 이름을 보면 알 수 있듯 JVM heap buffer를 사용합니다.

HeapByteBuffer의 read 동작 순서를 확인해보겠습니다.

  1. HeapByteBuffer 생성
    이 단계에서는 JVM heap buffer를 생성합니다.

HeapByteBuffer의 생성 방법은 다음과 같습니다.

ByteBuffer.allocate(1024);

관련 코드를 조금만 더 추적해보겠습니다.

ByteBuffer.java

new HeapByteBuffer -> HeapByteBuffer.java

super -> ByteBuffer.java

여기서 보이는 hbByteBuffer.javafinal byte[] hb;필드입니다. ByteBuffer.allocate(int capacity)에서 capacity로 지정한 만큼 byte array를 생성하는 걸 확인할 수 있습니다.

  1. 생성 후 read할때 Direct Buffer를 생성하거나 Temporary Direct Buffer를 사용

추상클래스 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로 보입니다.

https://docs.redhat.com/en/documentation/red_hat_enterprise_linux/5/html/global_file_system/s1-manage-direct-io#s1-manage-direct-io

다시 돌아와서 directIO가 false이기떄문에 ③번이 실행되면서 kernel buffer에 접근하는 DirectBuffer 할당됩니다. ③번에 대해서는 아래서 더 자세히 확인해보겠습니다.

이후 ④번에서 할당된 kernel buffer에서 데이터를 read하고, dts.put(bb);에서 JVM heap buffer에 데이터를 복사합니다.

사실 여기까지만 확인해도 HeapByteBuffer의 동작원리는 어느정도 파악이 됐습니다.

  • HeapByteBuffer가 선언되며 capacity만큼의 JVM heap buffer 생성
  • read가 동작
    - kernel buffer에 접근하는 DirectBuffer 할당
    - DirectBuffer를 사용하여 kernel buffer로부터 JVM heap buffer로 데이터를 복사

여기서부터 ③번에서 할당된 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.maxCachedBufferSize has 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

DirectByteBuffer는 HeapByteBuffer와 달리 JVM heap buffer를 사용하지 않는 방법입니다.

  1. DirectByteBuffer 생성
    HeapByteBuffer와 다르게 JVM heap buffer를 할당하지 않습니다. 따라서 ByteBuffer.javafinal byte[] hb;가 할당되지 않습니다.
ByteBuffer.allocateDirect(1024);

ByteBuffer.java

DirectByteBuffer의 생성자

base = UNSAFE.allcateMemory(size);를 통해 새로운 kernel buffer의 메모리 블록을 할당합니다. 이 후 UNSAFE.setMemory(base, size, (byte) 0)를 통해 할당된 메모리 블록의 값을 0으로 초기화합니다.

UNSAFEjdk.internal.misc.Unsafe 클래스로 low-level의 명령을 수행하는 클래스입니다. 각종 native 메서드들이 포함되어 있습니다.(새롭게 알게된 재밌는 사실은 Unsafe 클래스와 native 메서드들은 모두 public 이라고 합니다.)

read할 때 DirectBuffer를 할당하는 HeapByteBuffer와 다르게, DirectByteBuffer 선언 시 메모리 블록을 할당하는 걸 알 수 있습니다.

  1. 이 후 read를 실행

위에서 사용한 FileChannelImpl.java과, sun.nio.ch.IOUtil 클래스의 read 메서드를 다시 확인해보겠습니다.

FileChannelImpe.java

①번에서 바로 kernel buffer에 있는 데이터를 DirectBuffer로 접근하는 걸 알 수 있습니다.

ByteBuffer 정리

  • ByteBuffer를 사용하면 kernel buffer에 직접 접근한다.
    - kernel buffer에 직접 접근하는 방식을 제공하는 인터페이스같은 개념이 DirectBuffer
  • ByteBuffer를 사용하는 방식은 크게 두가지다.(하위클래스)
    - HeapByteBuffer
    - DirectByteBuffer
  • HeapByteBuffer는 JVM heap buffer를 사용하고 kernel buffer에 대해 접근한다.
  • DirectByteBuffer는 kernel buffer에 대한 접근만 이뤄진다.

참고한 글

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

글을 마무리하며(공부하며 든 여러 생각.. TMI)

  1. 어쩌다 여기까지 조사하게 됐을까

IO와 NIO, NIO.2 뭐가 좋아졌을까?
-> 버퍼를 사용한다는데(IO에서도 버퍼 썼던거같은데?)
-> JVM heap buffer와 kernel buffer 등...

  1. 널널한 개발자님 감사합니다.

여러 생각들을 하다보니 OS, 물리메모리와 가상메모리, JVM 등 동작원리에 대해 생각해봐야했습니다. CS 개념에 대해 잘 알려주신 널널한 개발자님 감사합니다.

  1. 자바는 어찌보면 더 어려울 수 있어요. CS 뿐만 아니라 JVM 도 알아야되니까요.(텍스트보단 뉘앙스를 봐주세요)

누군가 말씀하셨던 내용입니다.(누군진 기억이 안나네요.) 사실 잘못된 지식때문에 혼동이 왔던게 어려움의 주 원인입니다. OS의 동작으로는 여기까지 확실한데 이후 어떻게 되는거지? JVM이 어떻게 하는거지? 등 자료를 계속 찾아보려고 해도 안나오더라구요. 당연하게도 잘못된 지식이었으니까 자료가 없던 거였습니다.처음부터 잘못된 지식을 알려줘서 열받지만 탓할수 없는 gpt

  1. gpt가 득만큼 실도 많았던 느낌.. 코드를 보는게 나을때도 있다.

gpt 뿐만 아니라 교차검증을 위해 여러 가지 사용했습니다.(gpt, copilot, perplexity, claude, gemini) 하지만 같은 질문에 서로 다른 답변을 하기도 하고, 말이 바뀌기도 하고.. 참.. 힘들었네요.

여러 잘못된 지식들이 혼동된 상태가 되고 나니까, 그냥 코드를 직접 보면서 생각을 정리하는게 낫겠다는 생각이 들었습니다.

  1. netty 대단해요
    DirectByteBuffer를 사용하는 건 조심해야합니다. 이를 잘 사용할 수 있도록 해주는 것이 netty framework라고 합니다.(DirectByteBuffer를 pool로 만들어 사용한다고 합니다.) 관심있으시면 다음 링크도 읽어보세요.

    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

긴글 읽어주셔서 감사합니다.

profile
개발자가 하고싶어요

2개의 댓글

comment-user-thumbnail
2024년 10월 7일

오 처음 안 사실이네요 재밌게 읽었습니다 ㅎㅎ 감사합니다.
큰 틀에서는 이해했는데 내용이 어려워서 ㅋㅋ 한 번 더 읽어봐야겠네요

1개의 답글