Notice
Recent Posts
Recent Comments
Link
«   2024/07   »
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
Archives
Today
Total
관리 메뉴

csct3434

[자바 성능 튜닝 이야기] 09. IO & NIO 본문

개발 서적/자바 성능 튜닝 이야기

[자바 성능 튜닝 이야기] 09. IO & NIO

csct3434 2024. 5. 9. 09:16

IO

  • 어떤 디바이스를 통해서 이루어 지는 모든 작업을 IO라고 한다
  • IO는 성능에 영향을 가장 많이 미치는 부분이다.
  • 자바에서 IO는 스트림을 통해서 이루어진다

주요 클래스

클래스명 용도
ByteArrayInputStream 바이트로 구성된 배열을 읽어서 입력 스트림을 만든다.
FileInputStream 이미지와 같은 바이너리 기반의 파일 스트림을 만든다.
FilterInputStream 여러 종류의 유용한 입력 스트림의 추상 클래스이다.
ObjectInputStream ObjectOutputStream을 통해서 저장한 객체를 읽기 위한 스트림을 만든다.
PipedInputStream PipedOutputStream을 통해서 출력된 스트림을 읽어서 처리하기 위한 스트림을 만든다.
SequenceInputStream 별개인 두 개의 스트림을 하나의 스트림으로 만든다.
  • 읽기 스트림을 생성하기 위한 클래스들은 다음과 같다.
  • 여기서 명시된 모든 클래스들은 java.io.InputStream의 하위 클래스들이다.
  • 쓰기 스트림을 생성하기 위한 클래스들은 'Input'을 'Output'으로 바꿔서 사용하면 된다 (FileInputStream -> FileOutputStream)
클래스명 용도
BufferedReader 일반적으로 문자열 기반의 파일을 읽을 때 가장 많이 사용되는 클래스로, 문자열 입력 스트림을 버퍼에 담아서 처리한다.
CharArrayReader char 배열로 된 문자 배열을 처리한다.
FilterReader 문자열 기반의 스트림을 처리하기 위한 추상 클래스이다.
FileReader FileInputStream을 통해 문자열 기반의 파일을 읽는 클래스이다.
InputStreamReader 바이트 기반의 스트림을 문자열 기반의 스트림으로 연결하는 역할을 수행한다.
PipedReader 파이프 스트림을 읽는다.
StringReader 문자열 기반의 소스를 읽는다.
  • 문자열 기반의 스트림을 읽기 위한 클래스들은 다음과 같다
  • 여기서 명시된 모든 클래스들은 java.io.Reader의 하위 클래스들이다.
  • 한번 오픈한 스트림을 닫지 않으면 나중에 리소스가 부족해질 수 있다.

성능 비교

  FileReader without Buffer FileReader with Buffer BufferedReader
응답 속도 450ms 80ms 120ms

 

FileReader without Buffer

import java.io.FileReader;
import java.io.IOException;
import java.util.ArrayList;

public class BasicIOReadUtil {

    public static ArrayList<StringBuffer> readCharStream(String filename) throws Exception {
        ArrayList<StringBuffer> list = new ArrayList<>();
        FileReader fr = null;
        try {
            fr = new FileReader(filename);
            int data = 0;
            StringBuffer sb = new StringBuffer();
            while ((data = fr.read()) != -1) {
                if (data == '\n' || data == '\r') {
                    list.add(sb);
                    sb = new StringBuffer();
                } else {
                    sb.append((char) data);
                }
            }
        } catch (IOException e) {
            System.err.println(e.getMessage());
            throw e;
        } catch (Exception e) {
            System.err.println(e.getMessage());
            throw e;
        } finally {
            if (fr != null) {
                fr.close();
            }
        }
        return list;
    }

    public static void main(String[] args) throws Exception {
        String filename = "/Users/K/10MBFile.txt";
        long start = System.currentTimeMillis();
        ArrayList<StringBuffer> list = BasicIOReadUtil.readCharStream(filename);
        long end = System.currentTimeMillis();
        System.out.println((end - start) + "ms");
        System.out.println(list.size());
    }
}
  • readCharStream 메서드는 지정된 파일을 문자 단위로 읽어 StringBuffer에 내용을 담고, 줄이 바뀔 경우 ArrayList에 담아서 리턴하도록 되어있다.
  • 파일을 처리할 때는 되도록이면 IOException을 따로 구분하여 처리하는 것이 좋다
  • main 메서드에서는 10MB 정도의 파일을 읽는 속도가 얼마나 되는지 확인한다.
  • 실행 결과, 맥북 M2 PRO 시스템에서 10MB 파일을 처리하는데 약 450ms 정도의 시간이 소요됐다.
  • 응답 속도가 느린 이유는 파일을 문자 단위로 읽도록 되어있기 때문이다.

FileReader with Buffer

public static String readCharStreamWithBuffer(String filename) throws Exception {
    StringBuffer retSB = new StringBuffer();
    FileReader fr = null;
    try {
        fr = new FileReader(filename);
        int bufferSize = 1024*1024;
        char[] readBuffer = new char[bufferSize];
        int resultSize=0;
        while ((resultSize = fr.read(readBuffer)) != -1) {
            if (resultSize == bufferSize) {
                retSB.append(readBuffer);
            } else {
                for (int loop = 0; loop < resultSize; loop++) {
                    retSB.append(readBuffer[loop]);
                }
            }
        }
    }
    // 이하 예외 처리 생략
    return retSB.toString();
}
  • 특정 배열로 데이터를 한번에 읽은 후 그 데이터를 사용하면 IO 횟수가 줄어 더 빠르게 처리할 수 있다.
  • 실행 결과, 맥북 M2 PRO 시스템에서 10MB 파일을 처리하는데 약 80ms 정도의 시간이 소요됐다.

BufferedReader

public static ArrayList<String> readBufferReader(String filename) throws Exception {
    ArrayList<String> list = new ArrayList<>();
    BufferedReader br = null;
    try {
        br = new BufferedReader(new FileReader(filename));
        String data;
        while ((data = br.readLine()) != null) {
            list.add(data);
        }
    }
    // 이하 예외 처리 생략
    return list;
}
  • BufferedReader 클래스는 java.io.Reader 객체를 인자로 받아 내부적으로 char 버퍼를 생성하여 이를 처리한다.
  • FileReader 클래스와 달리, 라인 단위로 읽을 수 있는 readLine() 메서드를 제공한다
  • readLine() 메서드와 버퍼링의 추상화로 인해, FileReader 코드에 비해 소스가 간단해졌다.
  • 실행 결과, 맥북 M2 PRO 시스템에서 10MB 파일을 처리하는데 약 120ms 정도의 시간이 소요됐다.
    • 책에서는 버퍼를 사용한 FileReader가 400ms, BufferedReader가 350ms으로 나왔는데, 나의 경우 후자가 더 느리게 나왔다
    • 큰 차이는 아니지만, 원인을 추정해보기로는 readLine() 메서드에서 synchronized를 사용하기 때문이 아닌가 싶다
    • 또한, FileReader 예시에서는 버퍼 크기를 1024 * 1024로 지정하고 있는데 BufferedReader의 기본 버퍼 크기는 8192이다. 이것이 더 느려진 원인인가 싶어 버퍼 크기를 동일하게 지정해 보았다. 그 결과, 오히려 느려져서 140ms의 시간이 소요됐다.
    • BufferedReader의 버퍼 크기가 증가해서 더 느려진 이유를 알고 계신 분은 알려주시면 감사하겠습니다..!

NIO

파일 읽기 작업 수행 과정

  1. 파일을 읽어들이는 자바 메서드를 호출한다
  2. JVM은 커널에게 파일 읽기 작업을 요청한다
  3. 커널은 DMA를 통해 하드 디스크의 파일을 커널 내부의 버퍼로 읽어들인다.
  4. 커널은 JVM으로 해당 데이터를 전달한다
  5. JVM은 메서드에 있는 스트림 관리 클래스를 사용하여 데이터를 처리한다.

3번과 4번 작업을 수행할 때 자바에서는 대기 시간이 발생할 수 밖에 없다.

NIO는 3번 작업을 자바에서 직접 통제하여 시간을 더 단축할 수 있게 한 것이다.


DirectByteBuffer를 잘못 사용하여 문제가 발생한 사례

  • NIO를 사용할 때 ByteBuffer를 사용하는 경우가 있다. ByteBuffer는 네트워크나 파일에 있는 데이터를 읽어 들일 때 사용한다.
  • ByteBuffer 객체를 생성하는 메서드 중 allocateDirect() 메서드는 OS 메모리에 할당된 메모리를 Native한 JNI로 처리하는 DirectByteBuffer 객체를 생성한다.
  • DirectByteBuffer의 생성자는 Bits 클래스의 reserveMemory() 메서드를 호출하는데, 이 메서드는 JVM의 가용 메모리보다 더 큰 메모리가 요구될 경우 System.gc() 메서드를 호출하여 명시적으로 GC를 수행한다.
  • 따라서 해당 생성자를 무차별적으로 호출할 경우 GC가 자주 발생하여 성능에 영향을 줄 수 밖에 없다.
  • 따라서 DirectByteBuffer 객체를 생성할 때는 매우 신중하게 접근해야 하며, 가능하다면 singleton 패턴을 사용하여 JVM에 하나의 객체만 생성하는 것을 권장한다.

lastModified() 메서드의 성능 저하

JDK 6까지는 자바에서 파일이 변경되었는지를 확인하기 위해 File 클래스의 lastModified() 메서드를 사용해왔다.

public long lastModified() {
    SecurityManager security = System.getSecurityManager();
    if (security != null) {
        security.checkRead(path);
    }
    if (isInvalid()) {
        return 0L;
    }
    return fs.getLastModifiedTime(this);
}

 

  1. System.getSecurityManager() 메서드를 호출하여 SecurityManager 객체를 얻어옴
  2. 만약 null이 아니면 SecurityManager 객체의 checkRead() 메서드 수행
  3. File 클래스 내부에 있는 FileSystem 객체를 통해 getLastModifiedTime() 메서드를 수행하여 결과 리턴

그냥 보기에는 3단계이지만, 각각의 단계에서 호출되는 메서드는 내부적으로 많은 메서드들을 호출한다.

따라서 해당 작업을 반복적으로 수행하는 형태의 서비스에서는 OS의 IO의 영향을 많이 받을 수 밖에 없다. JDK 7부터는 새로운 개념의 IO를 처리하여 이를 개선할 수 있다.

Watch 클래스

import static java.nio.file.StandardWatchEventKinds.ENTRY_MODIFY;

import java.nio.file.FileSystems;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.WatchEvent;
import java.nio.file.WatchKey;
import java.nio.file.WatchService;
import java.util.Date;

public class WatcherThread extends Thread {
    
    private final String dirname;
    
    public WatcherThread(String dirname) {
        this.dirname = dirname;
    }
    
    public void run() {
        System.out.println("Watcher is started");
        fileWatcher();
        System.out.println("Watcher is ended");
    }

    private void fileWatcher() {
        try {
            Path dir = Paths.get(dirname);
            WatchService watcher = FileSystems.getDefault().newWatchService();
            
            dir.register(watcher, ENTRY_MODIFY);

            WatchKey key;
            for (int loop = 0; loop < 4; loop++) {
                key = watcher.take();
                String watchedTime = new Date().toString();
                for (WatchEvent<?> event : key.pollEvents()) {
                    Path name = (Path) event.context();
                    System.out.format("%s modified at %s%n", name, watchedTime);
                }
                key.reset();
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

JDK 7에는 nio의 Path 클래스와 Watch로 시작하는 클래스가 추가되었다. fileWatcher() 메서드를 살펴보자.

  1. Path 객체를 생성하여 모니터링할 디렉터리를 지정한다.
  2. WatchService 객체를 생성한다.
  3. Path 객체의 register() 메서드를 호출하여 WatchService에서 파일이 수정되는 이벤트를 처리하도록 지정한다.
  4. watcher 객체의 take() 메서드를 호출하면 해당 디렉터리에 변경이 있을 때까지 대기하다가, 작업이 발견되면 WatchKey 클래스의 객체를 생성하여 반환한다.
  5. 파일에 변화가 생겼다면 이벤트의 목록을 가져온다.
  6. 이벤트를 처리한 다음에 key 객체를 reset한다.

이와 같이 JDK 7 이상의 환경에서는 Watch 관련 클래스를 통해 파일을 쉽게 모니터링할 수 있어 파일의 변경 여부를 주기적으로 확인할 필요가 없어졌다.


정리

  • IO 부분에서의 응답 시간 병목을 간과하면 전반적인 시스템의 응답 속도에 큰 영향을 주게 된다.
  • 필요에 따른 정확한 API를 사용하는 것도 중요하지만, 불필요하게 발생하는 파일 IO 자체를 줄이는 것이 중요하다.
  • 여기서 다룬 NIO는 빙산의 일각일 뿐, 세부적으로 들어가면 엄청나게 많은 기능들이 있다.
  • 만약 시스템의 IO 관련 문제가 심각하다고 판단되면 NIO 관련 서적으로 공부하는 것을 권장한다. 제대로만 사용한다면 기존의 IO 방식보다 빠른 결과를 낼 수 있다.