Java NIO

Java NIO

IO、NIO、NIO.2

IO/BIO:即java.io.*,面向流、阻塞IO,逐个字节的读取/写入,没有相应的缓冲区(除了Buffered),因此效率较低;
NIO:即java.nio.*,面向缓冲区、阻塞/非阻塞IO(可配置),主要组件:Buffer缓冲器、Channel通道、Selector选择器;
AIO/NIO.2:即java.nio.channels.Asynchronous*,异步IO、阻塞IO、由ThreadPool线程池实现,每个异步IO Channel都属于某个AsynchronousChannelGroup,而每个AsynchronousChannelGroup都与一个ThreadPool相关联。

Linux 中的 5 种 IO Model

  • 同步IO(synchronous IO)
    • 阻塞IO(bloking IO)
    • 非阻塞IO(non-blocking IO)
    • 多路复用IO(multiplexing IO)
    • 信号驱动式IO(signal-driven IO)
  • 异步IO(asynchronous IO)

其中,由于信号驱动式IO不太常用,因此下面我们只讨论其他 4 种 IO 模型

IO 操作可以简单的分为这三类:
1) 内存IO,即byte[]char[]数组,不涉及与外部IO设备的交互;
2) 文件IO,即FileInputStreamFileOutputStreamRandomAccessFile,主要与本地磁盘进行交互;
3) 套接字IO,即ServerSocketSocketDatagramSocket,对于Linux来说,套接字也是一种文件,这里不深究。

内存IO,本质就是内存的拷贝,效率非常高,没有所谓的阻塞、非阻塞之分;
文件IO,在java.nio.channels.FileChannel实现中,只能运行在阻塞模式,因此不进行讨论;
套接字IO,Java NIO 的主要研究对象,有阻塞模式、非阻塞模式,默认为阻塞模式。

对于 Socket 的两个基本操作recv()send(),一个线程/进程调用它们主要经历两个阶段,以recv()为例:
1) 等待数据准备,通常涉及等待网络上的数据分组到达,然后被复制到内核的某个缓冲区(Socket缓冲区);
2) 将数据从内核空间拷贝至进程空间,把接收到的数据从内核缓冲区复制(准确说是剪切)到应用进程的缓冲区(字节数组);

了解这些细节之后,我们来逐个的分析这 4 种 IO 模型:
同步阻塞IO
当 socket 接收缓冲区中没有数据时,当前进程会被 recv 阻塞,一直会等待数据的到来;
当 socket 接收缓冲区中有数据时,recv 将数据拷贝到进程空间的这个过程,也是阻塞的;

对于同步阻塞IO来说,这两个阶段都被阻塞了;

同步非阻塞IO
当 socket 接收缓冲区中没有数据时,recv 会立即返回一个特定状态值,表示现在没有数据,等下再来吧;
当 socket 接收缓冲区中有数据时,recv 将数据拷贝到进程空间的这个过程,也是阻塞的;

对于非阻塞IO,通常采取轮询 polling 的方式,循环往复的主动询问内核,当前时候有数据可以读取了。实际上,这样的轮询还不如阻塞IO的性能好。

对于同步非阻塞IO来说,只有第一个阶段未被阻塞,第二个阶段依旧是阻塞的;

多路复用IO
和非阻塞IO很相似,但是它的优点是可以借助几个特殊的系统调用(selectpollepoll),来同时轮询多个 socket 连接:

当调用select、poll、epoll时,如果所监控的socket中有部分socket可读、可写或其他事件发生时,就会返回,将其交给用户进程来处理,这个过程是阻塞的,只不过是因为select、poll、epoll系统调用而阻塞的;
当调用返回后,用户进程再调用recv,将数据从内核拷贝到进程空间中,这个过程也是阻塞的,因recv拷贝数据而阻塞;

实际上这种方式相比第二种还差一些,因为这里面包含了两个系统调用(select/poll/epoll、recv),而第二种只有一个系统调用recv;
但是,IO多路复用的优点是可以处理更多的连接,当连接数大的时候,缺点就被优点给掩盖了。

IO多路复用相比多进程/多线程 + 阻塞IO的系统开销小,因为系统不需要创建新的进程或线程,也不需要维护多个进程、线程的执行;

对于多路复用IO来说,第一个阶段是因为select、poll、epoll而阻塞的,第二个阶段(实际IO操作)依旧是阻塞的;

异步IO
前面的几种IO模型都是同步的,因为所有的实际IO操作(将数据从内核拷贝到进程空间的这个过程)都是阻塞的;
所谓同步IO,就是必须等待当前的IO操作完成之后,才能执行后面的指令,这个等待的过程中是不能进行其他操作的,也就是说,指令序列都是线性执行的;
而异步IO,进程/线程并不会因为这个IO操作而阻塞,可以立即执行别的操作,当IO操作完成后,进程会收到完成的通知(回调函数、信号等等);

对于异步IO来说,两个阶段都没有被阻塞,因为我仅仅需要发起recv、send请求,具体怎么搞那是别人的事,我可以继续忙我的事情,等你干完了告诉我一声就行。

多线程和异步操作的异同
相同点:多线程和异步操作两者都可以达到避免调用线程阻塞的目的,从而提高软件的可响应性。
不同点:多线程是实现异步的一个重要手段,但不是唯一手段,对于一个单线程程序也可以是异步执行的。

异步操作的本质
所有的程序最终都会由计算机硬件来执行,所以为了更好的理解异步操作的本质,我们有必要了解一下它的硬件基础。

熟悉电脑硬件的朋友肯定对 DMA 这个词不陌生,硬盘、光驱的技术规格中都有明确 DMA 的模式指标,其实网卡、声卡、显卡也是有 DMA 功能的。
DMA 就是直接内存访问的意思,也就是说,拥有 DMA 功能的硬件在和内存进行数据交换的时候可以不消耗 CPU 资源。只要 CPU 在发起数据传输时发送一个指令,硬件就开始自己和内存交换数据,在传输完成之后硬件会触发一个中断来通知操作完成。

这些无须消耗 CPU 时间的 I/O 操作正是异步操作的硬件基础。所以即使在 DOS 这样的单进程(而且无线程概念)系统中也同样可以发起异步的 DMA 操作。

线程的本质
线程不是一个计算机硬件的功能,而是操作系统提供的一种逻辑功能,线程本质上是进程中一段并发运行的代码,所以线程需要操作系统投入 CPU 资源来运行和调度。

而 Java7 新引入的 AIO、NIO.2,其实就是通过多线程来实现的,但是不是简单的每个IO都创建一个线程,而是用ThreadPool线程池进行管理。

BIO、NIO、AIO 适用场景分析:
1) BIO方式适用于连接数目比较小且固定的架构,这种方式对服务器资源要求比较高,并发局限于应用中,JDK1.4 以前的唯一选择,但程序直观简单易理解。
2) NIO方式适用于连接数目多且连接比较短(轻操作)的架构,比如聊天服务器,并发局限于应用中,编程比较复杂,JDK1.4 开始支持。
3) AIO方式适用于连接数目多且连接比较长(重操作)的架构,比如相册服务器,充分调用 OS 参与并发操作,编程比较复杂,JDK1.7 开始支持。

另外,I/O 属于底层操作,需要操作系统支持,并发也需要操作系统的支持,所以性能方面不同操作系统差异会比较明显。

NIO的几个主要概念

  • Channel通道,相比之前的”流”的概念,是差不多的;但是有一个很大的区别就是:Channel 是双向的,Stream 是单向的;
  • Buffer缓冲器,就是数组的包装而已,我们可以直接把它看成一个数组,Buffer 和 Channel 通常需要一起使用;
  • Selector选择器,select/poll/epoll 系统调用,用于 Socket Channel,因为它要求 Channel 必须是非阻塞的;

BIO 都是没有缓冲区的,包括 RandomAccessFile 也没有缓冲区,除了 Buffered 装饰流;
NIO 都是具有缓冲区的,必须和 Buffer 对象一起使用,Buffer 对象就是缓冲区;

ByteBuffer是唯一直接与 Channel 交互的缓冲器,因为所有的数据都是以二进制形式存在的。

在 BIO 中,与文件 IO 相关联的三个类是:
1) java.io.FileInputStream,从文件流中读取字节数据,只读;
2) java.io.FileOutputStream,向文件流中写入字节数据,只写;
3) java.io.RandomAccessFile,独立的IO流类,支持r只读、rw读写、rws读写 + metadata/data自动同步、rwd读写 + data自动同步四种模式;

为了迎合 NIO,这几个类都进行修改,加入了getChannel()方法,获取对应的FileChannel对象,提供高效率的 I/O 操作;
但是 NIO 并未给 FileChannel 提供非阻塞支持,FileChannel 只能运行在阻塞模式下,但是效率依旧比 BIO 高,因为有 Buffer 啊;

java.net包中的三个 Socket 类:
1) java.net.ServerSocket:TCP Socket,Server端;
2) java.net.Socket:TCP Socket;
3) java.net.DatagramSocket:UDP Socket;

这几个类其实也定义了一个getChannel()方法,但是默认只能返回null,需要显式的 open() 对应的 SocketChannel 通道。

Channel通道的主要实现:
1) java.nio.channels.Channel,所有 Channel 通道的父接口,定义了两个方法:isOpen()close()
2) java.nio.channels.FileChannel,可以通过open()方法获得、或者通过FileInputStreamFileOutputStreamRandomAccessFilegetChannel()方法获得,只能为阻塞模式;
3) java.nio.channels.ServerSocketChannel:通过open()方法获得,可以自己配置为阻塞模式、非阻塞模式;
4) java.nio.channels.SocketChannel:通过open()方法获得,可以自己配置为阻塞模式、非阻塞模式;
5) java.nio.channels.DatagramChannel:通过open()方法获得,可以自己配置为阻塞模式、非阻塞模式;

Buffer缓冲器的主要实现:
1) Buffer:缓冲器的公共父类;
2) ByteBuffer:byte数组,Heap内存、Direct内存两种;
3) MappedByteBuffer:内存映射文件(mmap()),适合读写大文件;
4) CharBuffer:char数组;
5) ShortBuffer:short数组;
6) IntBuffer:int数组;
7) LongBuffer:long数组;
8) FloatBuffer:float数组;
9) DoubleBuffer:double数组;

Selector选择器:
1) SelectionKey:选择键,可以理解为 epoll 中的一个 event 事件,定义了OP_READOP_WRITEOP_ACCEPTOP_CONNECT四个常量;
2) Selector:选择器,select、poll、epoll的包装,和 SelectionKey 一起使用。

如果需要使用 Selector,必须将 Channel 配置为非阻塞模式,因此 FileChannel 不能用于 Selector。

Scattering、Gathering,分散读取、聚集写入
1) 分散读取(Scattering Reads)是指从 Channel 中读取的数据“分散”到多个 Buffer 中,如下图:
分散读取
2) 聚集写入(Gathering Writes)是指将多个 Buffer 中的数据“聚集”写入到 Channel 中,如下图:
聚集写入

其实就是一个概念而已,从 Buffer[] 数组中依次读取、依次写入。

Buffer缓冲器

Buffer 缓冲器主要在java.nio包中,是几个基本类型数组的包装,但是比直接使用数组更方便,有效率;

Buffer

ByteBuffer

CharBuffer

ShortBuffer

IntBuffer

LongBuffer

FloatBuffer

DoubleBuffer

MappedByteBuffer

ByteBuffer 的子类,使用内存映射文件,效率比read()、write()高
非常适合读写大文件,MappedByteBuffer 对象通过 FileChannel.map() 方法获得

内存映射文件:将虚拟内存地址和物理设备文件内容建立一一映射关系的一种内存映射技术。
而访问这段已映射的内存就像直接访问物理设备上文件的内容一样,省去了read()、write()系统调用,减少了不必要的内存拷贝,效率较高。

但是并不是说内存映射文件就一定效率高,这毕竟是一个重量级系统调用,因此,仅适合于大文件的读写,可显著提高IO效率。

Channel通道

java.nio.channels包,主要有:FileChannelServerSocketChannelSocketChannelDatagramChannel

FileChannel

FileChannel.open()方法涉及 Java7 引入的java.nio.file包的相关类,如需了解,请跳转至Java7 新的文件API

FileLock

java.nio.channels.FileLock,文件锁

例1,通过 BIO 的 getChannel() 方法获取 FileChannel:

例2,使用 open() 方法打开一个 FileChannel 通道:

例3,文件拷贝,使用 transferFrom()、transferTo() 方法,非常简单:

例4,ByteBuffer 提供的各基本类型的 view 视图:

例5,字符编码问题,ByteBuffer、CharBuffer:

例6,ByteOrder 字节序,Big-Endian大端字节序、Little-Endian小端字节序:

例7,MappedByteBuffer,读写效率对比:

SelectableChannel

可选择(用于Selector)的通道,主要子类:ServerSocketChannelSocketChannelDatagramChannel

StandardSocketOptions

java.net.StandardSocketOptions,提供了一些标准的 Socket 选项;

ServerSocketChannel

SocketChannel

例子:
Server.java

Client.java

运行结果

DatagramChannel

例子:
Server.java

Client.java

运行结果

AsynchronousChannel

java.nio.channels.AsynchronousChannel,异步 Channel 的父接口

AsynchronousChannelGroup

java.nio.channels.AsynchronousChannelGroup,异步 Channel 组,每个 ChannelGroup 都和一个 ThreadPool 相关联,如果不明确指明使用哪个 ChannelGroup,则自动归入 Default ChannelGroup。

AsynchronousFileChannel

从 API 中可以看出,处理异步 IO 主要有两种方式:
1) java.util.concurrent.Future,一个 Future 表示一个异步执行结果,可以调用get()方法获取执行结果,注意此方法是阻塞的。
2) java.nio.channels.CompletionHandler,一个回调函数,在异步操作完成之后将自动调用其 completed()、failed() 方法(根据是否操作成功来判断),我们来看一下该接口的定义:

例子:

AsynchronousServerSocketChannel

AsynchronousSocketChannel

Selector选择器

SelectionKey

Selector

例子:
Server.java

Client.java

运行结果

Charset字符集

java.nio.charset包,主要方法:Charset.forName("UTF-8")
Charset.encode():CharBuffer -> ByteBuffer
Charset.decode():ByteBuffer -> CharBuffer

StandardCharsets标准字符集

ByteOrder字节序

java.nio.ByteOrder类,定义了字节序,Big-Endian、Little-Endian

新的文件API

java.nio.file包,新的文件API,基本可以取代java.io.File类;

Path

这是一个接口,定义了 Path(文件路径)的相关操作方法:

DirectoryStream

目录流,用于 for-each 遍历目录内容,只有一个迭代器,同时它还定义了一个 Filter 子接口:

StandardOpenOption

枚举类,定义了几个标准打开选项(文件)

StandardCopyOption

枚举类,定义了拷贝文件时的相关处理选项:

AccessMode

枚举类,定义了 Posix 中的三个文件权限、rwx

LinkOption

枚举类,定义了如何处理符号链接文件的相关选项

Paths

工具类,提供了 get() 方法,用于获取 Path 对象

FileSystem

抽象类,定义了与文件系统相关的操作方法

FileSystems

工具类,提供了 get() 方法,用于获取 FileSystem 对象

FileStore

磁盘分区、卷的抽象,如:C盘、D盘、新加卷等

Files

工具类,用于文件操作