글의 목적
JAVA I/O 와 Java NIO는 목적 자체가 다르고, 상황에 맞게 써야 한다는데, “그럼 대체 뭐가 달라서, 어떤 기준으로 사용해야 하는가?”에 대한 탐구를 수행한 과정을 기록한다.
Java NIO 버퍼 입출력 메소드 - flip, clear, compact
기본 개념 - ByteBuffer 동작 방식
쓰기 모드 (Writing mode)
데이터를 버퍼에 쓸 때 사용한다.
- position: 현재 쓸 위치
- limit: 쓰기 가능한 최대 범위(일반적으로 버퍼의 capacity)
로 설정된다.
읽기 모드 (Reading mode)
버퍼에 쓰여진 데이터를 읽어올 때 사용한다.
- position: 현재 읽을 위치
- limit: 읽기 가능한 최대 범위(일반적으로 write 모드때의 마지막 position)
로 설정된다.
쓰기→읽기 전환
flip()
- 기능
- 쓰기 → 읽기 전환 시 사용한다.
- 동작방식
- limit(최대 읽기 가능한 범위)를 현재 position으로 설정한다.
- 지금까지 쓴 위치를 최대로 하여, 이 이상 읽지 못하도록
- position을 0으로 설정한다.
- 쓴 데이터를 처음부터 읽을 수 있도록
- limit(최대 읽기 가능한 범위)를 현재 position으로 설정한다.
읽기→쓰기 전환
clear()
- 기능
- 버퍼를 완전히 초기화한다.
- 전체 용량을 다시 쓸 수 있도록 한다.
- 동작방식
- position을 0으로 설정한다.
- 버퍼의 맨 앞에서부터 쓸 수 있도록
- limit을 버퍼의 전체 용량(capacity)로 리셋한다.
- 버퍼의 최대 크기까지 쓸 수 있도록
- position을 0으로 설정한다.
compact()
- 기능
- 아직 읽지 않은 데이터를 유지하며 버퍼를 정리한다.
- 남은 공간을 쓰기 위해 뒤에 있는 아직 안읽은 데이터를 앞으로 당겨온다.
- 읽지 않은 남은 데이터의 끝부터, 다시 쓰기를 시작할 수 있도록 한다.
- 동작방식
- position부터 limit까지의 데이터를 버퍼의 시작 부분으로 복사한다.
- 안읽은 데이터가 버퍼에 계속 남아있도록
- position을 복사된 데이터의 길이로 설정한다.
- 남은 데이터의 끝부터 다시 쓸 수 있도록
- limit을 버퍼의 전체 용량(capacity)로 리셋한다.
- 버퍼의 최대 크기까지 쓸 수 있도록
- position부터 limit까지의 데이터를 버퍼의 시작 부분으로 복사한다.
최초 의문
- JAVA nio의 채널이 하나의 버퍼를 가지고 flip(), compact()/clear()을 통해 읽기/쓰기 전환을 하며 사용하는 이유가 뭐지?
- Java I/O 처럼, input Buffer Output Buffer 따로 두는게 사용하기도 쉽고 명시적인데, 왜 굳이 하나의 버퍼를 이용해, flip(), compact()/clear() 을 이용해 통신을 하게 만들었을까?
- 어렵고 불편하기만 한데, 왜 이런 결정을 한거지?
1) 버퍼를 하나로 두고 관리하는게, 더 적게 메모리를 관리할 수 있는 것 아닐까?
- 소켓 인터페이스는 inputStream, outputStream 두개를 사용하는데, BufferedInputStream 같은 개념을 사용하면 Java NIO의 버퍼를 쓸 이유가 없다.
- 그렇다면, 버퍼가 하나임으로 얻는 장점이 있기에, 해당 방식을 선택한 것 아닐까?
1.1) 버퍼의 갯수를 줄이는게 성능 효율이 좋다면, TCP도 입력버퍼/출력버퍼 따로 두고 쓰는데, OS TCP 버퍼를 직접 사용한다면 성능이 더 좋지 않을까?
- 소켓 인터페이스가 관리하는 메모리 관리를, 개발자가 직접 해야 한다.
- 네트워크 스택에 직접 접근
- 파일 I/O와 네트워크 I/O의 접근 방식이 달라진다.
- 이런 I/O 관리를 추상화하기 위해서 "소켓 인터페이스"가 등장한 것을 잊지 말자.
- 한마디로 이 논리는, 완벽하지 않다!
1.2) TCP 통신
- 기본적으로 full-duplex 방식이다.
- OS 레벨에 입력 버퍼와 출력 버퍼가 따로 존재한다.
- 입력 버퍼
- 네트워크를 통해 도착한 데이터는 먼저 운영체제의 커널에 있는 수신 버퍼에 저장된다.
- 애플리케이션은 이 버퍼에서 데이터를 읽어 들인다.
- 이 방식은 네트워크 속도나 지연 등으로 인해 데이터가 도착하는 속도가 일정하지 않아도
- 애플리케이션이 필요할 때 데이터를 가져올 수 있도록 해준다.
- 출력 버퍼
- 애플리케이션이 소켓에 데이터를 쓰면, 이 데이터는 출력 버퍼에 저장된 후 실제 네트워크를 통해 전송된다.
- TCP의 경우, 이 버퍼는 전송한 데이터가 상대측으로 확인(ACK)되기 전까지 유지된다.
- 재전송이나 흐름 제어, 혼잡 제어 등의 역할을 수행한다.
2) 통신 방식 분류 - NIO 너는 대체 뭔데?
Java I/O
- 자바 파일 I/O
- 자바 소켓 I/O
- 소켓 통신에 대해서는, 해당 글을 참고해주세요.
Java NIO
- 채널 I/O
- 기존의 자바 소켓 인터페이스와 차별화된, 새로운 패러다임이다.
- 네이티브 소켓 인터페이스와 OS 레벨의 멀티플렉싱 시스템 콜을 사용하여, 새로운 방식의 I/O를 도입했다.
여러개의 소켓을 "멀티플렉싱 시스템 콜(epoll 계열, 이벤트 기반)"을 사용해 동시에 관리하면서, 새로운 형태의 I/O를 도입한 것이다.
- Direct ByteBuffer 개념을 도입한 새로운 파일 I/O 또한 도입했다.
2.1) Direct ByteBuffer 란?
- AllocateDirect()
- 네이티브 메모리에 메모리 할당을 요구하여, 힙 메모리가 아닌 외부에 새로운 공간을 할당받는 메소드이다.
- OS에 다시 직접 요청해야 하기 때문에, 할당에 비용이 많이 소모된다.
- 따라서, 한번 할당받고 재사용하는 것이 효율이 좋다.
- 힙 영역을 벗어났기 때문에, GC가 관리해주지 않고, 따라서 관리에 신경써야 한다.
- Direct ByteBuffer의 강점은?
- Zero-Copy
- 네이티브 메모리(커널 메모리) 레벨에서, 시스템 콜을 즉시 이용해 힙 영역에 데이터를 끌고오지 않고 즉시 다른 파일 디스크립터로 파일을 복사할 수 있게 하는 기법이다.
- Zero-Copy
추측) Java NIO가 단일 버퍼 모델을 채택하고, flip() | clear()/compact() 으로 현재 상태를 관리하며 입출력을 수행하는 이유
- 단일 버퍼 모델 채택
- Native 메모리는 OS에 직접 요청해야 하기 때문에, 할당받는 비용이 높다.
- 따라서, 버퍼의 할당 자체를 최소화하는 것을 목적으로 한다.
- 이는 입출력에 단일 버퍼 모델을 채택하기 위한 근거가 된다.
- 버퍼 상태 전환 메소드 도입
- 단일 버퍼 모델로 입력과 출력을 모두 받기 위해선, 현재 버퍼의 입력/출력 상태를 결정해야 한다.
- 이는 버퍼 상태 전환 메소드를 도입하는 근거가 된다.
- 따라서, 버퍼 상태 전환 메소드로 버퍼의 상태를 추가하여 입출력을 수행하는 이유는
- 메모리 최적화를 극한까지 끌어올려야 하기 때문이다.
결론
Java I/O
- 단순한 I/O가 필요할 때
- 기존 I/O로도 충분히 해결이 가능할 때
Java NIO로는 무엇을 할 수 있는가?
- 논블로킹
- 논블로킹 작업에 특화
- 멀티플렉싱 구현 가능
- Direct ByteBuffer
- Zero-Copy 기능을 도입할 수 있게 된다.
'Article > OS - Deep Dive' 카테고리의 다른 글
스택과 힙, 힙의 단편화 (0) | 2025.04.15 |
---|---|
메모리 압축(Memory Compaction), 그리고 Garbage Collector (0) | 2025.04.15 |
메모리 단편화와 페이징 (0) | 2025.04.14 |
메모리 할당 문제, 그리고 메모리 단편화 (0) | 2025.04.14 |
[OS] Thread의 정의와 Thread Pool, 그리고 적절한 Thread의 갯수 (0) | 2024.09.29 |