이번에 업무에서 NIO를 사용할 일이 있어서 공부하면서 내용을 정리해보고자 합니다.

해당 포스팅은 palpit Vlog님의 블로그의 내용을 보면서 정리한 내용입니다.


NIO란?

NIONew Input/output의 약자로 Java4부터 버전1이 지원이 되었으며 Java7부터는 버전2가 지원이 되었습니다. NIO의 대표적인 기능으로는 Non-blocking이 있으며,


IO vs NIO

구분 IO NIO
입출력 방식 Stream Channel
버퍼 방식 Non-Buffer Buffer
비동기 방식 지원 X 지원
Blocking / Non-Blocking 방식 Blocking Only Blocking / Non-Blocking
적합한 케이스 연결 클라이언트가 적고, IO가 큰 경우 (대용량) 연결 클라이언트가 많고 IO 처리가 작은 경우 (저용량)

Stream vs Channel

Stream

  • IO의 입출력 방식
  • 입력 스트림과 출력 스트림을 따로 만들어줘야 함

Channel

  • NIO의 입출력 방식
  • 양방향으로 입출력이 가능
  • 입출력을 위한 별도의 채널을 만들 필요 X
  • 하나의 파일에서 데이터를 읽고 쓴다면, FileChannel 하나만 필요

Non-Buffer vs Buffer

IO에서는 출력 스트림이 1바이트를 쓰면, 입력 스트림에서 1바이트를 읽습니다. 하지만 이런 형태는 느리기 때문에, 버퍼를 사용해서 여러개의 바이트를 한꺼번에 입력받고 저장했다가 한번에 출력하는 형태로 사용하여 성능을 높입니다. IO는 기본적으로 버퍼를 지원하지 않기 때문에, 버퍼를 제공해주는 보조 스트림인 BufferedInputStreamBufferedOutputStream을 이용합니다.

NIO는 기본적으로 버퍼가 지원이 되기때문에 IO보다 성능이 좋습니다. 채널은 버퍼에 저장된 데이터를 출력하고 입력된 데이터를 버퍼에 저장합니다.


Blocking vs Non-Blocking

Blocking : IO의 방식으로 각각의 스트림에서 read()write()가 호출이 되면 데이터가 입력되고, 데이터가 출력되기전까지, 스레드는 블로킹(=멈춤) 상태가 됩니다. 이렇게 되면 작업이 끝날때까지 기다려야 하며, 그 이전에는 해당 IO 스레드는 사용할 수 없게 되고, 인터럽트도 할 수 없습니다. 블로킹을 빠져나오려면 스트림을 닫는 방법 밖에 없습니다.

Non-Blocking : NIOBlockingNon-Blocking 모두 지원을 하며, NIOBlocking 상태에서는 Interrupt를 이용하여 빠져나올수도 있습니다. Non-Blocking은 입출력 작업이 준비가 되면 채널만 선택해서 처리하기 때문에 입출력 작업시 스레드가 멈추지 않습니다.


IO와 NIO의 Blocking 모드 차이

IONIO 모두 Blocking 모드를 지원하지만, 블로킹을 빠져나오기 위한 방법이 다릅니다. IOInterrupt를 이용해도 빠져나갈 수 없고, 오직 Stream을 닫는 수밖에 없지만, NIOInterrupt를 통해서 빠져나갈 수 있습니다.

종류 블로킹을 나가는 방법
IO Close Stream
NIO Intterupt & Close Stream

Path(경로 정의)

NIO에서 파일의 경로를 지정하기 위해서는 Path를 사용합니다, 경로는 절대경로, 상대경로 모두 지원합니다.

import java.nio.file.Paths

Path path = Paths.get(경로(String 타입));
Path path = Paths.get(경로(URI 타입));

예시

import java.nio.file.Path;
import java.nio.file.Paths;


public class test {
    public static void main(String[] args) {
        Path path = Paths.get("/Users/jungwoon/test.txt");

        System.out.println("fileName: " + path.getFileName()); // 선택된 파일의 이름
        System.out.println("parentName: " + path.getParent()); // 선택된 파일의 상위 디렉토리 정보
        System.out.println();
        
        for (int i = 0; i < path.getNameCount(); i++) {
            System.out.println("(" + i + ") : " + path.getName(i));
        }
    }
}

결과

fileName: test.txt
parentName: /Users/jungwoon

(0) : Users
(1) : jungwoon
(2) : test.txt

FileSystem

운영체제의 파일 시스템도 아래와 같은 방법으로 접근할 수 있습니다.

FileSystem fileSystem = FileSytems.getDefault();

예시

import java.io.IOException;
import java.nio.file.*;


public class test {

    public static void main(String[] args) throws IOException {

        FileSystem fileSystem = FileSystems.getDefault();

        for (FileStore fileStore : fileSystem.getFileStores()) {
            System.out.println("Driver : " + fileStore.name()); // 드라이버 명
            System.out.println("Type : " + fileStore.type()); // 파일 시스템 종류
            System.out.println("Total Space : " + fileStore.getTotalSpace()); // 전체 드라이브 공간
            System.out.println("Usable Space : " + fileStore.getUsableSpace()); // 사용가능한 드라이브 공간
            System.out.println("------------------------------------------");
        }
    }
}

결과

Driver : /dev/disk1s1
Type : apfs
Total Space : 500068036608
Unallocated Space : 219067645952
Usable Space : 216195403776
------------------------------------------
Driver : devfs
Type : devfs
Total Space : 340992
Unallocated Space : 0
Usable Space : 0
------------------------------------------
Driver : /dev/disk1s4
Type : apfs
Total Space : 500068036608
Unallocated Space : 497920528384
Usable Space : 216195403776
------------------------------------------
Driver : map -hosts
Type : autofs
Total Space : 0
Unallocated Space : 0
Usable Space : 0
------------------------------------------
Driver : map auto_home
Type : autofs
Total Space : 0
Unallocated Space : 0
Usable Space : 0
------------------------------------------

와치 서비스(WatchService)

와치 서비스는 디렉토리 내부에서 파일의 변화(생성, 삭제, 수정)를 감지하는데 사용합니다.

디렉토리(Path)에 WatchService를 등록하면 해당 디렉토리의 변화를 감지합니다.

import java.io.IOException;
import java.nio.file.*;
import java.util.List;


public class test {

    public static void main(String[] args) throws IOException {

        WatchService watchService = FileSystems.getDefault().newWatchService();
        Path path = Paths.get("/Users/jungwoon");

        path.register(
                watchService, // 위에서 만든 와치 인스턴스 등록
                StandardWatchEventKinds.ENTRY_CREATE, // 생성에 대해서 감지
                StandardWatchEventKinds.ENTRY_MODIFY, // 수정에 대해서 감지
                StandardWatchEventKinds.ENTRY_DELETE); // 삭제에 대해서 감지


        new Thread(() -> {
            try {
                while (true) {
                    WatchKey watchKey = watchService.take(); // 이벤트에 해당하는 WatchKey를 가져오기
                    List<WatchEvent<?>> list = watchKey.pollEvents(); // WatchKey에 해당하는 이벤트 목록 가져오기

                    for (WatchEvent watchEvent : list) {
                        WatchEvent.Kind kind = watchEvent.kind(); // 어떤 이벤트인지 종류 확인 (생성, 수정, 삭제)
                        String fileName = ((Path) watchEvent.context()).getFileName().toString(); // 어떤 파일인지 이름 가져오기

                        if (kind == StandardWatchEventKinds.ENTRY_CREATE) {
                            System.out.println("Created : " + fileName);
                        } else if (kind == StandardWatchEventKinds.ENTRY_MODIFY) {
                            System.out.println("Modified : " + fileName);
                        } else if (kind == StandardWatchEventKinds.ENTRY_DELETE) {
                            System.out.println("Deleted : " + fileName);
                        }
                    }

                    // 다 쓴 watchKey는 초기화를 해야하는데 안하면 동일한 watchKey가 큐에 또 다시 들어가게 됨
                    // 초기화에 성공하면 true, 실패하면 false
                    if (!watchKey.reset())
                        break;
                }
                watchService.close(); // 끝나면 서비스 종료
            }
            catch (Exception e) {
                e.printStackTrace();
            }
        }).start();
    }
}

결과

Modified : .zsh_history
Created : test2.txt
Modified : .zsh_history
Modified : .zsh_history
Deleted : test.txt
Deleted : test2.txt

버퍼

버퍼는 저장되는 데이터 타입어떤 메모리를 사용하느냐에 따라 구분이 됩니다.

데이터 타입에 따른 버퍼

NIO버퍼는 저장되는 데이터 타입에 따라서 별도의 클래스가 제공이 되는데 모두 Buffer Class를 상속받고 있습니다.

출처 : Google Images


Direct Buffer vs Non Direct Buffer

버퍼가 사용하는 메모리 위치에 따라서 다이렉트 버퍼넌 다이렉트 버퍼로 분류할수 있습니다.

구분 Direct Buffer Non Direct Buffer
사용 공간 OS의 메모리 JVM의 힙 메모리
버퍼 생성 속도 느리다 빠르다
버퍼의 크기 크다 작다
I/O 성능 높다 낮다
적합한 케이스 한 번 생성해놓고 재사용시 적합 빈번하게 생성해야할때 적합

다이렉트 버퍼채널을 사용해서 버퍼의 데이터를 읽고 저장할 경우에만 운영체제의 native I/O를 사용합니다. 만약 채널을 사용하지 않고 ByteBuffer의 get() / put() 메서드 사용시에는 내부적으로 JNI를 호출해야하기 때문에 JNI 호출이라는 오버 헤더가 추가됩니다.

생성방법

넌 다이렉트 버퍼의 생성 (아래 예시는 ByteBuffer를 만드는 예시 입니다, 다른 종류의 버퍼들도 가능합니다.)

ByteBuffer buffer = ByteBuffer.allocate(100); // 최대 100Byte를 저장하는 넌 다이렉트 버퍼

다이렉트 버퍼의 생성 (아래 예시는 ByteBuffer를 만드는 예시 입니다, 다른 종류의 버퍼들도 가능합니다.)

ByteBuffer buffer = ByteBuffer.allocateDirect(100); // 최대 100Byte를 저장하는 다이렉트 버퍼

Little endian vs Big endian (바이트 해석순서)

데이터 처리시 바이트의 처리 순서는 운영체젬다 차이가 있는데, 크게 Big endian 방식과 Little endian 방식이 있습니다.

  • Big endian : 앞쪽 바이트부터 먼저 처리
  • Little endian : 뒤쪽 바이트부터 먼저 처리

아래 코드를 이용하여, 자신의 OS의 endian 타입도 가져올 수 있습니다. 저는 Mac OS를 사용하는데 LITTLE_ENDIAN이네요

System.out.println("os name : " + System.getProperty("os.name"));
System.out.println("type of endian : " + ByteOrder.nativeOrder());

결과

OS Name : Mac OS X
Type of endian : LITTLE_ENDIAN

JVM은 기본적으로 BIG_ENDIAN을 사용하는데, 보통은 JVM이 자동적으로 처리를 해주기 때문에 문제가 없습니다. 하지만 다이렉트 버퍼일 경우에는 운영체제의 기본 해석 순서와 맞춰주는게 성능에 도움이 됩니다.

해석 순서가 다를때 맞춰주는 방법은 다음과 같이 order(ByteOrder.nativeOrder())를 이용할 수 있습니다.

ByteBuffer byteBuffer = ByteBuffer.allocateDirect(100).order(ByteOrder.nativeOrder());

버퍼의 위치 속성

  • position : 현재 읽거나 쓰는 위치값, 0 ~ limit
  • limit : 버퍼에서 읽거나 쓸 수 있는 위치의 한계, 이 값은 capacity보다 작거나 같은 값을 가진다.
  • capacity : 버퍼의 최대 데이터 개수 (인덱스가 아니라 크기, 크기가 7일때 인덱스는 0 ~ 6)
  • mark : reset() 메서드 실행시 돌아오는 위치를 지정하는 인덱스, mark() 메서드로 지정 가능하나 반드시 position 이하의 값이어야 한다.

그래서 위 4개의 위치 속성은 다음과 같은 규칙을 가집니다.

0 <= mark <= position <= limit <= capacity

위치속성 변경 메소드

  • flip() : limit를 현재 position의 위치로 설정하고 position0번으로 갑니다.
  • mark() : 현재 position의 위치에 mark를 놓습니다.
  • rewind() : limit은 유지, position0번으로 옮깁니다. mark는 자동적으로 사라집니다.
  • reset() : positionmark 위치로 가져다 놓습니다.
  • clear() : limit은 capacityposition은 0으로 mark는 사라짐
  • compact() : position ~ limit 데이터가 0으로 복사되고 position은 복사된 데이터 다음으로 이동, 그 외에 데이터는 유지

버퍼에 데이터를 읽고 쓰는 메소드들

  • get() : 데이터를 읽는 메소드
  • put() : 데이터를 쓰는 메소드

  • 상대적 : position으로부터 데이터를 읽고 쓰는 경우
  • 절대적 : position에 상관없이 주어진 인덱스에서 데이터를 읽고 쓰는 경우

File Channel

FileChannel은 동기화 처리가 되기 때문에 멀티스레드 환경에서도 안전하게 사용이 가능합니다.

파일 채널 얻는 방법

FileChannel은 open(), FileInputStream(), FileOutputStream(), getChannel() 메서드를 호출해서 얻을 수 있습니다.

아래는 /Users/jungwoon/를 읽고 쓸 수 있도록 채널을 열었습니다.

// "/Users/jungwoon/'를 읽고 쓸 수 있도록 채널을 생성
FileChannel fileChannel  = FileChannel.open(
                Paths.get("/Users/jungwoon/"),
                StandardOpenOption.READ,
                StandardOpenOption.WRITE);

다 쓴 채널은 close()를 이용하여 닫아줍니다.

fileChannel.close();

파일의 읽고 쓰기

파일에 바이트를 읽고 쓰려면 다음과 같이 FileChannel()의 read()메서드와 write()메서드를 호출하면 됩니다. 사용하는 방법은 다음과 같습니다.

import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.channels.FileChannel;
import java.nio.charset.Charset;
import java.nio.file.*;
import java.util.List;


public class test {

    public static void main(String[] args) throws IOException {

        FileChannel writeFileChannel
                = FileChannel.open(Paths.get("/Users/jungwoon/test.txt"), StandardOpenOption.WRITE);

        // 쓰기
        ByteBuffer byteBuffer = Charset.defaultCharset().encode("Let's go NIO coding");

        int byteLength = writeFileChannel.write(byteBuffer); // 쓴 바이트의 길이
        System.out.println("byte length is : " + byteLength);
        writeFileChannel.close();

        // ----------------------------------------------------------------------------------------------------

        FileChannel readFileChannel
                = FileChannel.open(Paths.get("/Users/jungwoon/test.txt"),  StandardOpenOption.READ);

        // 읽기
        ByteBuffer buffer = ByteBuffer.allocate(100); // capacity를 100으로

        StringBuilder readData = new StringBuilder();
        int byteCount;

        while (true) {
            byteCount = readFileChannel.read(buffer); // 100바이트를 읽습니다.

            // 더 이상 읽을 바이트가 없으면 -1을 리턴
            if (byteCount == -1)
                break;

            buffer.flip();
            readData.append(Charset.defaultCharset().decode(buffer).toString());
            buffer.clear();
        }

        readFileChannel.close();

        System.out.println("readData is : " + readData);

    }
}

결과

byte length is : 19
readData is : Let's go NIO coding

파일 비동기 채널

FileChannel에서 read()와 write()가 호출되는 동안 스레드는 블로킹(=멈춤)이 됩니다. 그래서 보통 이런 경우는 별도의 스레드를 생성해서 처리하는데, 이러한 I/O가 동시에 많이 처리되야 한다면 스레드 수도 함께 증가하기 때문에 문제가 발생할 수 있습니다.

그래서 NIO비동기 파일 채널(AsynchronousFileChannel)을 제공하고 있습니다, 비동기 파일 채널은 데이터의 read()와 write() 호출시에 스레드 풀에게 작업 요청을 하고, 해당 작업이 끝나면 콜백 메서드가 호출됩니다.

사용 방법

이 방법은 기본 스레드풀의 스레드 개수를 지정할 수 없습니다.

AsynchronousFileChannel fileChannel = AsynchronousFileChannel.open(
                Paths.get("/Users/jungwoon/test.txt"),
                StandardOpenOption.READ,
                StandardOpenOption.WRITE);

스레드풀의 개수를 지정하기 위해서는 아래와 같이 만들어야 합니다.

// ExecutorService는 스레드풀을 생성
// Runtime.getRuntime().availableProcessors() : CPU 코어 수
ExecutorService executorService = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors());

AsynchronousFileChannel fileChannel = AsynchronousFileChannel.open(
        Paths.get("/Users/jungwoon/test.txt"),
        EnumSet.of(StandardOpenOption.READ, StandardOpenOption.WRITE),
        executorService);

read()와 write()에는

read(ByteBuffer dst, long position, A attachment, CompletionHandler<Integer, A> handler);
write(ByteBuffer dst, long position, A attachment, CompletionHandler<Integer, A> handler);
  • dst : 읽고 쓰기 위한 ByteBuffer
  • position : 파일을 읽거나 쓰기 위한 위치
  • attachment : 콜백 메소드로 전달한 객체
  • handler : 작업 완료했을때, 처리할 콜백 함수
    • Integer : read()와 write()가 읽거나 쓴 바이트 수
    • A : 첨부 객체 타입으로 개발자가 CompletionHandler 구현 객체 작성시 지정 가능하며, 생략도 가능합니다.

아래는 read()에 대한 사용예제로, 주의할점은 작업이 정상적으로 완료되거나, 실패할 경우에만 채널을 닫아야 합니다.

import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.AsynchronousFileChannel;
import java.nio.channels.CompletionHandler;
import java.nio.charset.Charset;
import java.nio.file.*;
import java.util.EnumSet;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;


public class test {

    public static void main(String[] args) throws IOException {

        // ExecutorService는 스레드풀을 생성
        // Runtime.getRuntime().availableProcessors() : CPU 코어 수
        ExecutorService executorService = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors());


        for (int i = 0; i < 10; i++) {
            // test0.txt, test1.txt ... 파일을 만들기위한 path 객체
            Path path = Paths.get("/Users/jungwoon/test" + i + ".txt");
            // 상위 디렉토리가 없으면 미리 생성
            Files.createDirectories(path.getParent());

            AsynchronousFileChannel fileChannel = AsynchronousFileChannel.open(
                    path,
                    EnumSet.of(StandardOpenOption.CREATE, StandardOpenOption.WRITE),
                    executorService);

            // ByteBuffer 생성
            ByteBuffer byteBuffer = Charset.defaultCharset().encode("Hello World");

            // Callback Method에 제공할 첨부 객체로, 결과값 이외에 추가 정보를 얻고자 할 때 사용
            class Attachment {
                private Path path;
                private AsynchronousFileChannel asynchronousFileChannel;

                Path getPath() {
                    return path;
                }

                private void setPath(Path path) {
                    this.path = path;
                }

                AsynchronousFileChannel getAsynchronousFileChannel() {
                    return asynchronousFileChannel;
                }

                private void setAsynchronousFileChannel(AsynchronousFileChannel asynchronousFileChannel) {
                    this.asynchronousFileChannel = asynchronousFileChannel;
                }
            }

            Attachment attachment = new Attachment();
            attachment.setPath(path);
            attachment.setAsynchronousFileChannel(fileChannel);

            CompletionHandler<Integer, Attachment> completionHandler = new CompletionHandler<Integer, Attachment>() {

                // 작업이 정상적으로 완료될었을 경우
                @Override
                public void completed(Integer result, Attachment attachment) {
                    System.out.println(
                            attachment.getPath().getFileName() + " : "
                            + result + " bytes written : "
                            + Thread.currentThread().getName());

                    try {
                        attachment.getAsynchronousFileChannel().close();
                    } catch (IOException e) {
                        e.printStackTrace();
                    }
                }

                // 작업이 뭔가에 의해서 실패한 경우
                @Override
                public void failed(Throwable exc, Attachment attachment) {
                    exc.printStackTrace();

                    try {
                        attachment.getAsynchronousFileChannel().close();
                    } catch (IOException e) {
                        e.printStackTrace();
                    }
                }
            };

            fileChannel.write(byteBuffer, 0, attachment, completionHandler);
        }
        executorService.shutdown();
    }
}

결과

test1.txt : 11 bytes written : pool-1-thread-2
test0.txt : 11 bytes written : pool-1-thread-1
test3.txt : 11 bytes written : pool-1-thread-4
test2.txt : 11 bytes written : pool-1-thread-3
test4.txt : 11 bytes written : pool-1-thread-4
test5.txt : 11 bytes written : pool-1-thread-4
test7.txt : 11 bytes written : pool-1-thread-1
test6.txt : 11 bytes written : pool-1-thread-4
test8.txt : 11 bytes written : pool-1-thread-4
test9.txt : 11 bytes written : pool-1-thread-1

아래는 위에서 만든 파일에 대해서 쓰기 예제 입니다.

import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.AsynchronousFileChannel;
import java.nio.channels.CompletionHandler;
import java.nio.charset.Charset;
import java.nio.file.*;
import java.util.EnumSet;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;


public class test {

    public static void main(String[] args) throws IOException {

        // ExecutorService는 스레드풀을 생성
        // Runtime.getRuntime().availableProcessors() : CPU 코어 수
        ExecutorService executorService = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors());


        for (int i = 0; i < 10; i++) {
            // test0.txt, test1.txt ... 파일을 만들기위한 path 객체
            Path path = Paths.get("/Users/jungwoon/test" + i + ".txt");
            // 상위 디렉토리가 없으면 미리 생성
            Files.createDirectories(path.getParent());

            AsynchronousFileChannel fileChannel = AsynchronousFileChannel.open(
                    path,
                    EnumSet.of(StandardOpenOption.READ),
                    executorService);

            // ByteBuffer 생성
            ByteBuffer byteBuffer = ByteBuffer.allocate((int) fileChannel.size());

            // Callback Method에 제공할 첨부 객체로, 결과값 이외에 추가 정보를 얻고자 할 때 사용
            class Attachment {
                private Path path;
                private AsynchronousFileChannel asynchronousFileChannel;
                private ByteBuffer byteBuffer;

                public ByteBuffer getByteBuffer() {
                    return byteBuffer;
                }

                public void setByteBuffer(ByteBuffer byteBuffer) {
                    this.byteBuffer = byteBuffer;
                }

                Path getPath() {
                    return path;
                }

                private void setPath(Path path) {
                    this.path = path;
                }

                AsynchronousFileChannel getAsynchronousFileChannel() {
                    return asynchronousFileChannel;
                }

                private void setAsynchronousFileChannel(AsynchronousFileChannel asynchronousFileChannel) {
                    this.asynchronousFileChannel = asynchronousFileChannel;
                }
            }

            Attachment attachment = new Attachment();
            attachment.setPath(path);
            attachment.setAsynchronousFileChannel(fileChannel);
            attachment.setByteBuffer(byteBuffer);

            CompletionHandler<Integer, Attachment> completionHandler = new CompletionHandler<Integer, Attachment>() {

                // 작업이 정상적으로 완료될었을 경우
                @Override
                public void completed(Integer result, Attachment attachment) {

                    attachment.getByteBuffer().flip();

                    String data = Charset.defaultCharset().decode(byteBuffer).toString();

                    System.out.println(
                            attachment.getPath().getFileName() + " : "
                            + data + " : "
                            + Thread.currentThread().getName());

                    try {
                        attachment.getAsynchronousFileChannel().close();
                    } catch (IOException e) {
                        e.printStackTrace();
                    }
                }

                // 작업이 뭔가에 의해서 실패한 경우
                @Override
                public void failed(Throwable exc, Attachment attachment) {
                    exc.printStackTrace();

                    try {
                        attachment.getAsynchronousFileChannel().close();
                    } catch (IOException e) {
                        e.printStackTrace();
                    }
                }
            };

            fileChannel.read(byteBuffer, 0, attachment, completionHandler);
        }

        executorService.shutdown();

    }
}

결과

test0.txt : Hello World : pool-1-thread-1
test3.txt : Hello World : pool-1-thread-4
test2.txt : Hello World : pool-1-thread-3
test1.txt : Hello World : pool-1-thread-2
test4.txt : Hello World : pool-1-thread-3
test5.txt : Hello World : pool-1-thread-3
test6.txt : Hello World : pool-1-thread-3
test8.txt : Hello World : pool-1-thread-3
test7.txt : Hello World : pool-1-thread-4
test9.txt : Hello World : pool-1-thread-3