Reactive Streams 여정기(2부) - JVM 과I/O
1. JVM과 I/O
1.1. 개요
이번 편에서는 JVM에서 I/O 를 어떤 방식으로 처리하는지에 대해서 알아보고자 합니다.
- I/O Multiplexing
- JVM 환경에서의 I/O 기술
- Java I/O
- Java NIO
- Java AIO
- Reactor Pattern 과 Reactive Streams 그리고 Reactive Programming
- Spring과 I/O Multiplexing 구현 기술
- Netty와 Armeria 구현 기술
이전 편과 마찬가지로, Java I/O 그리고 NIO 더 나아가서 NIO2(AIO)에 대해서 학습은 했지만
정확히 어디서 어떻게 사용하고 왜 이렇게 발전을 했는지에 대해서 개념을 잡아가기 힘들다고 생각합니다.
필자도 2019년 처음 개발 공부를 접하면서 학습한 내용이지만 지금에서야 조금 이해했다고 생각이 듭니다.
우선, 이번 과정의 이해를 돕기 위해 등장 순서대로 ‘설명’ → ‘문제’ → ‘해결(다음 모델)’ 와 같이 설명하려 합니다.
NIO2(AIO)의 경우 자료가 많이 부족해서 단순히 개념 정도만 기술한 것을 양해 부탁드립니다.(추후 추가 예정)
2. Socket
JVM IO 모델을 살펴보기 전에, Socket에 대해서 간단히 짚어보고 가려고 합니다.
Linux에서는 모든 것이 File로 이루어져 있습니다.
Socket 도 파일(file), 정확히는 파일 디스크립터(file descriptor)로 생성해 관리합니다.
이는 곧, 저수준 파일 입출력 함수를 통해 Socket 통신(데이터 송수신)이 가능하다는 뜻입니다.
다르게 말하면, 네트워크 및 파일등과 같은 리소스 작업에는 File I/O 과 관련 되어있습니다.
이번 포스팅에서는, JVM에서 File을 어떻게 효율적으로 접근하고 관리하는지에 대해서 알아보고자 합니다.
2.1. Socket을 통한 Network I/O
Socket 은 네트워크에서 서버와 클라이언트의 통신을 위한 인터페이스입니다.
2개의 프로세스가 특정 포트를 통해 양뱡향 통신을 하는 추상화된 장치라고도 볼 수 있습니다.
유저 모드에 존재하는 프로세스가, 커널 모드로 생성한 Socket 을 통해 다른 프로세스와 통신합니다.
각 Socket 은 Local Address와 Port, Remote Address 와 Port를 통해 서로를 식별합니다.
정확히는 아래 항목들로 서로 다른 Connection으로 식별하는 형태입니다.
- Protocol(TCP)
- Remote IP
- Remote Port
- Local IP
- Local Port
local address | remote address |
192.168.0.10:20001 | 192.168.0.10:8080 |
192.168.0.10:20002 | 192.168.0.10:8080 |
192.168.0.10:20003 | 192.168.0.10:8080 |
192.168.0.10:20004 | 192.168.0.10:8080 |
Connection 이 되었다면 데이터를 읽고 쓰는 전송 과정을 시작한다는 의미이기도 합니다.
그런데 데이터가 들어올 때마다 읽고 쓰는 과정을 반복하면 성능에 문제는 없을까요?
지속적은 Kernel Mode ↔ User Mode 작업이 이루어질 것이므로 좋아 보이지는 않습니다.
기본적으로 Socket 은 Send Buffer와 Receive Buffer를 가지고 있습니다.
Buffer 를 이용하여 데이터를 모아두었다가 한 번에 처리하여 이 같은 문제를 해결하고 있습니다.
2.2. Socket 통신 흐름
Server Socket
- socket : 소켓 생성
- bind : IP와 Port 번호 설정
- listen : 클라이언트 접근 요청에 수신 대기열을 만들어 클라이언트 대기 숫자 지정
- accept : 클라이언트의 연결을 기다림
- read/write :읽기/쓰기 작업
- close : 통신 종료
Client Socket
- socket :소켓 생성
- connect : 서버의 IP 와 Port 번호에 시도, 서버는 socket descriptor를 반환
- read/write : 읽기 쓰기 작업, socket descriptor 를 통해 통신
- close : 통신 종료
3. Java I/O
Java I/O 는 JDK 1.0부터 도입된 리소스 관리 라이브러리입니다.
File과 같은 리소스를 기준으로 데이터를 읽고 쓸 수 있는 기능들을 제공해주고 있습니다.
Byte Array 단위로 데이터를 다루며 Stream이라는 단방향 통신 구현체를 제공하고 있습니다.
하지만, 초창기 기술이다 보니 모든 작업들이 Block 방식으로 동작하는 단점을 가지고 있습니다.
3.1. InputStream/OutputStream
Input/OutputStream 은 단방향 데이터 통신에서 사용되는 모듈입니다.
어떤 Source로부터 데이터를 읽을지에 따라 실제 구현한 구현체들이 제각 기입니다.
이들은 Closable을 구현하고 있기 때문에 Try-with-resource를 통해 관리하여 사용할 수도 있습니다.
앞서 말했듯이 Block 방식으로 동작하기 때문에 Thread 가 작업 처리 중 멈춘다는 단점이 있습니다.
3.1.1. SocketInputStream/SocketOutputStream
- socket.getInputStream을 통해서 간접적으로 접근 및 사용 가능합니다.
- Thread 가 Blocking 됩니다.
- socket.getOutputStream을 통해서 간접적으로 접근 및 사용 가능합니다.
- Thread 가 Blocking 됩니다.
SocketInputStream과 SocketOutputStream 은 다른 Stream 구현체들과 달리 Public 이 아닙니다.
socket.getInputStream 또는 socket.getOutputStream을 통해서만 간접 접근 및 사용 가능합니다.
그리고 가장 큰 문제가 하나 있는데, 바로 Socket 통신 과정에서 Blocking 이 발생한다는 것입니다.
- read()에 의해 데이터가 도착하기 전까지 Thread 가 blocking 될 수 있다.
- write()에 의해 데이터가 완전히 소켓에 쓰일 때까지 Thread 가 blocking 될 수 있다.
3.2. Reader/Writer
Reader/Writer의 경우 JDK 1.1에 도입된 I/O 리소스 관리 기술입니다.
기존 Java I/O 가 Byte Array 데이터들을 위한 Stream이었다면
Reader/Writer 는 Character 데이터들을 위한 Stream입니다.
Character 데이터들의 송수신에 사용되므로 문자열 인코딩과 같은 기능들도 지원해 줍니다.
3.3. Java I/O 의 한계
Java의 슬로건인 플랫폼 간의 이식성(Once Write, Run AnyWay!)을 지키면서
각 OS 별로 system call이나 kernel을 직접 이용하는 것은 기술적으로 매우 어려운 일입니다.
Java I/O 의 경우 이 같은 문제를 극복하지 못한 상황에서 출시되었기 때문에 여럿 문제들이 있습니다.
- Kernel Buffer 직접 접근이 불가능해서, Memory Copy 가 발생합니다.
- Blocking으로 동작합니다.
3.4.1. Kernel Buffer 직접 접근이 불가능합니다. (Memory Copy)
실제 Disk로부터 값을 읽어오면 Disk Controller DMA를 통해서 Kernel Buffer에 값을 복사합니다.
DMA의 경우 후술할 내용이지만, 간단히 언급하자면 리소스 접근 시 일정 부분 튜닝을 해주는 기능입니다.
하지만, JVM에서 동작하는 언어들의 경우 Kernel Buffer의 값을 또 내부 Memory Buffer로 옮겨야 합니다.
JVM 은 내부에 할당한 프로세스인 JVM Process Buffer를 통해야만 작업을 처리할 수 있기 때문입니다.
즉, 성능 튜닝을 위해 DMA를 사용한다 하더라도
Kernel Buffer 값을 JVM Process Buffer로 옮기는 작업으로 오버헤드가 증가합니다.
이 과정에서, Kernel Space와 User Space를 넘나들기 때문에 CPU 자원을 크게 소모합니다.
더불어, 복사해 온 데이터는 메모리에 있기에 GC의 대상이 되고 이 또한 CPU 자원을 소모시킵니다.
3.4.2. Blocking으로 동작합니다.
SocketInputStream.read() or SocketOutputStream.write()을 사용하면 Blocking 이 됩니다.
I/O 요청이 발생할 때마다, Thread를 새로 생성하는 방향으로 막을 수는 있겠지만
Thread 를 생성 및 관리하는 비용이 비싸지며, Context Switching 이 발생해 CPU 자원 소모가 큽니다.
4. Java NIO
JDK 1.3에 통일된 인터페이스로 각 시스템 별로 Native 언어를 이용한 기능을 구현하기 시작했습니다.
JDK 1.4에서는 이러한 인터페이스의 완성으로 탄생된 Java NIO(Java New IO)가 적용되었습니다.
Java NIO는 10k 문제를 해결하기 위한 I/O Multiplexing 기술을 도입한 라이브러리입니다.
Java NIO 는 Java I/O 와 마찬가지로 File과 같은 리소스를 읽고 쓸 수 있는 기능을 제공합니다.
Channel, Buffer, Selector를 기반으로 동작하고 있으며, Non-blocking 도 지원합니다.(일부 Blocking)
Java NIO는 Java I/O 를 기준으로 비교적 높은 성능을 보장하고 있습니다.
JavaNIO | Java IO | |
데이터의 흐름 | 양방향 | 단방향 |
종류 | Channel | InputStream, OutputStream |
데이터의 단위 | Buffer | byte 혹은 character |
blocking 의 여부 | non-blocking 지원 | blocking 만 가능 |
특이사항 | Selector 지원 |
4.1. Java NIO의 개선점
4.1.1. DirectBuffer를 통해 커널 버퍼를 직접 핸들링
Java NIO는 Java I/O 의 Memory Copy 문제를 해결하기 위해
Kernel Buffer에 직접 접근할 수 있는 DirectBuffer 클래스를 제공합니다.
DirectBuffer 클래스는 내부적으로
Kernel Buffer를 직접 참조하고 있기 때문에 운영체제가 제공해 주는 효율적인 I/O 핸들링 서비스를 이용할 수 있게 해 줍니다.
즉, DirectBuffer를 통해, CPU 자원의 비효율성, I/O 요청 Thread 가 Blocking 되는 문제를 해결했습니다.
ByteBuffer.allocateDirect(N); 을 사용하면 Kernel Buffer 를 직접 이용 가능해집니다.
put(), get(), position(), flip(), clear() 등의 메서드로 커널 버퍼를 핸들링할 수 있습니다.
4.1.2. system call 간접 사용 및 개선
Java NIO는 Scatter/Gather 패턴을 Buffer, Channel, Selector를 통해 구현했습니다.
또한, 내부적으로 SelectorProvider를 구현하는데 OS system call을 간접 사용할 수 있게 해줍니다.
정리하자면, 불필요한 system call 수를 줄이고 효율적인 OS system call 을 사용하도록 해줍니다.
이 같은 이유들로, Java NIO에서는 요청 시마다 Thread 생성 및 GC 유발 문제를 완화시켰습니다.
Thread를 계속 생성하지 않고도 많은 수의 클라이언트를 처리할 수 있게 되었다고 볼 수 있습니다.
4.2. JVM과 I/O 향상을 위한 운영체제 기술들
4.2.1. DMA(Direct Memory Access)
Disk Controller는 DMA를 통해 CPU를 건드리지 않고 직접 운영체제 메모리에 접근할 수 있고,
응용 프로그램은 Direct Buffer를 활용해서 JVM 메모리가 아닌 운영체제 메모리에 직접 접근할 수 있습니다.
장점은 아래와 같습니다.
- 디스크에 있는 파일을 운영체제 메모리로 읽어 들일 때 CPU를 건드리지는 않는다.
- 운영체제 메모리에 있는 파일 내용을 JVM 내 메모리로 다시 복사할 필요가 없다.
- JVM Heap 메모리를 사용하지 않으므로, GC를 유발하지 않는다.
단점은 아래와 같습니다.
- DMA에 사용할 버퍼 시간이 더 많이 소요될 수 있습니다.
- 바이트 단위로 데이터를 취급하므로, 데이터를 행 단위로 취급하기 불편합니다.
기존 Java I/O 의 경우 Kernel Buffer에 직접 접근이 불가능했습니다.
DMA를 통해 Kernel Buffer의 값을 복사해도 JVM Process Buffer로 값을 복사하는 과정이 필요했습니다.
Java NIO는 DirectBuffer를 지원하기에 DMA의 장점을 살릴 수 있게 되었습니다.
4.2.2. Scatter/Gather
OS의 Native I/O 기능인 Scatter/Gather 는 I/O system call을 할 때
여러 개의 Memory Buffer를 지정하고 사용할 수 있는 방식입니다.
기존에는 system call 요청 한 번에, Memory Buffer와 하나의 Thread를 점유해야 했습니다.
Scatter/Gather를 사용하면 여러 Memory Buffer를 사용하더라도 하나의 Thread 만 점유할 수 있습니다.
마찬가지로, 여러 Memory Buffer 를 한 번에 사용하는 구조이니 자연스레 I/O system call 수도 줄어듭니다.
Java NIO의 Channel 은 Scatter/Gather를 사용할 수 있도록 인터페이스를 제공하고 있습니다.
JVM에서는 Kernel Buffer와 별도로 JVM Process Buffer 가 존재합니다.
JVM Process Buffer 별로, Kernel Buffer에 대한 읽기/쓰기 작업이 발생하면 Thread는 물론,
system call을 너무 빈번히 호출하는 형태가 되기 때문에 좋지 않습니다.
Scatter/Gather Pattern을 이용하면,
한 번의 system call을 사용해도 여러 JVM Process Buffer에 분산하여 처리를 할 수 있습니다.
이와 관련해서 지인분이 작성하신 블로그와 실제 코드들이 예시로 있는데 함께 확인하면 좋을 것 같습니다.
4.2.3. Memory Mapped File과 Virtual Memory
Memory Mapped File는 이름 그대로 Memory에 File을 매핑하겠다는 전략입니다.
파일 데이터 일부와 프로세스의 가상 메모리 일부를 매핑하여
유저 모드로 동작하는 프로세스에서 쓰기 작업이 일어나더라도
커널 모드로 전환되지 않은 상태에서 자연스레 파일에 데이터가 쓰이는 기법입니다.
이러한 Memory Mapped File 은 성능적인 이점 + 프로그래밍에 대한 편의성을 제공해 줍니다.
💡 파일에는 여러 데이터들이 적재되어 있고, 적재된 데이터에 대한 정렬이 필요한 상황이라 가정하겠습니다.
MMF 를 사용하지 않는다면 파일에 있는 데이터를 모두 메인 메모리로 불러 들인 후 정렬을 진행하고
이후 다시 파일 메모리에 데이터를 쓰는 작업을 진행해야합니다.
이 과정에서 당연히 SystemCall 과 User/Kernel Mode 변환은 필수 입니다.
MMF는 파일 입출력을 빈번히 수행하지 않고 Memory 기준으로 데이터를 읽고 쓰기 때문에
Disk로부터의 system call 이 자주 발생하지 않아 File I/O의 성능 향상을 가져올 수 있습니다.
참고로, 메모리에 적재된 데이터는 일정 수준 데이터가 쓰여야 실제 물리적인 파일에 쓰는 형태로 동작합니다.
- Disk 로이 read() , write()와 같은 system call을 직접 호출할 필요가 없어집니다.
- 기존의 메모리 복사 문제에서, 큰 파일 또한 메모리에 올려야 하는 비효율이 없어집니다.
- 2개 이상의 버퍼를 사용해도 하나의 Virtual Memory를 통해 하나의 작업처럼 처리할 수 있습니다.
- 결과적으로 Kernel 영역 → User 영역으로의 데이터 복사가 일어나지 않는 장점이 있습니다.
JVM 관점에서도 아래와 같은 장점을 얻는다고 블로그에서 언급하고 있습니다.
Java Examples | Files | Memory Mapped File
Memory Mapped File Loading large files into jvm may take up a lot of time and may also fail becuase the jvm memory may become full. It is now possible to directly map the huge files without loading them into memory. A buffer is created around the file in t
www.javacodex.com
💡 대용량 파일을 JVM 에 읽는데 많은 시간이 소요될 수 있습니다.
대용량이기 때문에 JVM 메모리가 가득차서 실패할 수 있습니다.
MMF를 사용하면 대용량 파일을 메모리에 로드하지 않고 직접 매핑할 수 있습니다.
File을 JVM에 로드하지 않고도 File System에서 주위에 버퍼가 생성됩니다.
이렇게 매핑된 버퍼를 사용하면, 파일을 직접 읽거나 쓸 수 있습니다.
이 기능을 통해 이제 Java에서 대용량 파일을 처리할 수 있습니다.
Java NIO에서는 java.nio.MappedByteBuffer 클래스가 Memory Mapped File IO과 관련되어 사용하는 버퍼입니다.
4.2.4. FIle Lock
운영체제에서도 DB에서의 X-Lock, S-Lock과 같은 개념이 존재하는데 바로 FIle Lock입니다.
특정 파일을 작업을 하고 있다면, 다른 프로세스 혹은 Thread 가 해당 파일에 대한 작업을 못하도록 막습니다.
JDK 1.4부터 NIO 패키지에서는 이러한 File Lock 기능을 제공하기 시작했습니다.
(이 부분도 운영체제의 기능 중 하나였기 때문에 구현이 어려웠습니다.)
Introduction to File Locking in Linux | Baeldung on Linux
4.3. Buffer 클래스
Java I/O 는 기술력 이슈로 운영체제가 제공하는 기능을 활용하지 못했습니다.
JDK 1.3에서 Native OS 기능이 구현되었으며
JDK 1.4부터는 Java NIO 가 도입 되면서 문제들을 해소하기 시작했습니다.
Java NIO 에는 많은 변화가 있었지만 그중에서 가장 먼저 확인해 볼 것이 Buffer입니다.
Buffer는 Stream과 달리 양방향 통신 수단이며
버퍼 처리는 물론, 종류에 따라 Memory 또는 Kernel에 직접 접근을 지원하고 있습니다.
4.3.1. Buffer 란 무엇인가?
Java NIO Buffer는 뒤에서 후술 할 Java NIO Channel과 상호 작용하는 용도로 사용되고 있습니다.
데이터를 기준으로 Channel에서 Buffer로 읽기 작업을, Buffer에서 Channel로 쓰기 작업을 수행합니다.
버퍼를 사용해 데이터를 읽고 쓰는 것은 4단계로 진행됩니다.
- 버퍼에 데이터 쓰기
- 버퍼의 flip() 메서드 호출
- 버퍼에서 데이터 읽기
- 버퍼의 clear() 혹은 compact() 메서드 호출
Buffer와 함께 사용되는 Channel 은 양방향 통신 수단이기에 Buffer를 읽기/쓰기 모드를 전환하기 위해서 flip()을 호출해야 합니다.
반대로, 또한 모든 데이터를 읽은 후에는 버퍼를 지우고 다시 쓸 준비를 해야 하며, 이때 clear()를 호출해서 전체 버퍼를 지워야 합니다.
4.3.2. Buffer 작동 방식
Buffer의 작동 방식을 이해하기 위해 친숙해야 하는 속성이 있습니다.
참고로, 위 그림을 보면 알 수 있듯이 Buffer는 메모리 블록 자료 구조입니다.
Buffer 관련 속성(필드)
capacity | • Buffer 가 저장할 수 있는 데이터의 최대 크기를 의미합니다. • Buffer 생성시에 결정되며 변경 불가능합니다. • Buffer 가 가득 차면 데이터를 쓰기 전에 Buffer 를 비워야 합니다. |
postiion | • Buffer 에서 현재 위치를 가리키고 있습니다. • Buffer 에서 데이터를 읽거나 쓸 때, 해당 위치에서 시작합니다. • Buffer 에서 1Byte 가 추가될 때마다 1 증가합니다. • 쓰기 모드에서 읽기 모드로 전환되면 다시 0으로 재 설정됩니다. |
limit | ◦ Buffer 에서 데이터를 읽거나 쓸 수 있는 마지막 위치입니다. ◦ 쓰기 모드에서의 limit 은 버퍼의 capacity 와 같습니다. ◦ 읽기 모드에서의 limit 은 읽을 수 있는 데이터의 한계를 의미합니다. ◦ 최초 생성시에는 capacity 와 값이 동일합니다. |
mark | • 현재 position 위치를 mark 로 지정할 수 있습니다. • rest() 호출시 postion mark 로 이동합니다. |
Buffer 위치 메서드
flip | • Buffer 의 limit 위치를 현재 postion 으로 이동시키고 postion 을 0으로 리셋합니다. • Buffer 를 쓰기 모드에서 읽기 모드로 전환하는 경우 사용합니다. |
rewind | • Buffer 의 position 위치를 0으로 리셋한다. limit 은 유지합니다. • 데이터를 처음부터 다시 읽는 경우에 사용합니다. |
clear | • Buffer 와 limit 위치를 capacity 위치로 이동시키고, position 을 0으로 리셋합니다. • Buffer 를 초기화할 때 사용합니다. |
Java NIO Buffer
This tutorial explains how Java NIO Buffers work, meaning how you read from them and how you write to them.
jenkov.com
4.3.3. Buffer 종류
Buffer 의 구현체인 ByteBuffer는 생성되는 위치를 기준으로 크게 2가지로 나눌 수 있습니다.
heapByteBuffer | • JVM Heap 내에 생성 ◦ JVM Heap 메모리에 저장합니다. ◦ byte array 를 래핑한 방식입니다. ◦ 커널 메모리에서 복사가 일어나므로 데이터를 읽고 쓰는 속도가 느립니다. (이 과정에서 임시로 Direct Buffer 를 만들기 때문에 성능 저하가 있습니다.) ◦ GC에서 관리가 되므로 allocate, deallocate 가 빠르다. |
DirectByteBuffer : | • JVM Heap 밖에 있는 Native 공간에 생성 ◦ native 메서드(off-heap) 에 저장합니다. ◦ 커널 메모리에서 복사를 하지 않으므로 데이터를 읽고 쓰는 속도가 빠릅니다. ◦ 비용이 많이 드는 system call 을 사용하므로 allocate, dellocate 가 느립니다. |
MappedByteBuffer도 존재하는데 Native 공간에 생성되며
파일 일부를 메모리에 매핑한다는 점외에는 일반적인 DirectByteBuffer와 동작이 다르지 않다고 언급하고 있습니다.
MappedByteBuffer (Java Platform SE 8 )
Loads this buffer's content into physical memory. This method makes a best effort to ensure that, when it returns, this buffer's content is resident in physical memory. Invoking this method may cause some number of page faults and I/O operations to occur.
docs.oracle.com
4.3.4. HeapByteBuffer
HeapByteBuffer는 이름 그대로 Byte를 Heap에서 관리하는 Buffer로
JVM의 GC에 의해 제어되어, 메모리 관리에 대해 안전하게 의지할 수 있지만 DMA를 활용할 수 없습니다.
- 데이터를 JVM Heap 메모리에 저장합니다.
- Byte Array를 래핑 하는 방식으로 동작합니다.
- 커널 메모리에서 복사가 일어나므로 데이터를 읽고 쓰는 속도가 느립니다.
- 복사가 일어나는 과정에서 임시로 DirectByteBuffer를 만들기 때문에 성능 저하가 있습니다.
- GC에서 관리가 되므로 allocate, deallocate 가 빠릅니다.
임시로 DirectByteBuffer 를 만들기 때문에 성능 저하가 있습니다.라고 언급하고 있는데
결론부터 말하자면, HeapByteBuffer를 사용하더라도 결국 Native 메모리를 사용하게 되는 것입니다.
사실 더 정확히 말하면 JDK 구현체마다 다르다고 이야기할 수 있습니다.
Oracle 사에서 제공하는 sun.nio.ch 패키지의 IOUtil 클래스는 write()를 제공하고 있습니다.
- ByteBuffer의 타입이 DirectBuffe r면 writeFromNativeBuffer()를 호출합니다.
- DirectBuffer 타입이 아니더라도 Util.getTemporaryDirectBuffer(rem)를 호출합니다.
- Util.getTemporaryDirectBuffer(rem)는 DirectByteBuffer를 생성하고 있습니다.
- 실제 read/write 할 데이터 크기만큼의 DirectByteBuffer를 생성하고 있는 것입니다.
결론적으로, 개발자가 작성한 프로그램 코드에서 HeapByteBuffer를 사용하더라도
내부적으로는 그 HeapByteBuffer가 사용되지 않고 항상 DirectByteBuffer 를 사용하고 있습니다.
4.3.5. DirectByteBuffer
DirectByteBuffer를 사용하면 DMA의 혜택을 얻을 수 있습니다.
하지만, JVM의 GC를 벗어나게 되므로 메모리 관리 부담이 생겨나기도 합니다.
- native 메서드(off-heap)에 저장합니다.
- 커널 메모리에서 복사를 하지 않으므로 데이터를 읽고 쓰는 속도가 빠릅니다.
- 비용이 많이 드는 system call을 사용하므로 allocate, dellocate 가 느립니다.
DirectByteBuffer의 메모리를 JVM에서 해제하는 방법은 아예 없는 것일까요?
뒤태지존님의 글에 따르면, 아래와 같이 언급된다고 말하고 있습니다.
💡 Native 메모리를 참조하는 객체는 결국 JVM Heap 안에 생성되며, JVM GC에 의해 회수되면
이 객체가 참조하는 Native 메모리는 JVM이 아닌 다른 메커니즘에 의해 어쨌든 회수된다.
DirectByteBuffer를 사용해도 간접적이지만 결국에는 JVM GC에 의해 회수가 시작된다는 것입니다.
물론, 해당 글에서도 메모리가 회수되지 않아 아래와 같이 에러가 발생한다고 언급하고 있습니다.
# JVM GC 에 의해 회수되지 않아 OOM 이 발생할 수 있습니다.
java.lang.OutOfMemoryError: Direct buffer memory
이 경우도 Oracle 사에서 제공하는 sun.nio.ch 패키지에 의해 동작이 이루어지는데
회수되지 않는 경우에는 ((DirectBuffer) directBuffer). cleaner(). clean()를 이용하면 됩니다.
JVM Heap 밖에 있어서 GC 가 아닌 다른 메커니즘과 다르게 Java 코드로 명시적으로 바로 회수할 수 있습니다.
jcmd로 확인해 보면 Internal 영역이 명시적으로 사용한 DirectByteBuffer 크기만큼 바로 줄어듭니다.
해당 글에는 Buffer Cache 도 언급하고 있지만, JDK 구현체마다 다르므로 언급하고 가지는 않겠습니다.
4.4. Channel 클래스
Java NIO의 Channel 은 Stream 과 유사하지만 양방향으로 데이터 전달이 가능하며 비동기도 지원합니다.
Java NIO 의 Channel 은 Buffer로부터 데이터를 읽어 들이거나 Buffer에 데이터를 쓰는 작업을 합니다.
- 양방향으로 데이터가 흐를 수 있습니다.
- Java IO와 다르게 Non-blocking 방식을 지원합니다.(선택적)
- 각 리소스별 구현체를 만들어서 읽고 쓰는 게 가능합니다.
4.4.1. Channel 리소스 별 구현체
FileChannel | 파일에 데이터를 읽고 쓸 수 있습니다. |
DatagramChannel | UDP를 이용해 네트워크에서 데이터를 읽고 쓸 수 있습니다. |
SocketChannel | TCP를 이용해 네트워크에서 데이터를 읽고 쓸 수 있습니다. |
ServerSocketChannel | 클라이언트의 TCP 연결 요청을 수신(listening)할 수 있으며, SocketChannel은 각 연결마다 생성됩니다. |
4.5. Selector 클래스
Java NIO의 Selector는 하나의 스레드로 여러 채널을 동시에 다룰 수 있는 컴포넌트입니다.
등록된 채널 중 이벤트 준비가 완료된 하나 이상의 채널이 생길 때까지 Block 됩니다.
메서드가 반환되면, 스레드는 채널에 준비 완료된 이벤트를 처리할 수 있는 상태가 됩니다.
즉, 하나의 스레드에서 여러 채널을 관리할 수 있으므로 여러 소켓 연결을 관리할 수 있습니다.
Selector 객체에는 여러 Channel을 등록할 수 있습니다.
만일, Channel 중 하나에서 I/O 활동이 발생하면 Selector 가 이를 알려줍니다.
이 같은 방식을 잘 활용한다면 단일 스레드에서도 많은 수의 데이터 소스를 읽을 수 있습니다.
참고로 Selector에 등록하는 모든 Channel 은 SelectableChannel의 서브 클래스여야 합니다.
이는 Non-Blocking 모드로 설정할 수 있는 특수한 유형의 Channel입니다.
어디서 많이 본 방식이라 생각하실 수 있는데, 맞습니다.
I/O Multiplexing처럼, I/O 작업이 완료되면 이를 Single Thread에서 콜백 처리를 하는 형태입니다.
참고로 Non-blocking 모드를 지원하려면, configureBlocking(false); 설정을 해주어야 합니다.
channel.register 은 첫 번째 인자로 Channel을, 두 번째 인자로 이벤트를 받습니다.
Event 종류 | 설명 |
Connect | 클라이언트가 서버에 연결을 시도할 때 발생합니다 |
Accept | 서버가 클라이언트의 연결을 수락할 때 발생합니다. |
Read | 서버가 채널에서 데이터를 읽을 준비가 된 것입니다. |
Write | 서버가 채널에 데이터를 쓸 준비가 된 것입니다 |
4.5.1. Selector Key
Selector에 채널을 등록하면 SelectionKey 객체를 얻습니다.
SelectionKey는 Channel에서 Selector를 사용하도록 하는 몇 가지 중요한 속성들이 있습니다.
Channel 에서 Selector 를 사용하려면 잘 이해해야 하는 몇 가지 중요한 속성이 포함되어 있습니다.
4.5.2. The Interest Set
Selector에 등록된 채널이 확인하고자 하는 이벤트 집합(세트)입니다.
정수 값이며, SelectionKey를 이용해 Interest set을 확인할 수 있습니다.
4.5.3. The Ready Set
Selector 에 등록된 채널에서 바로 처리할 수 있도록 준비된 이벤트의 집합입니다.
정수 값이며, 아래 4가지 메서드를 이용해서 이벤트를 확인할 수 있습니다.
4.5.4. Attached object
SelectionKey에 객체를 첨부할 수도 있습니다.
Channel에 추가 정보 혹은 Channel에서 사용하는 Buffer와 같은 객체들을 첨부할 수 있습니다.
SelectionKey를 통해 직접 첨부할 수도 있고, Selector에 Channel을 등록하면서 객체를 첨부할 수도 있습니다.
4.6. Non-blocking 하게 사용하기 위한 노력
4.6.1. Java NIO는 생각만큼 non-blocking 하지 않다.
- Files.newXXX()는 모두 blocking입니다.
java.nio.Files는 NIO(NewIO) 중에서 File I/O를 담당하고 있습니다.
이들의 내부 구현체들은 ReadableByteChannel과 WritableByteChannel를 구현하고 있습니다.
하지만, 이 구현체들은 모두 Blocking 모드로만 동작하는 Blocking Channel입니다.
결국, NIO 중에서 File과 관련된 컴포넌트들은 아쉽지만 모두 Blocking으로 동작하는 것입니다.
- Files.lines()
- Files.readAllLines()
- Files.readAllBytes()
- Files.write()
4.6.2. NIO File 은 Blocking인데 왜 사용할까?
File과 관련된 Channel 이 Blocking 하게 동작하지만,
데이터를 Buffer를 이용해서 전달하기에 Stream에서 병목을 유발하는 몇 가지 레이어를 건너뛸 수 있습니다.
더 구체적으로는 Buffer를 사용하면 DMA를 활용할 수 있다는 건데, 이로 인해 성능 이점이 있습니다.
4.6.3. Non-Blocking을 지원해 주는 SelectableChannel
- socketChannel, ServerSocketChannel 모두 AbstractSelectableChannel을 상속합니다.
- AbstractSelectableChannel 은 SelectableChannel 을 상속합니다.
- SelectableChannel은 configureBlocking과 register 함수를 제공합니다.
- configureBlocking(false)
- serverSocketChannel의 accept, socketChannel의 connect 등이 non-blocking으로 동작합니다.
- File Channel의 경우에는 SelectableChannel을 구현하고 있지 않습니다.
- 모든 연산들이 Blocking 하게 동작한다는 것을 알 수 있습니다.
- 즉, 모든 NIO 가 non-blocking 하지 않다는 것입니다.
5. Java AIO(NIO2)
5.1. Java AIO의 특징
- Java 1.7부터 NIO2를 지원한다.
- AsynchronousChannel을 지원한다.
- AsynchronousSocketChannel을 지원한다.
- AsynchronousServerChannel을 지원한다.
- AsynchronousFileChannel을 지원한다.
- Callback과 Future를 지원한다.
5.2. Java AIO 구현
- 내부적으로 Thread Pool과 epoll, kqueue 등의 이벤트 알림 system call을 이용하여 I/O를 비동기적으로 처리합니다.
- read() / write()와 같은 IO 요청이 들어오면, 비동기 채널은 바로 응답값을 반환합니다.
- Future 혹은 Void를 반환할 수 있습니다.
- 바로 반환하는 목적은 Caller에게 제어권을 반환하기 위한 목적입니다.
- IO 요청에 대해서 Thread Pool의 Task Queue에 쌓이게 되고 각각의 Thread들이받아서 작업을 처리하는 형태입니다.
- IO 준비가 완료되었다면, call back을 실행하면서 I/O가 준비되었음을 알립니다.
5.3. Callback 지원
- AsynchronousFIleChannel을 Open 하고, ByteBuffer 도 할당했습니다.
- Java AIO에서는 Attachment와 CompletionHandler를 넘길 수 있습니다.
- Attachment는 CompletionHandler에서 사용할 수 있는 값입니다.
- CompletionHandler을 통해서 CallBack 작업을 처리할 수 있습니다.
5.4. Future 지원
- read를 하는데 반환값을 Future로 받고 있습니다.
- 코드는 Callback 보다 깔끔해졌지만 동기적으로 처리하는 것을 볼 수 있습니다.
참고
- https://fastcampus.co.kr/dev_online_webflux
- https://notes.shichao.io/unp/ch6/
- https://plummmm.tistory.com/68
- https://blog.naver.com/n_cloudplatform/222189669084
- https://www.youtube.com/watch?v=mb-QHxVfmcs&ab_channel=쉬운 코드
- https://engineering.linecorp.com/ko/blog/do-not-block-the-event-loop-part1
- https://engineering.linecorp.com/ko/blog/do-not-block-the-event-loop-part2
- https://engineering.linecorp.com/ko/blog/do-not-block-the-event-loop-part3
- https://www.getoutsidedoor.com/2021/10/03/eventloop-설계와-구현-el-project
- https://www.baeldung.com/spring-webflux-concurrency
- https://homoefficio.github.io/2020/08/10/Java-NIO-FileChannel-과-DirectByteBuffer/
- https://homoefficio.github.io/2016/08/06/Java-NIO는-생각만큼-non-blocking-하지-않다/
- https://homoefficio.github.io/2016/08/13/대용량-파일을-AsynchronousFileChannel로-다뤄보기/
- http://eincs.com/2009/08/java-nio-bytebuffer-channel-file/
- https://brewagebear.github.io/fundamental-nio-and-io-models/
- https://brewagebear.github.io/java-syscall-and-io/