Java中的IO与NIO
前文开了高并发学习的头,文末说了将会选择NIO、RPC相关资料做进一步学习,所以本文开始学习NIO知识。
IO知识回顾
在学习NIO前,有必要先回顾一下IO的一些知识。
IO中的流
Java程序通过流(Stream)来完成输入输出。流是生产或者消费信息的抽象,流通过Java的输入输出与物理设备连接,尽管与之相连的物理设备不尽相同,但是所有的流的行为都是一样的,所以相同的输入输出类的功能和方法适用于所有的外部设备。这意味着一个输入流可以抽象多种类型的输入,比如文件、键盘或者网络套接字等,同样的,一个输出流也可以输出到控制台、文件或者相连的网络。
流的分类
从功能上可以将流分为输入流和输出流。输入和输出是相对于程序来说的,程序在使用数据时所扮演的角色有两个:一个是源,一个是目的。若程序是数据的源,对外输出数据,我们就称这个数据流相对于程序来说是输出流,若程序是数据的目的地,我们就称这个数据流相对于程序来说是输入流。
从结构上可以将流分为字节流和字符流,字节流以字节为处理单位,字符流以字符为处理单位。
从角色上可以将流分为节点流和过滤流。从特定的地方读写的流叫做节点流,如磁盘或者一块内存区域,而过滤流则以节点流作为输入或者输出,过滤流是使用一个已经存在的输入流或者输出流连接来创建的。
字节流的输入流和输出流的基础是InputStream和OutputStream,字节流的输入输出操作由这两个类的子类实现。字符流是Java1.1后新增的以字符为单位进行输入和输出的流,字符流的输入输出的基础是Reader和Writer。
字节流(byte stream)为处理字节的输入和输出提供了方便的方法,比如使用字节流读取或者写入二进制的数据。字符流(character stream)为字符的输入和输出提供了方便,采用了统一的编码标准,因而可以国际化。值得注意的,在计算机的底层实现中,所有的输入输出其实都是以字节的形式进行的,字符流只是为字符的处理提供了具体的方法。
输入流
读数据的逻辑为:
open a stream
while more information
read information
close the stream
忽略掉异常处理,相关的代码实现大致如下:
InputStream input = new FileInputStream("c:\\data\\input-text.txt"); int data = input.read(); while(data != -1) { //do something with data... doSomethingWithData(data); data = input.read(); } input.close();
输出流
写数据的逻辑为:
open a stream
while more information
write information
close the stream
忽略掉异常处理,相关的代码实现大致如下:
OutputStream output = new FileOutputStream("c:\\data\\output-text.txt"); while(hasMoreData()) { int data = getMoreData(); output.write(data); } output.close();
输入流的类层次
输出流的类层次
过滤流
在InputStream、OutputStream类的子类中,FilterInputStream和FilterOutputStream过滤流又派生出DataInputStream和DataOutputStream数据输入输出流等子类。
IO流的链接
Input Stream Chain:从外部文件往程序中写入数据,所以第一步需要构造输入流,同时也是节点流,FileInputStream,为了使这个流具备缓冲的特性,需要从节点流转成过滤流,BufferedInputStream,仅仅有缓冲特性可能还不能满足日常需求,还需要有读取基本数据类型的特性,可以基于现有的过滤流再转成其他的过滤流,DataInputStream,此时便可以方便的从文件中读取数据;
Output Stream Chain:往外部文件中写出数据,首先对于外部文件来说,是FileOutputStream,为了使这个流具备缓冲的特性,需要从节点流转成过滤流,BufferedOutputStream,仅仅有缓冲特性可能还不能满足日常需求,还需要有写出基本数据类型的特性,可以基于现有的过滤流再转成其他的过滤流,DataOutputStream,此时便可以方便的从写各种数据类型;
Reader的类层次
Writer的类层次
至此,大概回顾了一下IO的部分基础知识。
IO与装饰模式
回到IO流的链接中,Input Stream Chain的一般代码是这样写的:
InputStream input = new DataInputStream(new BufferedInputStream(new FileInputStream("c:\\data\\input-text.txt")))
Output Stream Chain的一般代码是这样写的:
OutputStream output = new DataOutputStream(new BufferedOutputStream(new FileOutputStream("c:\\data\\output-text.txt")))
实际上,这种一个流与另一个流首尾相接,形成一个流管道的实现机制,其实就是装饰模式的一种应用。
装饰模式的套路
抽象构件角色(Component):给出一个抽象接口,以规范准备接收附加责任的对象
具体构件角色(ConcreteComponent):定义一个将要接收附加责任的类
装饰角色(Decorator):持有一个构件(Component)对象引用,并定义一个与抽象构建接口一致的接口
具体装饰角色(ConcreteDecorator):负责给构件对象贴上附加的责任
装饰模式的代码实现
让我们来看下代码:
public interface Component { void doSomething(); } public class ConcreteComponent implements Component{ @Override public void doSomething() { System.out.println("功能A"); } } public class Decorator implements Component{//重点1 定义抽象构件接口一致的接口 private Component component;//重点2 持有构件对象的引用 public Decorator(Component component) { this.component = component; } @Override public void doSomething() { component.doSomething(); } }
最后是具体装饰角色代码:
public class ConcreteDecorator1 extends Decorator{ public ConcreteDecorator1(Component component) { super(component); } @Override public void doSomething() { super.doSomething(); this.doAnotherThing(); } public void doAnotherThing() { System.out.println("功能B"); } } public class ConcreteDecorator2 extends Decorator{ public ConcreteDecorator2(Component component) { super(component); } @Override public void doSomething() { super.doSomething(); this.doAnotherThing(); } public void doAnotherThing() { System.out.println("功能C"); } }
对于客户端来说只需要如下简单的代码,即可完成对构件对象ConcreteComponent的装饰:
Component component = new ConcreteDecorator1(new ConcreteDecorator2(new ConcreteComponent())); component.doSomething();
IO中对应的装饰模式解释
DataInputStream、BufferedInputStream的角色就像上述的ConcreteDecorator1和ConcreteDecorator2,FilterInputStream类似Decorator,InputStream就是Component。
在JDK的源码中:
public class FilterInputStream extends InputStream { protected volatile InputStream in; protected FilterInputStream(InputStream in) { this.in = in;} public int read() throws IOException { return in.read();}
再看下Decorator:
public class Decorator implements Component{//重点1 定义抽象构件接口一致的接口 private Component component;//重点2 持有构件对象的引用 public Decorator(Component component) { this.component = component; } @Override public void doSomething() { component.doSomething(); } }
至此,我们可以知道IO是如何体现的装饰模式。
为什么需要NIO
IO主要面向流数据,经常为了处理个别字节或字符,就要执行好几个对象层的方法调用。这种面向对象的处理方法,将不同的 IO 对象组合到一起,提供了高度的灵活性(IO里面的装饰模式),但需要处理大量数据时,却可能对执行效率造成致命伤害。IO的终极目标是效率,而高效的IO往往又无法与对象形成一一对应的关系。高效的 IO 往往意味着您要选择从A到B的最短路径,而执行大量IO 操作时,复杂性毁了执行效率。传统Java平台上的IO抽象工作良好,适应用途广泛。但是当移动大量数据时,这些IO类可伸缩性不强,也没有提供当今大多数操作系统普遍具备的常用IO功能,如文件锁定、非块IO、就绪性选择和内存映射。这些特性对实现可伸缩性是至关重要的,对保持与非 Java 应用程序的正常交互也可以说是必不可少的,尤其是在企业应用层面,而传统的Java IO 机制却没有模拟这些通用IO服务。Java规范请求#51(JSR 51, https://jcp.org/en/jsr/detail?id=51)包含了对高速、可伸缩 I/O 特性的详尽描述,借助这一特性,底层操作系统的IO性能可以得到更好发挥。 JSR 51 的实现,其结果就是新增类组合到一起,构成了 java.nio 及其子包,以及 java.util.regex 软件包,同时现存软件包也相应作了几处修改。JCP 网站详细介绍了 JSR 的运作流程,以及 NIO 从最初的提议到最终实现并发布的演进历程。随着 Merlin(Jdk1.4) 的发布,操作系统强大的IO特性终于可以借助 Java 提供的工具得到充分发挥。论及IO性能,Java再也不逊于任何一款编程语言。
到这里,我们知道Java NIO的目的是为了提高效率,充分利用操作系统提供的IO特性,所以为了应对更多的处理请求,我们需要新的IO模型(NIO)。
NIO的核心组件
这一节,我们将开始学习NIO。
上文中曾提到,NIO的三个核心组件:Selector、Channel和Buffer。用一张图来抽象这三者之间的关系。
在没有Java NIO之前,传统的IO对于网络连接的处理,通常会采用Thread Per Task,即一线程一连接的模式,这种模式在中小量业务处理时基本上是能满足要求的,但是随着连接数不断增加,所创建的线程会不断占用内存空间,同时大量的线程也会带来频繁的上下文切换,CPU都用来操作上下文切换了,必然影响实际的业务处理。有了Java NIO之后,就可以花少量的线程来处理大量的连接,在上图中,Selector是对Linux下的select/poll/epoll的包装,Channel是可IO操作的硬件、文件、socket、其他程序组件的包装,我们可以把Channel看成是网络连接,网络连接上主要有connect、accept、read和write等事件,Selector负责监听这些事件的发生,一旦某个Channel上发生了某个事件,Thread切换到该Channel进行事件处理。类比到操作系统层面,如果是select/poll方式,应用进程对每个socket(Channel)的文件描述符顺序扫描,查看是否就绪,阻塞在select(Selector)上,如果就绪,调用recvfrom,如果是epoll方式,就不是顺序扫描,而是提供回调函数,当文件描述符就绪时,直接调用回调函数,进一步提高效率。图中还有一个组件就是Buffer,它实际上就是一块内存,底层是基于数组来实现的,一般和Channel成对出现,数据的读写都是通过Buffer来实现的。
接下来,依次来看下Selector、Channel和Buffer。
Selector模块
Selector
Selector是一个多路传输的SelectableChannel对象。
Selector可以通过调用自身的open方法来进行创建,在open方法里面是通过系统默认的selector provider来创建Selector的。当然也可以通过调用openSelector来自定义一个Selector。一个Selector将会一直保持open的状态直到调用close方法。
一个可选择的Channel对象注册到Selector的行为是通过SelectionKey对象来体现的。Selector维护了三种SelectionKey的集合:
key set包含了当前的注册到Selector的Channel对应的所有的keys,这些keys可以通过keys()返回;
selected-key set的每个成员都是相关的通道被选择器(在前一个选择操作
中)判断为已经准备好的,并且包含于键的 interest 集合中的操作。这个集合通过 selectedKeys()方法返回。selected-key set是key set的子集;
cancelled-key set是一个被取消的Channel但是还未被注销的key的集合,这个集合无法直接访问,cancelled-key set也是key set的子集。
对于一个刚创建的Selector,上述三个集合默认都是空的。
通过调用Channel的register方法,一个新的key将会被添加到Selector的key set中。在执行selection操作时,cancelled keys将会从key set中移除,key set本身是不能被直接修改的。
不管是直接关闭Channel,还是调用SelectionKey的close方法,都会有一个key被添加到cancelled-key set中。在下一个selection操作中,取消一个key的动作都会导致其对应的Channel被注销掉,同时这个key也会从Selector的key set中移除。
在执行selection操作时,keys会被添加到selected-key set中,通过Set的remove方法或者是Iterator的remove方法,key可以直接从selected-key set中移除,除此以外,没有其他的方法可以达到这样的效果。特别需要强调的是, 移除操作不是selection的副作用。key也不能直接添加到selected-key set中。
Selection
在每一次Selection操作中,keys有可能从selected-key set、key set或者cancelled-key set中增加或者删除。Selection操作是通过select()、select(long)、selectNow()方法来执行的,一般包含如下三步:
1、cancelled-key set中的每一个key都可以从它所属的key set中移除,同时它所属的Channel也会注销,这一步将会使cancelled-key set为空;
2、底层操作系统开始被查询以更新剩余的Channel通道的就绪状态来执行这个key感兴趣的事件,对于一个至少有一种这样操作的Channel来说,下面2个动作将会被执行:
2.1 如果Channel的key不在selected-key set中,然后key会被增加到selected-key set中,同时它的就绪操作会被修改出来准确的标记出哪一个Channel已完成准备工作,任意之前的ready set的就绪信息将会被丢弃;
2.2 如果Channel的key在selected-key set中,同时它的就绪操作会被修改出来准确的标记出哪一个Channel已完成准备工作,任意之前的ready set的就绪信息将会被保留,换句话来说,这个底层操作系统的ready set将会按位写入到当前的key的ready set。
如果所有的key set里面的key一开始就没有interest set,那么不管是selected-key set还是它对应的就绪操作都不会被更新。
3、如果执行步骤2时有新的key加入到cancelled-key,则按步骤1进行处理。
这三个方法的本质区别就是选择操作是否阻塞等待一个或多个通道准备就绪,或者等待了多长时间。
Concurrency
选择器本身可以安全地供多个并发线程使用。但是,它们的key set不是。
在执行选择操作时,选择器在 Selector 对象上进行同步,然后是key set,最后是selected-key set,按照这样的顺序。cancelled-key set也在选择过程的的第 1步和第 3 步之间保持同步。
在进行选择操作时,对选择器的interest sets所做的更改对该操作没有影响,他们将在下一个选择操作中看到。
选择器的一个或多个键集中的键的存在并不表示该键有效或其通道已打开。如果其他线程有可能取消键或关闭通道,则应用程序代码应谨慎同步并在必要时检查这些条件。
线程会阻塞在select()或者select(long)方法上,如果其他线程想中断这个阻塞,可以通过如下三种方式:
通过调用选择器的wakeup方法;
通过调用选择器的close方法;
通过调用被阻塞线程的interrupt方法,在这种情况下,将设置其中断状态,并调用选择器的wakeup方法。
close方法以与选择操作相同的顺序在选择器和所有三个键集上同步。
通常,选择器的key和selected-key不能安全地供多个并发线程使用。如果此类线程可以直接修改这些集合之一,则应通过在集合本身上进行同步来控制访问。 这些集合的迭代器方法返回的迭代器是快速失败的:如果在创建迭代器之后修改了集合,则除了通过调用迭代器自己的remove方法之外,其他任何方式都会抛出java.util.ConcurrentModificationException。
如下为Selector提供的所有方法:
SelectionKey
表示SelectableChannel向Selector的注册的令牌。
每次将Channel注册到选择器中时,都会创建一个选择键。这个键一直有效,直到通过调用其cancel方法,关闭其通道或关闭其选择器将其取消。取消键不会立即将其从选择器中删除,而是将其添加到选择器的“取消键”集合中,以便在下一次选择操作期间将其删除。可以通过调用isValid方法来测试这个键是否有效性。
选择键包含两个表示为整数值的操作集。操作集的每个位表示键的通道支持的可选操作的类别。
兴趣集确定下一次调用选择器的选择方法之一时,将测试那些操作类别是否准备就绪。使用创建键时给定的值来初始化兴趣集,以后可以通过interestOps(int)方法对其进行更改。
准备集标识键的选择器已检测到键的通道已准备就绪的操作类别。 创建key时,准备集将初始化为零。它可能稍后会在选择操作期间由选择器更新,但无法直接更新。
选择键的就绪集指示其通道已为某个操作类别做好了提示,但不是保证,此类类别中的操作可以由线程执行而不会导致线程阻塞。准备工作很可能在选择操作完成后立即准确。外部事件和在相应通道上调用的I/O操作可能会使它不准确。
此类定义了所有已知的操作集位,但是精确地给定通道支持哪些位取决于通道的类型。SelectableChannel的每个子类都定义一个validOps()方法,该方法返回一个集合,该集合仅标识通道支持的那些操作。尝试设置或测试键通道不支持的操作集位将导致run-time exception。
通常有必要将一些特定于应用程序的数据与选择键相关联,例如,一个对象代表一个更高级别协议的状态并处理就绪通知,以实现该协议。因此,选择键支持将单个任意对象附加到键上。可以通过attach方法附加对象,然后再通过attach方法检索对象。
选择键可安全用于多个并发线程。通常,读取和写入兴趣集的操作将与选择器的某些操作同步。确切地说,如何执行此同步取决于实现方式:在低性能的实现方式中,如果选择操作已在进行中,则兴趣组的读写可能会无限期地阻塞;在高性能实现中,读取或写入兴趣集可能会短暂阻塞(如果有的话)。无论如何,选择操作将始终使用该操作开始时当前的兴趣设置值。
如下为SelectionKey提供的所有方法:
Channel模块
Channel用来表示诸如硬件设备、文件、网络套接字或程序组件之类的实体的开放连接,该实体能够执行一个或多个不同的I/O操作(例如读取或写入)。I/O 可以分为广义的两大类别:File I/O和Stream I/O。那么相应地有两种类型的通道,它们是文件(file)通道和套接字(socket)通道。文件通道有一个FileChannel类,而套接字则有三个socket通道类:SocketChannel、 ServerSocketChannel和DatagramChannel。通道可以以阻塞(blocking)或非阻塞(nonblocking)模式运行。非阻塞模式的通道永远不会让调用的线程休眠。请求的操作要么立即完成,要么返回一个结果表明未进行任何操作。只有面向流的(stream-oriented)的通道,如 SocketChannel、ServerSocketChannel才能使用非阻塞模式。SocketChannel、ServerSocketChannel从 SelectableChannel引申而来。从 SelectableChannel 引申而来的类可以和支持有条件的选择(readiness selectio)的选择器(Selector)一起使用。将非阻塞I/O 和选择器组合起来就可以使用多路复用 I/O(multiplexed I/O),也就是前面提到的select/poll/epoll。由于FileChannel不是从SelectableChannel类引申而来,所以FileChannel,也就是文件IO,是无法使用非阻塞模型的。
FileChannel
用于读取、写入、映射和操作文件的通道。
文件通道是可以连接到文件的SeekableByteChannel。它在文件中具有当前位置,支持查询和修改。文件本身包含一个可变长度的字节序列,可以读取和写入这些字节,并且可以查询其当前大小。当写入字节超过当前大小时,文件大小会增加; 文件的大小在被截断时会减小。该文件可能还具有一些关联的元数据,例如访问权限、内容类型和最后修改时间等,此类未定义用于元数据访问的方法。
除了熟悉的字节读取、写入和关闭操作之外,此类还定义了以下特定于文件的操作:
可以以不影响通道当前位置的方式在文件中的绝对位置读取或写入字节;
文件的区域可以直接映射到内存中。对于大文件,这通常比调用传统的读取或写入方法要有效得多;
对文件所做的更新可能会被强制发送到基础存储设备,以确保在系统崩溃时不会丢失数据;
字节可以从文件传输到其他通道,反之亦然,可以通过操作系统进行优化,将字节快速传输到文件系统缓存或从文件系统缓存快速传输;
文件的区域可能被锁定,以防止其他程序访问;
文件通道可以安全地供多个并发线程使用。如Channel接口所指定的,close方法可以随时调用。在任何给定时间,可能仅在进行涉及通道位置或可以更改其文件大小的一项操作。当第一个此类操作仍在进行时,尝试启动第二个此类操作的尝试将被阻止,直到第一个操作完成。其他操作,尤其是采取明确立场的操作,可以同时进行。它们是否实际上执行取决于底层实现。
此类的实例提供的文件视图保证与同一程序中其他实例提供的相同文件的其他视图一致。但是,由于底层操作系统执行的缓存和网络文件系统协议引起的延迟,此类实例提供的视图可能与其他并发运行的程序所见的视图一致,也可能不一致。 不管这些其他程序的编写语言是什么,以及它们是在同一台计算机上还是在其他计算机上运行,都是如此。任何此类不一致的确切性质都取决于底层操作系统如何实现。
通过调用此类定义的open方法来创建文件通道。也可以通过调用后续类的getChannel方法从现有的FileInputStream,FileOutputStream或RandomAccessFile对象获得文件通道,该方法返回连接到相同基础文件的文件通道。如果文件通道是从现有流或随机访问文件获得的,则文件通道的状态与其getChannel方法返回该通道的对象的状态紧密相连。无论是显式更改通道位置,还是通过读取或写入字节来更改通道位置,都会更改原始对象的文件位置,反之亦然。通过文件通道更改文件的长度将更改通过原始对象看到的长度,反之亦然。通过写入字节来更改文件的内容将更改原始对象看到的内容,反之亦然。
在各个点上,此类都指定需要一个“可读取”、“可写入”或“可读取和写入”的实例。通过FileInputStream实例的getChannel方法获得的通道将打开以供读取。通过FileOutputStream实例的getChannel方法获得的通道将打开以进行写入。最后,如果实例是使用模式“ r”创建的,则通过RandomAccessFile实例的getChannel方法获得的通道将打开以供读取;如果实例是使用模式“ rw”创建的,则将打开以进行读写。打开的用于写入的文件通道可能处于附加模式,例如,如果它是从通过调用FileOutputStream(File,boolean)构造函数并为第二个参数传递true创建的文件输出流中获得的。在这种模式下,每次调用相对写入操作都会先将位置前进到文件末尾,然后再写入请求的数据。位置的提升和数据的写入是否在单个原子操作中完成取决于操作系统的具体实现。
SocketChannel
一个可选择的Channel,用于面向流的连接socket。
通过调用此类的open方法来创建套接字通道。无法为任意现有的套接字创建通道。新建的套接字通道一打开,是处于尚未连接的状态的。尝试在未连接的通道上调用I/O操作将导致引发NotYetConnectedException。套接字通道可以通过调用其connect方法进行连接,连接后,套接字通道将保持连接状态,直到关闭为止。套接字通道是否已连接可以通过调用其isConnected方法来确定。
套接字通道支持非阻塞连接,创建一个套接字通道,并可以通过connect方法启动建立到远程套接字的链接的过程,然后由finishConnect方法完成。可以通过调用isConnectionPending方法来确定连接操作是否正在进行。
套接字通道支持异步关闭,这类似于Channel类中指定的异步关闭操作。如果套接字的输入端被一个线程关闭,而另一个线程在套接字通道的读取操作中被阻塞,则阻塞线程中的读取操作将完成而不会读取任何字节,并且将返回-1。如果套接字的输出端被一个线程关闭,而另一个线程在套接字通道的写操作中被阻塞,则被阻塞的线程将收到AsynchronousCloseException。
套接字选项是使用setOption方法配置的。套接字通道支持以下选项:
选项名称 描述
SO_SNDBUF 套接字发送缓冲区的大小
SO_RCVBUF 套接字接收缓冲区的大小
SO_KEEPALIVE 保持连接活跃
SO_REUSEADDR 重复使用地址
SO_LINGER 如果有数据,则在关闭时徘徊(仅在阻塞模式下配置)
TCP_NODELAY 禁用Nagle算法
也可以支持其他(特定于实现的)选项。
套接字通道可以安全地供多个并发线程使用。它们支持并发读取和写入,尽管在任何给定时间最多可以读取一个线程,并且最多可以写入一个线程。connect和finishConnect方法彼此相互同步,并且在这些方法之一的调用正在进行时尝试启动读取或写入操作将被阻止,直到该调用完成为止。
ServerSocketChannel
一个可选择的Channel,用于面向流的监听socket。
通过调用此类的open方法可以创建服务器套接字通道。无法为任意现有的ServerSocket创建通道。新创建的服务器套接字通道一打开,是处于尚未绑定的状态的。尝试调用未绑定的服务器套接字通道的accept方法将导致引发NotYetBoundException。可以通过调用此类定义的bind方法之一来绑定服务器套接字通道。
套接字选项是使用setOption方法配置的。服务器套接字通道支持以下选项:
选项名称 描述
SO_RCVBUF 套接字接收缓冲区的大小
SO_REUSEADDR 重复使用地址
也可以支持其他(特定于实现的)选项。
服务器套接字通道可安全用于多个并发线程。
Buffer模块
Buffer
一个特定原始类型数据的容器。
缓冲区是特定原始类型元素的线性有限序列。除了其内容之外,缓冲区的基本属性还包括capacity、limit和position:
缓冲区的capacity是它包含的元素数量。缓冲区的capacity永远不会为负,也不会改变。
缓冲区的limit是不应读取或写入的第一个元素的索引。缓冲区的limit永远不会为负,也永远不会大于缓冲区的capacity。
缓冲区的position是下一个要读取或写入的元素的索引。缓冲区的position永远不会为负,也不会大于limit。
对于每个非布尔基本类型,此类都有一个子类,即IntBuffer、ShortBuffer、LongBuffer、CharBuffer、ByteBuffer、DoubleBuffer和FloatBuffer。
Transferring data
此类的每个子类定义了get和put操作的两类:
相对操作从当前位置开始读取或写入一个或多个元素,然后将该位置增加所传送元素的数量。如果请求的传输超出limit,则相对的get操作将引发BufferUnderflowException,而相对的put操作将引发BufferOverflowException; 无论哪种情况,都不会传输数据。
绝对运算采用显式元素索引,并且不影响位置。如果index参数超出limit,则绝对的get和put操作将引发IndexOutOfBoundsException。
当然,也可以通过始终相对于当前位置的通道的I/O操作将数据移入或移出缓冲区。
Marking and resetting
缓冲区的mark标记是在调用reset方法时将其position重置的索引。mark并非总是定义的,但是定义时,它永远不会为负,也永远不会大于position。如果定义了mark,则在将position或limit调整为小于mark的值时将mark标记丢弃。如果未定义mark,则调用reset方法将引发InvalidMarkException。
Invariants
对于mark,position,limit和capacity,以下不变式成立:
0 <=mark<= position <=limit<=capacity
新创建的缓冲区始终具有零位置和未定义的标记。 初始时limit可以为零,也可以是其他一些值,具体取决于缓冲区的类型及其构造方式。新分配的缓冲区的每个元素都初始化为零。
Clearing, flipping, and rewinding
除了访问position,limit和capacity以及mark和reset的方法之外,此类还定义了以下对缓冲区的操作:
clear使缓冲区为新的通道读取或相对put操作序列做好准备:将limit设置为capacity,并将位置position为零。
flip使缓冲区为新的通道写入或相对get操作序列做好准备:将limit设置为当前position,然后将position设置为零。
rewind使缓冲区准备好重新读取它已经包含的数据:保留limit不变,并将position设置为零。
Read-only buffers
每个缓冲区都是可读的,但并非每个缓冲区都是可写的。每个缓冲区类的变异方法都指定为可选操作,当对只读缓冲区调用时,该方法将引发ReadOnlyBufferException。只读缓冲区不允许更改其内容,但其mark,positoin和limit是可变的。缓冲区是否为只读可以通过调用isReadOnly方法来确定。
Thread safety
缓冲区不能安全用于多个并发线程。如果一个缓冲区将由多个线程使用,则应通过适当的同步来控制对该缓冲区的访问。
Invocation chaining
此类中没有其他要返回值的方法被指定为返回在其上调用它们的缓冲区。这使得方法调用可以链接在一起,例如,语句序列:
b.flip();
b.position(23);
b.limit(42);
可以用一个更紧凑的语句代替
b.flip().position(23).limit(42);
基于NIO实现一个简单的聊天程序
上述总结了NIO的基础知识,知道了NIO可以处理文件IO和流IO(网络IO),NIO最大的魅力还是在于网络IO的处理,接下来将通过NIO实现一个简单的聊天程序来继续了解Java的NIO,这个简单的聊天程序是一个服务端多个客户端,客户端相互之间可以实现数据通信。
服务端:
public class NioServer { //通过Map来记录客户端连接信息 private static Map<String,SocketChannel> clientMap = new HashMap<String,SocketChannel>(); public static void main(String[] args) throws Exception { //创建ServerSocketChannel 用来监听端口 ServerSocketChannel serverSocketChannel = ServerSocketChannel.open(); //配置为非阻塞 serverSocketChannel.configureBlocking(false); //获取服务端的socket ServerSocket serverSocket = serverSocketChannel.socket(); //监听8899端口 serverSocket.bind(new InetSocketAddress(8899)); //创建Selector Selector selector = Selector.open(); //serverSocketChannel注册到selector 初始时关注客户端的连接事件 serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT); while (true) { try { //阻塞 关注感兴趣的事件 selector.select(); //获取关注事件的SelectionKey集合 Set<SelectionKey> selectionKeys = selector.selectedKeys(); //根据不同的事件做不同的处理 selectionKeys.forEach(selectionKey -> { final SocketChannel client; try { //连接建立起来之后 开始监听客户端的读写事件 if (selectionKey.isAcceptable()) { //如何监听客户端读写事件 首先需要将客户端连接注册到selector //如何获取客户端建立的通道 可以通过selectionKey.channel() //前面只注册了ServerSocketChannel 所以进入这个分支的通道必定是ServerSocketChannel ServerSocketChannel server = (ServerSocketChannel)selectionKey.channel(); //获取到真实的客户端 client = server.accept(); client.configureBlocking(false); //客户端连接注册到selector client.register(selector,SelectionKey.OP_READ); //selector已经注册上ServerSocketChannel(关注连接)和SocketChannel(关注读写) //UUID代表客户端标识 此处为业务信息 String key = "[" + UUID.randomUUID().toString() + "]"; clientMap.put(key,client); }else if (selectionKey.isReadable()) { //处理客户端写过来的数据 对于服务端是可读数据 此处必定是SocketChannel client = (SocketChannel)selectionKey.channel(); //Channel不能读写数据 必须通过Buffer来读写数据 ByteBuffer byteBuffer = ByteBuffer.allocate(1024); //服务端读数据到Buffer int count = client.read(byteBuffer); if(count > 0) { //读写转换 byteBuffer.flip(); //写数据到其他客户端 Charset charset = Charset.forName("utf-8"); String receiveMessage = String.valueOf(charset.decode(byteBuffer).array()); System.out.println("client:" + client + receiveMessage); String sendKey = null; for(Map.Entry<String,SocketChannel> entry: clientMap.entrySet()) { if(client == entry.getValue()) { //拿到发送者的UUID 用于模拟客户端的聊天发送信息 sendKey = entry.getKey(); break; } } //给所有的客户端发送信息 for(Map.Entry<String,SocketChannel> entry: clientMap.entrySet()) { //拿到所有建立连接的客户端对象 SocketChannel value = entry.getValue(); ByteBuffer writeBuffer = ByteBuffer.allocate(1024); //这个put操作是Buffer的读操作 writeBuffer.put((sendKey + ":" + receiveMessage).getBytes()); //write之前需要读写转换 writeBuffer.flip(); //写出去 value.write(writeBuffer); } } } }catch (Exception ex) { ex.printStackTrace(); } }); //处理完成该key后 必须删除 否则会重复处理报错 selectionKeys.clear(); }catch (Exception e) { e.printStackTrace(); } } } }
客户端:
public class NioClient { public static void main(String[] args) throws Exception { //创建SocketChannel 用来请求端口 SocketChannel socketChannel = SocketChannel.open(); //配置为非阻塞 socketChannel.configureBlocking(false); //创建Selector Selector selector = Selector.open(); //socketChannel注册到selector 初始时关注向服务端建立连接的事件 socketChannel.register(selector,SelectionKey.OP_CONNECT); //向远程发起连接 socketChannel.connect(new InetSocketAddress("127.0.0.1",8899)); while (true) { //阻塞 关注感兴趣的事件 selector.select(); //获取关注事件的SelectionKey集合 Set<SelectionKey> selectionKeys = selector.selectedKeys(); //根据不同的事件做不同的处理 for(SelectionKey selectionKey : selectionKeys) { final SocketChannel channel; if(selectionKey.isConnectable()) { //与服务端建立好连接 获取通道 channel = (SocketChannel)selectionKey.channel(); //客户端与服务端是否正处于连接中 if(channel.isConnectionPending()) { //完成连接的建立 channel.finishConnect(); //发送连接建立的信息 ByteBuffer writeBuffer = ByteBuffer.allocate(1024); //读入 writeBuffer.put((LocalDateTime.now() + "连接成功").getBytes()); writeBuffer.flip(); //写出 channel.write(writeBuffer); //TCP双向通道建立 //键盘作为标准输入 避免主线程的阻塞 新起线程来做处理 ExecutorService service = Executors.newSingleThreadExecutor(Executors.defaultThreadFactory()); service.submit(() -> { while (true) { writeBuffer.clear(); //IO操作 InputStreamReader inputStreamReader = new InputStreamReader(System.in); BufferedReader reader = new BufferedReader(inputStreamReader); String readLine = reader.readLine(); //读入 writeBuffer.put(readLine.getBytes()); writeBuffer.flip(); //写出 channel.write(writeBuffer); } }); } //客户端也需要监听服务端的写出信息 所以需要关注READ事件 channel.register(selector,SelectionKey.OP_READ); }else if(selectionKey.isReadable()) { //从服务端读取事件 channel = (SocketChannel)selectionKey.channel(); ByteBuffer readBuffer = ByteBuffer.allocate(1024); int count = channel.read(readBuffer); if(count > 0) { readBuffer.flip(); Charset charset = Charset.forName("utf-8"); String receiveMessage = String.valueOf(charset.decode(readBuffer).array()); System.out.println("client:" + receiveMessage); } } //处理完成该key后 必须删除 否则会重复处理报错 selectionKeys.clear(); } } } }
演示效果:
最后我们来总结一下:
1、IO面向流,NIO面向缓冲区,流只能单向传输,而缓冲区可以双向传输,双向传输的模型除了吞吐量得到增加外,这个模型也更接近操作系统和网络的底层;
2、对于网络IO,Selector和Channel组合在一起,实现了IO多路复用,这样少量的线程也能处理大量的连接,适用于应对高并发大流量的场景;而对于文件IO,就谈不上IO多路复用,但是FileChannel通过提供transferTo、transferFrom方法来减少底层拷贝的次数也能大幅提升文件IO的性能;
3、Buffer缓冲区用来存储数据,除了没有布尔类型外,其他基础数据类型和Java里面的基础类型是一样的,Buffer的核心属性是position、limit和capacity,读写数据时是这几个变量在不断翻转变化,但是其实这个设计并不优雅,Netty的ByteBuf提供读写索引分离的方式使实现更加优雅;
4、NIO的编程模式总结:
将Socket通道注册到Selector中,监听感兴趣的事件;
当感兴趣的事件就绪时,则会进去我们处理的方法进行处理;
每处理完一次就绪事件,删除该选择键(因为我们已经处理完了)。
参考资料:
http://ifeve.com/java-nio-all/
https://segmentfault.com/a/1190000014932357?utm_source=tag-newest
https://www.zhihu.com/question/29005375?sort=created
部分图片截图自某学习视频,如有侵权请告知。