>  기사  >  Java  >  Java NIO 핵심 구성요소에 대한 심층적인 이해

Java NIO 핵심 구성요소에 대한 심층적인 이해

不言
不言앞으로
2018-11-16 17:42:222543검색

이 기사는 Java NIO의 핵심 구성 요소에 대한 심층적인 이해를 제공합니다. 도움이 필요한 친구들이 참고할 수 있기를 바랍니다.

동기화, 비동기, 차단, 비차단

우선 이러한 개념은 매우 헷갈리기 쉽지만, NIO에도 관련되어 있으니 정리해 보겠습니다 [1].

  • 동기화: API 호출이 반환되면 호출자는 작업 결과(실제로 읽거나 쓴 바이트 수)를 알 수 있습니다.

  • 비동기식: 동기화에 비해 API 호출이 반환될 때 호출자는 작업 결과를 알 수 없으며 결과는 나중에 콜백으로 통보됩니다.

  • Blocking: 읽을 데이터가 없거나 모든 데이터를 쓸 수 없는 경우 현재 스레드를 일시 중지하고 대기합니다.

  • 비 차단: 읽을 때 최대한 많은 데이터를 읽은 후 반환합니다. 쓸 때는 최대한 많은 데이터를 쓴 다음 반환합니다.

I/O 작업의 경우 Oracle 공식 웹사이트의 문서에 따르면 동기식과 비동기식의 분류 표준은 "호출자가 I/O 작업이 완료될 때까지 기다려야 하는지 여부"입니다. /O 작업 완료"는 반드시 데이터를 읽거나 모든 데이터를 써야 한다는 의미는 아니며 TCP/IP 프로토콜 간에 데이터가 전송되는 시간과 같이 실제로 I/O 작업이 수행될 때 호출자가 기다려야 하는지 여부를 나타냅니다. 스택 버퍼와 JVM 버퍼.

그래서 일반적으로 사용되는 read() 및 write() 메서드는 동기 I/O입니다. 동기 I/O는 차단 모드와 비차단 모드인 경우 데이터가 감지되지 않을 때의 두 가지 모드로 구분됩니다. 읽으려면 실제로 I/O 작업을 수행하지 않고 직접 반환합니다.

요약하자면 실제로 Java에는 동기식 차단 I/O, 동기식 비차단 I/O 및 비동기식 I/O의 세 가지 메커니즘만 있다는 것입니다. 아래에서 설명할 내용은 처음 두 가지이며 JDK 1.7은 이제 막 시작되었습니다. NIO.2라고 불리는 비동기 I/O를 도입합니다.

Traditional IO

우리는 새로운 기술의 출현은 항상 개선과 개선을 동반한다는 것을 알고 있으며, Java NIO의 출현도 마찬가지입니다.

기존 I/O는 I/O를 차단하고 있으며, 가장 큰 문제는 시스템 리소스 낭비입니다. 예를 들어, TCP 연결의 데이터를 읽으려면 InputStream의 read() 메서드를 호출합니다. 그러면 데이터가 도착할 때까지 현재 스레드가 일시 중지됩니다. 그런 다음 데이터가 도착하는 동안 스레드가 메모리를 차지합니다. . 리소스(저장 스레드 스택)는 아무 작업도 수행하지 않습니다. 즉, 다른 연결의 데이터를 읽으려면 다른 스레드를 시작해야 합니다. 동시 연결 수가 적을 때는 문제가 되지 않을 수 있지만, 연결 수가 일정 규모에 도달하면 많은 수의 스레드에 의해 메모리 리소스가 소모됩니다. 반면에 스레드 전환은 프로그램 카운터, 레지스터 값 등 프로세서의 상태를 변경해야 하므로 많은 수의 스레드 간을 매우 자주 전환하는 것도 리소스 낭비입니다.

기술의 발전과 함께 최신 운영 체제는 이러한 리소스 낭비를 방지하기 위해 새로운 I/O 메커니즘을 제공합니다. 이를 바탕으로 Java NIO가 탄생하게 되었습니다. NIO의 대표적인 기능은 Non-Blocking I/O입니다. 그런 다음 단순히 비차단 I/O를 사용하는 것만으로는 문제를 해결할 수 없다는 사실을 발견했습니다. 왜냐하면 비차단 모드에서는 데이터를 읽지 않으면 read() 메서드가 즉시 반환하기 때문입니다. 데이터가 언제 도착할지 알 수 없습니다. 계속해서 read() 메서드를 호출해야만 재시도할 수 있는데 이는 명백히 CPU 리소스 낭비입니다. 아래에서 볼 수 있듯이 이 문제를 해결하기 위해 Selector 구성 요소가 탄생했습니다.

Java NIO 핵심 구성 요소

1.Channel

Concept

Java NIO의 모든 I/O 작업은 스트림 작업이 Stream 개체를 기반으로 하는 것처럼 Channel 개체를 기반으로 하므로 먼저 Channel이 무엇인지 이해해야 합니다. . 다음 내용은 JDK 1.8의 문서에서 가져온 것입니다.

채널은 하나 이상의 고유한 작업을 수행할 수 있는
하드웨어 장치, 파일, 네트워크 소켓 또는 프로그램 구성 요소와 같은 엔터티에 대한 열린 연결을 나타냅니다. I/O 작업(예: 읽기 또는 쓰기).

위 내용에서 볼 수 있듯이 채널은 특정 엔터티에 대한 연결을 나타냅니다. 이 엔터티는 파일, 네트워크 소켓 등이 될 수 있습니다. 즉, 채널은 프로그램이 운영 체제의 기본 I/O 서비스와 상호 작용할 수 있도록 Java NIO에서 제공하는 브리지입니다.

Channel은 매우 기본적이고 추상적인 설명입니다. 다양한 I/O 서비스와 상호 작용하고, 다양한 I/O 작업을 수행하며, 다양한 구현이 있으므로 특정 항목에는 FileChannel, SocketChannel 등이 포함됩니다.

채널은 데이터를 버퍼로 읽거나 버퍼의 데이터를 채널에 쓸 수 있다는 점에서 스트림과 유사합니다.

Java NIO 핵심 구성요소에 대한 심층적인 이해물론 차이점이 있는데 주로 다음 두 가지 점에 반영됩니다.

    채널은 읽고 쓸 수 있는 반면 스트림은 단방향입니다(따라서 InputStream과 OutputStream으로 구분됨)
  • 채널에 비차단 I/O 모드가 있습니다
  • implementation

Java NIO에서 가장 일반적으로 사용되는 채널 구현은 다음과 같습니다. 전통적인 I/O 작업 클래스에 해당함을 알 수 있습니다. 대일.

    FileChannel: 파일 읽기 및 쓰기
  • DatagramChannel: UDP 프로토콜 네트워크 통신
  • SocketChannel: TCP 프로토콜 네트워크 통신

  • ServerSocketChannel: TCP 연결 모니터링

2.Buffer

NIO에서 사용되는 버퍼는 단순한 바이트 배열이 아니라 캡슐화된 Buffer 클래스를 제공하는 API입니다. 아래에 자세히 설명된 대로 데이터를 유연하게 조작할 수 있습니다.

NIO는 Java 기본 타입에 대응하여 ByteBuffer, CharBuffer, IntBuffer 등 다양한 Buffer 타입을 제공합니다. 차이점은 버퍼를 읽을 때와 쓸 때 단위 길이가 다르다는 것입니다(읽기와 쓰기는 변수 단위로 이루어집니다) 해당 유형).

Buffer에는 Buffer의 작동 메커니즘을 이해하는 데 중요한 3가지 중요한 변수가 있습니다.

  • capacity(총 용량)

  • position(포인터의 현재 위치)

  • 제한(읽기/쓰기 경계 위치)

Buffer는 C 언어의 문자 배열과 매우 유사하게 작동합니다. 비유하자면 용량은 배열의 전체 길이이고 위치는 문자를 읽고 쓸 수 있는 첨자 변수입니다. 한계는 종결자입니다. Buffer에 있는 세 가지 변수의 초기 상황은 아래와 같습니다

Java NIO 핵심 구성요소에 대한 심층적인 이해

Buffer를 읽고 쓰는 과정에서 위치가 뒤로 이동하게 되는데, 그 한계는 위치 이동의 경계가 됩니다. Buffer에 쓸 때는 용량의 크기로 제한을 설정하고, Buffer를 읽을 때는 데이터의 실제 끝 위치로 제한을 설정해야 한다는 것을 상상하기 어렵지 않습니다. (참고: 채널에 버퍼 데이터를 쓰는 것은 버퍼 읽기 작업이고, 채널에서 버퍼로 데이터를 읽는 것은 버퍼 쓰기 작업입니다.)

버퍼를 읽거나 쓰기 전에 버퍼 클래스에서 제공하는 몇 가지 보조 메서드를 호출할 수 있습니다. 위치 및 제한 값을 올바르게 설정하려면 주로 다음과 같은 것이 있습니다

  • flip(): 제한을 위치 값으로 설정한 다음 위치를 0으로 설정합니다. 버퍼를 읽기 전에 호출됩니다.

  • rewind(): position
    을 0으로 설정하세요. 일반적으로 Buffer 데이터를 다시 읽기 전에 호출됩니다. 예를 들어 동일한 Buffer 데이터를 읽어 여러 채널에 쓸 때 사용됩니다.

  • clear(): 초기 상태로 돌아갑니다. 즉, 제한은 용량과 동일하고 위치는 0으로 설정됩니다. 버퍼에 다시 쓰기 전에 호출됩니다.

  • compact(): 읽지 않은 데이터(위치와 한계 사이의 데이터)를 버퍼의 시작 부분으로 이동하고 position
    을 이 데이터 끝의 다음 위치로 설정합니다. 실제로 이러한 데이터 조각을 버퍼에 다시 쓰는 것과 같습니다.

그런 다음 FileChannel을 사용하여 텍스트 파일을 읽고 쓰는 예를 살펴보겠습니다. 이 예를 통해 채널의 읽기 및 쓰기 가능 특성과 Buffer의 기본 사용법을 확인합니다(FileChannel은 비차단으로 설정할 수 없습니다). 방법).

FileChannel channel = new RandomAccessFile("test.txt", "rw").getChannel();
channel.position(channel.size());  // 移动文件指针到末尾(追加写入)

ByteBuffer byteBuffer = ByteBuffer.allocate(20);

// 数据写入Buffer
byteBuffer.put("你好,世界!\n".getBytes(StandardCharsets.UTF_8));

// Buffer -> Channel
byteBuffer.flip();
while (byteBuffer.hasRemaining()) {
    channel.write(byteBuffer);
}

channel.position(0); // 移动文件指针到开头(从头读取)
CharBuffer charBuffer = CharBuffer.allocate(10);
CharsetDecoder decoder = StandardCharsets.UTF_8.newDecoder();

// 读出所有数据
byteBuffer.clear();
while (channel.read(byteBuffer) != -1 || byteBuffer.position() > 0) {
    byteBuffer.flip();

    // 使用UTF-8解码器解码
    charBuffer.clear();
    decoder.decode(byteBuffer, charBuffer, false);
    System.out.print(charBuffer.flip().toString());

    byteBuffer.compact(); // 数据可能有剩余
}

channel.close();

이 예제에서는 두 개의 버퍼를 사용하는데, 그 중 byteBuffer는 채널 읽기 및 쓰기를 위한 데이터 버퍼로 사용되고 charBuffer는 디코딩된 문자를 저장하는 데 사용됩니다. Clear()와 Flip()의 사용법은 위에서 언급한 바와 같습니다. 주목해야 할 것은 마지막 Compact() 메서드입니다. charBuffer의 크기가 byteBuffer의 디코딩된 데이터를 완전히 수용할 수 있을 만큼 충분하더라도 이 Compact()는 다음과 같습니다. 필수입니다. 일반적으로 사용되는 한자의 UTF-8 인코딩은 3바이트를 차지하기 때문에 중간에 잘릴 확률이 높습니다. 아래 그림을 참조하세요.

Java NIO 핵심 구성요소에 대한 심층적인 이해

디코더가 0xe4를 읽을 때. decode() 메서드의 세 번째 매개 변수 false의 기능은 디코더가 매핑되지 않은 바이트와 후속 데이터를 추가 데이터로 간주하도록 하는 것입니다. 따라서 decode() 메서드는 여기서 멈추면 위치는 0xe4 위치로 돌아갑니다. 결과적으로 "중간" 문자 인코딩의 첫 번째 바이트가 버퍼에 남게 되며 올바른 후속 데이터와 연결되도록 앞쪽으로 압축되어야 합니다.

BTW, 예제의 CharsetDecoder도 Java NIO의 새로운 기능이므로 NIO 작업이 버퍼 지향적이라는 점을 발견했어야 합니다(기존 I/O는 스트림 지향적임).

이제 채널과 버퍼의 기본적인 사용법을 이해했습니다. 다음으로 이야기할 것은 하나의 스레드가 여러 채널을 관리하도록 하는 중요한 구성 요소입니다.

3.Selector

Selector란 무엇입니까

Selector(셀렉터)는 각 채널의 상태(또는 이벤트)를 수집하는 데 사용되는 특수 구성 요소입니다. 먼저 채널을 선택기에 등록하고 관심 있는 이벤트를 설정한 다음 select() 메서드를 호출하여 이벤트가 발생할 때까지 조용히 기다릴 수 있습니다.

채널에는 모니터링할 다음 4가지 이벤트가 있습니다.

  • 수락: 허용 가능한 연결이 있습니다.

  • 연결: 연결이 성공했습니다.

  • 읽기: 읽을 데이터가 있습니다

  • 쓰기: 쓸 수 있습니다. 입력된 데이터

为什么要用Selector

前文说了,如果用阻塞I/O,需要多线程(浪费内存),如果用非阻塞I/O,需要不断重试(耗费CPU)。Selector的出现解决了这尴尬的问题,非阻塞模式下,通过Selector,我们的线程只为已就绪的通道工作,不用盲目的重试了。比如,当所有通道都没有数据到达时,也就没有Read事件发生,我们的线程会在select()方法处被挂起,从而让出了CPU资源。

使用方法

如下所示,创建一个Selector,并注册一个Channel。

注意:要将 Channel 注册到 Selector,首先需要将 Channel 设置为非阻塞模式,否则会抛异常。

Selector selector = Selector.open();
channel.configureBlocking(false);
SelectionKey key = channel.register(selector, SelectionKey.OP_READ);

register()方法的第二个参数名叫“interest set”,也就是你所关心的事件集合。如果你关心多个事件,用一个“按位或运算符”分隔,比如

SelectionKey.OP_READ | SelectionKey.OP_WRITE复制代码

这种写法一点都不陌生,支持位运算的编程语言里都这么玩,用一个整型变量可以标识多种状态,它是怎么做到的呢,其实很简单,举个例子,首先预定义一些常量,它们的值(二进制)如下

Java NIO 핵심 구성요소에 대한 심층적인 이해

可以发现,它们值为1的位都是错开的,因此对它们进行按位或运算之后得出的值就没有二义性,可以反推出是由哪些变量运算而来。怎么判断呢,没错,就是“按位与”运算。比如,现在有一个状态集合变量值为 0011,我们只需要判断 “0011 & OP_READ” 的值是 1 还是 0 就能确定集合是否包含 OP_READ 状态。

然后,注意 register() 方法返回了一个SelectionKey的对象,这个对象包含了本次注册的信息,我们也可以通过它修改注册信息。从下面完整的例子中可以看到,select()之后,我们也是通过获取一个 SelectionKey 的集合来获取到那些状态就绪了的通道。

一个完整实例

概念和理论的东西阐述完了(其实写到这里,我发现没写出多少东西,好尴尬(⊙ˍ⊙)),看一个完整的例子吧。

这个例子使用Java NIO实现了一个单线程的服务端,功能很简单,监听客户端连接,当连接建立后,读取客户端的消息,并向客户端响应一条消息。

需要注意的是,我用字符 ‘0′(一个值为0的字节) 来标识消息结束。

单线程Server

public class NioServer {

public static void main(String[] args) throws IOException {
    // 创建一个selector
    Selector selector = Selector.open();

    // 初始化TCP连接监听通道
    ServerSocketChannel listenChannel = ServerSocketChannel.open();
    listenChannel.bind(new InetSocketAddress(9999));
    listenChannel.configureBlocking(false);
    // 注册到selector(监听其ACCEPT事件)
    listenChannel.register(selector, SelectionKey.OP_ACCEPT);

    // 创建一个缓冲区
    ByteBuffer buffer = ByteBuffer.allocate(100);

    while (true) {
        selector.select(); //阻塞,直到有监听的事件发生
        Iterator<selectionkey> keyIter = selector.selectedKeys().iterator();

        // 通过迭代器依次访问select出来的Channel事件
        while (keyIter.hasNext()) {
            SelectionKey key = keyIter.next();

            if (key.isAcceptable()) { // 有连接可以接受
                SocketChannel channel = ((ServerSocketChannel) key.channel()).accept();
                channel.configureBlocking(false);
                channel.register(selector, SelectionKey.OP_READ);

                System.out.println("与【" + channel.getRemoteAddress() + "】建立了连接!");

            } else if (key.isReadable()) { // 有数据可以读取
                buffer.clear();

                // 读取到流末尾说明TCP连接已断开,
                // 因此需要关闭通道或者取消监听READ事件
                // 否则会无限循环
                if (((SocketChannel) key.channel()).read(buffer) == -1) {
                    key.channel().close();
                    continue;
                } 

                // 按字节遍历数据
                buffer.flip();
                while (buffer.hasRemaining()) {
                    byte b = buffer.get();

                    if (b == 0) { // 客户端消息末尾的\0
                        System.out.println();

                        // 响应客户端
                        buffer.clear();
                        buffer.put("Hello, Client!\0".getBytes());
                        buffer.flip();
                        while (buffer.hasRemaining()) {
                            ((SocketChannel) key.channel()).write(buffer);
                        }
                    } else {
                        System.out.print((char) b);
                    }
                }
            }

            // 已经处理的事件一定要手动移除
            keyIter.remove();
        }
    }
}
}</selectionkey>

Client

这个客户端纯粹测试用,为了看起来不那么费劲,就用传统的写法了,代码很简短。

要严谨一点测试的话,应该并发运行大量Client,统计服务端的响应时间,而且连接建立后不要立刻发送数据,这样才能发挥出服务端非阻塞I/O的优势。

public class Client {

public static void main(String[] args) throws Exception {
    Socket socket = new Socket("localhost", 9999);
    InputStream is = socket.getInputStream();
    OutputStream os = socket.getOutputStream();

    // 先向服务端发送数据
    os.write("Hello, Server!\0".getBytes());

    // 读取服务端发来的数据
    int b;
    while ((b = is.read()) != 0) {
        System.out.print((char) b);
    }
    System.out.println();

    socket.close();
}
}

NIO vs IO

学习了NIO之后我们都会有这样一个疑问:到底什么时候该用NIO,什么时候该用传统的I/O呢?

其实了解他们的特性后,答案还是比较明确的,NIO擅长1个线程管理多条连接,节约系统资源,但是如果每条连接要传输的数据量很大的话,因为是同步I/O,会导致整体的响应速度很慢;而传统I/O为每一条连接创建一个线程,能充分利用处理器并行处理的能力,但是如果连接数量太多,内存资源会很紧张。

总结就是:连接数多数据量小用NIO,连接数少用I/O(写起来也简单- -)。

Next

经过NIO核心组件的学习,了解了非阻塞服务端实现的基本方法。然而,细心的你们肯定也发现了,上面那个完整的例子,实际上就隐藏了很多问题。比如,例子中只是简单的将读取到的每个字节输出,实际环境中肯定是要读取到完整的消息后才能进行下一步处理,由于NIO的非阻塞特性,一次可能只读取到消息的一部分,这已经很糟糕了,如果同一条连接会连续发来多条消息,那不仅要对消息进行拼接,还需要切割,同理,例子中给客户端响应的时候,用了个while()循环,保证数据全部write完成再做其它工作,实际应用中为了性能,肯定不会这么写。另外,为了充分利用现代处理器多核心并行处理的能力,应该用一个线程组来管理这些连接的事件。

要解决这些问题,需要一个严谨而繁琐的设计,不过幸运的是,我们有开源的框架可用,那就是优雅而强大的Netty,Netty基于Java NIO,提供异步调用接口,开发高性能服务器的一个很好的选择,之前在项目中使用过,但没有深入学习,打算下一步好好学学它,到时候再写一篇笔记。

Java NIO 디자인의 목표는 프로그래머에게 최신 운영 체제의 최신 I/O 메커니즘을 즐길 수 있는 API를 제공하는 것이므로 이 기사에 언급된 구성 요소 및 기능 외에도 다양한 적용 범위가 있습니다. Pipe, Path, Files 등과 같은 일부는 I/O 성능을 향상시키는 데 사용되는 새로운 구성 요소이고 일부는 I/O 작업을 단순화하는 도구입니다. 구체적인 사용법은 마지막에 있는 참고 자료의 링크를 참조하세요.


위 내용은 Java NIO 핵심 구성요소에 대한 심층적인 이해의 상세 내용입니다. 자세한 내용은 PHP 중국어 웹사이트의 기타 관련 기사를 참조하세요!

성명:
이 기사는 segmentfault.com에서 복제됩니다. 침해가 있는 경우 admin@php.cn으로 문의하시기 바랍니다. 삭제