一文理解:Java NIO 核心组件

背景知识

同步、异步、阻塞、非阻塞

首先,这几个概念很是容易搞混淆,但NIO中又有涉及,因此总结一下。html

  • 同步:API调用返回时调用者就知道操做的结果如何了(实际读取/写入了多少字节)。
  • 异步:相对于同步,API调用返回时调用者不知道操做的结果,后面才会回调通知结果。
  • 阻塞:当无数据可读,或者不能写入全部数据时,挂起当前线程等待。
  • 非阻塞:读取时,能够读多少数据就读多少而后返回,写入时,能够写入多少数据就写入多少而后返回。

对于I/O操做,根据Oracle官网的文档,同步异步的划分标准是“调用者是否须要等待I/O操做完成”,这个“等待I/O操做完成”的意思不是指必定要读取到数据或者说写入全部数据,而是指真正进行I/O操做时,好比数据在TCP/IP协议栈缓冲区和JVM缓冲区之间传输的这段时间,调用者是否要等待。java

因此,咱们经常使用的 read() 和 write() 方法都是同步I/O,同步I/O又分为阻塞和非阻塞两种模式,若是是非阻塞模式,检测到无数据可读时,直接就返回了,并无真正执行I/O操做。程序员

总结就是,Java中实际上只有 同步阻塞I/O、同步非阻塞I/O 与 异步I/O 三种机制,咱们下文所说的是前两种,JDK 1.7才开始引入异步 I/O,那称之为NIO.2。编程

传统IO

咱们知道,一个新技术的出现老是伴随着改进和提高,Java NIO的出现亦如此。api

传统 I/O 是阻塞式I/O,主要问题是系统资源的浪费。好比咱们为了读取一个TCP链接的数据,调用 InputStream 的 read() 方法,这会使当前线程被挂起,直到有数据到达才被唤醒,那该线程在数据到达这段时间内,占用着内存资源(存储线程栈)却无所做为,也就是俗话说的占着茅坑不拉屎,为了读取其余链接的数据,咱们不得不启动另外的线程。在并发链接数量很少的时候,这可能没什么问题,然而当链接数量达到必定规模,内存资源会被大量线程消耗殆尽。另外一方面,线程切换须要更改处理器的状态,好比程序计数器、寄存器的值,所以很是频繁的在大量线程之间切换,一样是一种资源浪费。数组

随着技术的发展,现代操做系统提供了新的I/O机制,能够避免这种资源浪费。基于此,诞生了Java NIO,NIO的表明性特征就是非阻塞I/O。紧接着咱们发现,简单的使用非阻塞I/O并不能解决问题,由于在非阻塞模式下,read()方法在没有读取到数据时就会当即返回,不知道数据什么时候到达的咱们,只能不停的调用read()方法进行重试,这显然太浪费CPU资源了,从下文能够知道,Selector组件正是为解决此问题而生。服务器

Java NIO 核心组件

1.Channel

概念网络

Java NIO中的全部I/O操做都基于Channel对象,就像流操做都要基于Stream对象同样,所以颇有必要先了解Channel是什么。如下内容摘自JDK 1.8的文档多线程

A channel represents an open connection to an entity such as a hardware device, a file, a network socket, or a program component that is capable of performing one or more distinct I/O operations, for example reading or writing.

从上述内容可知,一个Channel(通道)表明和某一实体的链接,这个实体能够是文件、网络套接字等。也就是说,通道是Java NIO提供的一座桥梁,用于咱们的程序和操做系统底层I/O服务进行交互。架构

通道是一种很基本很抽象的描述,和不一样的I/O服务交互,执行不一样的I/O操做,实现不同,所以具体的有FileChannel、SocketChannel等。加群895244712,免费获取Java架构师进阶学习资料

通道使用起来跟Stream比较像,能够读取数据到Buffer中,也能够把Buffer中的数据写入通道。

固然,也有区别,主要体如今以下两点:

  • 一个通道,既能够读又能够写,而一个Stream是单向的(因此分 InputStream 和 OutputStream)
  • 通道有非阻塞I/O模式

实现

Java NIO中最经常使用的通道实现是以下几个,能够看出跟传统的 I/O 操做类是一一对应的。

  • FileChannel:读写文件
  • DatagramChannel: UDP协议网络通讯
  • SocketChannel:TCP协议网络通讯
  • ServerSocketChannel:监听TCP链接

2.Buffer

NIO中所使用的缓冲区不是一个简单的byte数组,而是封装过的Buffer类,经过它提供的API,咱们能够灵活的操纵数据,下面细细道来。

与Java基本类型相对应,NIO提供了多种 Buffer 类型,如ByteBuffer、CharBuffer、IntBuffer等,区别就是读写缓冲区时的单位长度不同(以对应类型的变量为单位进行读写)。

Buffer中有3个很重要的变量,它们是理解Buffer工做机制的关键,分别是

  • capacity (总容量)
  • position (指针当前位置)
  • limit (读/写边界位置)

Buffer的工做方式跟C语言里的字符数组很是的像,类比一下,capacity就是数组的总长度,position就是咱们读/写字符的下标变量,limit就是结束符的位置。Buffer初始时3个变量的状况以下图

在对Buffer进行读/写的过程当中,position会日后移动,而 limit 就是 position 移动的边界。由此不难想象,在对Buffer进行写入操做时,limit应当设置为capacity的大小,而对Buffer进行读取操做时,limit应当设置为数据的实际结束位置。(注意:将Buffer数据 写入 通道是Buffer 读取 操做,从通道 读取 数据到Buffer是Buffer 写入 操做)

在对Buffer进行读/写操做前,咱们能够调用Buffer类提供的一些辅助方法来正确设置 position 和 limit 的值,主要有以下几个

  • flip(): 设置 limit 为 position 的值,而后 position 置为0。对Buffer进行读取操做前调用。
  • rewind(): 仅仅将 position 置0。通常是在从新读取Buffer数据前调用,好比要读取同一个Buffer的数据写入多个通道时会用到。
  • clear(): 回到初始状态,即 limit 等于 capacity,position 置0。从新对Buffer进行写入操做前调用。
  • compact(): 将未读取完的数据(position 与 limit 之间的数据)移动到缓冲区开头,并将 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(); // 数据可能有剩余
    }
    加群895244712,免费获取Java架构师进阶学习资料
    channel.close();

这个例子中使用了两个Buffer,其中 byteBuffer 做为通道读写的数据缓冲区,charBuffer 用于存储解码后的字符。clear() 和 flip() 的用法正如上文所述,须要注意的是最后那个 compact() 方法,即便 charBuffer 的大小彻底足以容纳 byteBuffer 解码后的数据,这个 compact() 也必不可少,这是由于经常使用中文字符的UTF-8编码占3个字节,所以有很大几率出如今中间截断的状况,请看下图:

当 Decoder 读取到缓冲区末尾的 0xe4 时,没法将其映射到一个 Unicode,decode()方法第三个参数 false 的做用就是让 Decoder 把没法映射的字节及其后面的数据都视做附加数据,所以 decode() 方法会在此处中止,而且 position 会回退到 0xe4 的位置。如此一来, 缓冲区中就遗留了“中”字编码的第一个字节,必须将其 compact 到前面,以正确的和后序数据拼接起来。

BTW,例子中的 CharsetDecoder 也是 Java NIO 的一个新特性,因此你们应该发现了一点哈,NIO的操做是面向缓冲区的(传统I/O是面向流的)。

至此,咱们了解了 Channel 与 Buffer 的基本用法。接下来要说的是让一个线程管理多个Channel的重要组件。

3.Selector

Selector 是什么

Selector(选择器)是一个特殊的组件,用于采集各个通道的状态(或者说事件)。咱们先将通道注册到选择器,并设置好关心的事件,而后就能够经过调用select()方法,静静地等待事件发生。

通道有以下4个事件可供咱们监听:

  • Accept:有能够接受的链接
  • Connect:链接成功
  • Read:有数据可读
  • Write:能够写入数据了

为何要用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

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

能够发现,它们值为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();
            }
        }
    }
}

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为每一条链接建立一个线程,能充分利用处理器并行处理的能力,可是若是链接数量太多,内存资源会很紧张。加群895244712,免费获取Java架构师进阶学习资料

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

Next

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

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

Java NIO设计的目标是为程序员提供API以享受现代操做系统最新的I/O机制,因此覆盖面较广,除了文中所涉及的组件与特性,还有不少其它的,好比 Pipe(管道)、Path(路径)、Files(文件) 等,有的是用于提高I/O性能的新组件,有的是简化I/O操做的工具,具体用法能够参看最后 References 里的连接。

References

[1] Differences Between Synchronous and Asynchronous I/O

[2] Java NIO - Wikipedia)

[3] Java NIO Tutorial

[4] Package java.nio

相关文章
相关标签/搜索