标准的IO基于字节流和字符流进行操作的,而NIO是基于通道(Channel)和缓冲区(Buffer)进行操作,数据总是从通道读取到缓冲区中,或者从缓冲区写入到通道中。java nio主要支持如下的channel,涵盖了Tcp,Udp 和文件IO。
- FileChannel
- DatagramChannel
- SocketChannel
- ServerSocketChannel
Buffer
Buffer是java nio中很重要的一项,上一篇文章说过,IO读操作就是从把数据从网卡或者磁盘复制到内核缓存,然后内核缓存复制到用户缓存(工作缓存),而这个用户缓存就是我们用的Buffer。举个例子说明。
socketChannel.read(byteBuffer);这个过程就是从内核缓存复制数据到工作缓存的过程,这个byteBuffer就是我们的工作缓存
操作Buffer的方式很简单,只需要记住它的几个核心属性即可
- capacity 总的大小
- position 当前光标位置
- limit 可用位置
Buffer本质上就是一个capacity长度的数组,position是数组的光标位置,注意是光标,而不是当前数据的位置,也就是说你的数据的下一个位置。limit就是可读写的位置。Buffer的方法只是移动position和limit的位置,我们读写的时候用的只是这两个之间的数据。比如capacity为10.
byteBuffer.clear();这个就是将position=0,limit=capacity。
byteBuffer.flip();这个就是将limit=position,position=0。
byteBuffer.compact();这个就是将position和limit之间的数据复制到0到limit-position之间,同时position=limit-position+1,limit=capacity。
也可以直接通过position(p)和limit(l)方法直接设置position和limit。总之Buffer的操作就是操作position和limit以及这俩下标之间的数组。
Channel
Channel用于在字节缓冲区和位于通道另一侧的实体(通常是一个文件或套接字)之间有效地传输数据。
通道是访问I/O服务的导管。I/O可以分为广义的两大类别:File I/O和Stream I/O。那么相应地有两种类型的通道也就不足为怪了,它们是文件(file)通道和套接字(socket)通道。我们看到在api里有一个FileChannel类和三个socket通道类:SocketChannel、ServerSocketChannel和DatagramChannel。
通道可以以多种方式创建。Socket通道有可以直接创建新socket通道的工厂方法。但是一个FileChannel对象却只能通过在一个打开的RandomAccessFile、FileInputStream或FileOutputStream对象上调用getChannel( )方法来获取。你不能直接创建一个FileChannel对象,还有FileChannel是不支持非阻塞io的,毕竟大部分操作系统的文件io操作本身就是阻塞的。
FileChannel
RandomAccessFile aFile = new RandomAccessFile("s.txt", "rw");
FileChannel inChannel = aFile.getChannel();
ByteBuffer buf = ByteBuffer.allocate(48);
int bytesRead = inChannel.read(buf);
while (bytesRead != -1) {
System.out.println("Read " + bytesRead);
buf.flip();
while(buf.hasRemaining()){
System.out.print((char) buf.get());
}
buf.clear();
bytesRead = inChannel.read(buf);
}
aFile.close();
既然FileChannel不支持非阻塞,那么FileChannel的优势是什么,记得上一篇写的零拷贝技术吗,FileChannel的map方法和transferTo/transferFrom就是用了此技术(mmap和sendfile),这便是FileChannel主要的优势。下面会详细的介绍。
FileInputStream/FileOutputStream | FileChannel |
---|---|
单向 | 双向 |
面向字节的读写 | 面向Buffer读写 |
不支持 | 支持内存文件映射 |
不支持 | 支持转入或转出其他通道 |
不支持 | 支持文件锁 |
不支持操作文件元信息 | 不支持操作文件元信息 |
BIO 是面向流的 IO,它建立的通道都是单向的,只能向后移动数据,所以输入和输出流的通道不相同,必须建立2个通道。而FileChannel则是可以在通道内随意移动光标,并在光标处向后进行读写。当然就像我上面说的缓冲区buffer只是进行position和limit的移动,并不删除数据,对应的通道也是一样,所以即使你将光标移动到通道中间的位置,然后在此位置进行写操作,然后新写进去的数据就会覆盖此位置原来的数据,并不是在这个光标位置插入你的数据。
FileChannel大致提供了以上的重要操作接口。下面详细介绍每个接口的作用和用法:
1)open:用于创建一个FileChannel对象。具有两种重载形式
public static FileChannel open(Path path, OpenOption... options) throws IOException
public static FileChannel open(Path path, Set<? extends OpenOption> options, FileAttribute<?>... attrs) throws IOException
OpenOption
主要用于控制文件的操作模式:
- READ:只读方式
- WRITE:只写方式
- APPEND:只追加方式
- CREATE:创建新文件
- CREATE_NEW:创建新文件,如果存在则失败
- TRUNCATE_EXISTING:如果以读方式访问文件,它的长度将被清除至0
示例:
Path path = FileSystems.getDefault().getPath("D:/test.txt");
FileChannel channel2 = FileChannel.open(path, StandardOpenOption.READ);
除了以上的方式能够创建FileChannel,还有以下两种方式:
a. 通过FileInputStream/FileOutputStream提供的getChannel
FileInputStream inputStream = new FileInputStream("D:/test.txt");
FileChannel channel = inputStream.getChannel();
FileOutputStream outputStream = new FileOutputStream("D:/test.txt");
FileChannel channel1 = outputStream.getChannel();
b. 通过RandomAccessFile的getChannel
RandomAccessFile randomAccessFile = new RandomAccessFile("./test.txt", "rw");
FileChannel channel2 = randomAccessFile.getChannel();
通过以上的示例,open相比流和随机文件的方式创建FileChannel更具有操作模式上的选择性。
2)read/write:在通道上用于读写文件
ByteBuffer byteBuffer = ByteBuffer.allocate(16);
int count = channel2.read(byteBuffer);
ByteBuffer byteBuffer = ByteBuffer.allocate(16);
byte[] bs = "s".getBytes();
byteBuffer.put(bs);
byteBuffer.flip();
channel2.write(byteBuffer);
read时返回int型值,代表读入buffer内的字节数,如果返回-1,表示已经读到文件尾,无内容可读。
3)force:强制将在这个Channel更新的内容写入文件中(因为每次更新时不可能一直都更新到文件中,这样频繁的进行外存I/O,读写效率很低,一般都是用缓冲区)。
使用force可以避免在系统宕机情况下数据丢失,但是如果文件不是本地的设备上文件,那force将不能得到保证。
channel2.force(false);
该方法具有布尔类型参数用来限制I/O次数。false表示只对文件内容更新写入存储的文件中,true表示关于更新的内容和文件的元信息都写入,这个通常至少需要一次甚至更多的I/O。
这个方法不保证使用内存映射文件(MappedByteBuffer
)的情况下对MappedByteBuffer的修改也写入内存,如果需要,则调用MappedByteBuffer.force
4)map:将外存文件某段映射至内存,返回MappedByteBuffer,具有以下几种映射模式:
- READ_ONLY:以只读的方式映射,如果发生修改,则抛出
ReadOnlyBufferException
- READ_WRITE:读写方式
- PRIVATE:对这个MappedByteBuffer的修改不写入文件,且其他程序是不可见的。
一旦经过map映射后,将于用于映射的FileChannel没有联系,即使Channel关闭,也对MappedByteBuffer没有影响。
关于映射的很多细节都是不确定的,因为都是依赖操作系统。两个程序对文件的同一个区域进行映射,其一程序进行了修改,这个修改不一定就立即传播到另一个程序的Buffer中,这依赖操作系统的实行。
map通产应用在超大文件的处理中时使用,整体的性能才得以提升。对于数十Kb的文件处理,使用map的性能不一定比传统基于流式的读写好,因为直接映射进入内存的代价开销较大。需要在这两者之间进行权衡选择。
Path path = FileSystems.getDefault().getPath("./test.txt");
FileChannel channel = FileChannel.open(path, StandardOpenOption.READ, StandardOpenOption.WRITE);
MappedByteBuffer mappedByteBuffer = channel.map(MapMode.READ_ONLY, 0, 100000);
byte[] bs = new byte[100];
mappedByteBuffer.get(bs);
内存文件映射大幅度提高了文件的读写效率,使用了操作系统的mmap方法,以下阐述下map的原理。
从以上的图可以看出,内存文件映射是将文件直接映射至用户空间内存,未经过内核空间缓冲区的拷贝,相对于传统的I/O减少一次内存拷贝。
传统的文件IO流程(这里以读为例说明):
- 应用进程对本地接口发出调用请求(system call)
- 用户上下文切换至内核上下文,操作系统调用驱动接口读外存文件
- 将磁盘上文件数据读入内核缓冲区(这里的读的方式,暂不考虑)
- 然后再将内核缓冲区的内容拷贝至用户空间的应用缓冲区中
- 内核上下文切换至用户上下文
- 用户进程处理缓冲区中的数据
整个流程中不仅涉及到用户空间和内核空间的频繁切换,而且还需要讲内核缓冲区中的数据再拷贝一次至用户空间的缓冲区,操作过程复杂且性能降低。
接下来再看下mmap内存文件映射的流程(仍然以读为例说明):
- 应用进程进行系统调用
- 用户上下文切换至内核上下文
- 建立用户空间与磁盘文件的映射关系(此时只是关系的建立,并没有将文件数据读入用户缓冲区)
- 当用户进程第一次读数据时,因为没有关联内核空间,这时会发生缺页(内存中定长的连续块,存储数据和内存管理的最小单元)异常
- 操作系统根据缺页异常,认为内核空间不够,便将相应的部分数据直接读入用户缓冲区
从以上的比较中可以看出,mmap内存文件映射,减少了一次内存拷贝:内核缓冲区至用户缓冲区。
内存文件映射对于超大型文件处理的优势相对于面向字节流的I/O操作很大。
5)transferTo和transferFrom:将该文件通道的内容转出到另一个通道或者将另一个通道的内容转入
srcChannel.transferTo(0, Integer.MAX_VALUE, dstChannel);
srcChannel.transferFrom(fromChannel, 0, Integer.MAX_VALUE);
该两个方法可以实现通道数据的快速转移,不仅在简化代码量(少了中间内存的数据拷贝转移)而且还大幅到提高了性能,使用了操作系统的sendfile方法,以下阐述下map的原理。
传统的方式将读取文件内容然后写入其他的文件或者网络协议栈中流程:
- 应用进程对本地接口发出读调用请求(system call)
- 用户上下文切换至内核上下文,操作系统调用驱动接口读外存文件
- 将磁盘上文件数据读入内核缓冲区(这里的读的方式,暂不考虑)
- 然后再将内核缓冲区的内容拷贝至用户空间的应用缓冲区中
- 内核上下文切换至用户上下文
- 应用进程对本地接口发出写调用请求(system call)
- 用户上下文切换至内核上下文
- 将用户缓冲区数据拷贝至内核缓冲区或者协议栈
- 将内核缓冲区的数据写到网络接口或者文件中
接下来再看看transferTo和transferFrom过程:
- 应用进程进行系统调用
- 用户上下文切换至内核上下文
- 将文件数据写入内核缓冲区
- 内核将数据拷贝至内核缓冲区或者协议栈
- 将内核缓冲区的数据写到网络接口或者文件中
6)lock/tryLock
在了解lock和tryLock之前,先来了解下文件锁的概念。
文件锁是作用在文件区域上的锁,即文件区域是同步资源,多个程序访问时,需要先获取该区域的锁,才能进入访问文件,访问结束释放锁,实现程序串行化访问文件。在多数unix系统中,当多个进程/线程同时编辑一个文件时,该文件的最后状态取决于最后一个写该文件的进程。对于有些应用程序,如数据库,各个进程需要保证它正在单独地写一个文件。这时就要用到文件锁。文件锁(也叫记录锁)的作用是,当一个进程读写文件的某部分时,其他进程就无法修改同一文件区域。这里可以类比Java中的对象锁或者lock锁理解。
FileChannel channel = rf.getChannel();
FileLock lock = channel.lock(0L, 23L, false); //自定义加锁方式。前2个参数指定要加锁的部分(可以只对此文件的部分内容加锁),第三个参数值指定是否是共享锁。
......
lock.release();
如上,如果两个java程序都获取同一文件的通道并对文件加独占式锁(两个程序对文件加锁的区域存在重叠),则同一时间只能有一个程序能获取,另一个程序则处于等待获取锁的状态。
这里我基于操作系统windows7和MacOS Sierra验证。
下表展示独占锁和共享锁的相互作用于同一文件的统一区域的叠加性关系:
共享锁 | 独占锁 | |
---|---|---|
共享锁 | Yes | false |
独占锁 | false | false |
下面列出FileLock的几个重要关注事项:
-
文件锁FileLock是被整个Java Vitrual Machine持有的,即FileLock是进程级别的,所以不可用于作为多线程安全控制的同步工具。
-
虽然上面提到FileLock不可用于多线程访问安全控制,但是多线程访问是安全的。如果线程1获取了文件锁FileLock(共享或者独占),线程2再来请求获取该文件的文件锁,则会抛出
OverlappingFileLockException
-
一个程序获取到FileLock后,是否会阻止另一个程序访问相同文件具重叠内容的部分取决于操作系统的实现,具有不确定性。FileLock的实现依赖于底层操作系统实现的本地文件锁设施。
-
以上所说的文件锁的作用域是文件的区域,可以时整个文件内容或者只是文件内容的一部分。独占和共享也是针对文件区域而言。程序(或者线程)获取文件0至23范围的锁,另一个程序(或者线程)仍然能获取文件23至以后的范围。只要作用的区域无重叠,都相互无影响。
TCP和UDP
在socket网络程序中,TCP和UDP分别是面向连接和非面向连接,TCP是基于数据流,UDP是基于数据报的。
因此TCP的socket编程,收发两端(客户端和服务器端)都要有一一成对的socket,因此,发送端为了将多个发往接收端的包,更有效的发到对方,使用了优化方法(Nagle算法),将多次间隔较小且数据量小的数据,合并成一个大的数据块,然后进行封包。还有socket缓冲区与滑动窗口以及MSS/MTU限制。这样数据就发生了粘包和拆包,导致接收端难分辨出来哪些字节是一个完整的包,必须提供科学的拆包机制,即面向流的通信是无消息保护边界的。
对于UDP,不会使用块的合并优化算法,这样,实际上目前认为,是由于UDP支持的是一对多的模式,所以接收端的skbuff(套接字缓冲区)采用了链式结构来记录每一个到达的UDP包,在每个UDP包中就有了消息头(消息来源地址,端口等信息),这样,对于接收端来说,就容易进行区分处理了,面向消息的通信是有消息保护边界的。
关于TCP的粘包和拆包原理比较长,就不再这里介绍了,请看这一篇文章https://www.jianshu.com/p/db3d43be8507
DatagramChannel
DatagramChannel是一个处理UDP协议的通道,每个数据报(datagram)都是一个自包含的实体,拥有它自己的目的地址及不依赖其他数据报的数据净荷。与面向流的的socket不同,DatagramChannel可以发送单独的数据报给不同的目的地址。同样,DatagramChannel对象也可以接收来自任意地址的数据包。每个到达的数据报都含有关于它来自何处的信息。一个未绑定的DatagramChannel仍能接收数据包。当一个底层socket被创建时,一个动态生成的端口号就会分配给它。绑定行为要求通道关联的端口被设置为一个特定的值(此过程可能涉及安全检查或其他验证)。不论通道是否绑定,所有发送的包都含有DatagramChannel的源地址(带端口号)。未绑定的DatagramChannel可以接收发送给它的端口的包,通常是来回应该通道之前发出的一个包。已绑定的通道接收发送给它们所绑定的熟知端口(wellknown port)的包。数据的实际发送或接收是通过send( )和receive( )方法来实现的
打开一个DatagramChannel(Opening a DatagramChannel)
打开一个DatagramChannel你这么操作:
DatagramChannel channel = DatagramChannel.open();
channel.socket().bind(new InetSocketAddress(9999));
上述示例中,我们打开了一个DatagramChannel,它可以在9999端口上收发UDP数据包。
接收数据(Receiving Data)
接收数据,直接调用DatagramChannel的receive()方法:
ByteBuffer buf = ByteBuffer.allocate(48);
buf.clear();
**channel.receive(buf);**
receive()方法会把接收到的数据包中的数据拷贝至给定的Buffer中。如果数据包的内容超过了Buffer的大小,剩余的数据会被直接丢弃。
发送数据(Sending Data)
发送数据是通过DatagramChannel的send()方法:
String newData = "New String to wrte to file..." +System.currentTimeMillis();
ByteBuffer buf = ByteBuffer.allocate(48);
buf.clear();
buf.put(newData.getBytes());
buf.flip();
**int byteSent = channel.send(buf, new InetSocketAddress("jenkov.com", 80));**
上述示例会吧一个字符串发送到“jenkov.com”服务器的UDP端口80.目前这个端口没有被任何程序监听,所以什么都不会发生。当发送了数据后,我们不会收到数据包是否被接收的的通知,这是由于UDP本身不保证任何数据的发送问题。
链接特定机器地址(Connecting to a Specific Address)
DatagramChannel实际上是可以指定到网络中的特定地址的。由于UDP是面向无连接的,这种链接方式并不会创建实际的连接,这和TCP通道类似。确切的说,他会锁定DatagramChannel,这样我们就只能通过特定的地址来收发数据包。
看一个例子先:
channel.connect(new InetSocketAddress("jenkov.com"), 80));
当连接上后,可以向使用传统的通道那样调用read()和Writer()方法。区别是数据的读写情况得不到保证。下面是几个示例:
int bytesRead = channel.read(buf);
int bytesWritten = channel.write(buf);
SocketChannel
- SocketChannel是用来连接Socket套接字
- SocketChannel主要用途用来处理网络I/O的通道
- SocketChannel是基于TCP连接传输
- SocketChannel实现了可选择通道,可以被多路复用的
SocketChannel具有以下的特征:
- 对于已经存在的socket不能创建SocketChannel
- SocketChannel中提供的open接口创建的Channel并没有进行网络级联,需要使用connect接口连接到指定地址
- 未进行连接的SocketChannle执行I/O操作时,会抛出
NotYetConnectedException
- SocketChannel支持两种I/O模式:阻塞式和非阻塞式
- SocketChannel支持异步关闭。如果SocketChannel在一个线程上read阻塞,另一个线程对该SocketChannel调用shutdownInput,则读阻塞的线程将返回-1表示没有读取任何数据;如果SocketChannel在一个线程上write阻塞,另一个线程对该SocketChannel调用shutdownWrite,则写阻塞的线程将抛出
AsynchronousCloseException
- SocketChannel支持设定参数
SocketChannel的使用
1.创建SocketChannel
方式1.
SocketChannel socketChannel = SocketChannel.open(new InetSocketAddress("www.baidu.com", 80));
方式2.
SocketChannel socketChannel = SocketChannel.open();
socketChannel.connect(new InetSocketAddress("www.baidu.com", 80));
直接使用有参open api或者使用无参open api,但是在无参open只是创建了一个SocketChannel对象,并没有进行实质的tcp连接。
2.连接校验
socketChannel.isOpen(); // 测试SocketChannel是否为open状态
socketChannel.isConnected(); //测试SocketChannel是否已经被连接
socketChannel.isConnectionPending(); //测试SocketChannel是否正在进行连接
socketChannel.finishConnect(); //校验正在进行套接字连接的SocketChannel是否已经完成连接
3.读写模式
前面提到SocketChannel支持阻塞和非阻塞两种模式:
socketChannel.configureBlocking(false);
主要是通过以上方法设置SocketChannel的读写模式。false表示非阻塞,true表示阻塞。
4.读写
SocketChannel socketChannel = SocketChannel.open(
new InetSocketAddress("www.baidu.com", 80));
ByteBuffer byteBuffer = ByteBuffer.allocate(16);
socketChannel.read(byteBuffer);
socketChannel.close();
System.out.println("test end!");
以上为阻塞式读,当执行到read出,线程将阻塞,控制台将无法打印test end!。
SocketChannel socketChannel = SocketChannel.open(
new InetSocketAddress("www.baidu.com", 80));
socketChannel.configureBlocking(false);
ByteBuffer byteBuffer = ByteBuffer.allocate(16);
socketChannel.read(byteBuffer);
socketChannel.close();
System.out.println("test end!");
以上为非阻塞读,控制台将打印test end!。
读写都是面向缓冲区,socketChannel.read返回为从内核缓冲区(SO_RCVBUF)读取到的字节数,如果是0则是代表内核缓冲区(SO_RCVBUF)没有数据,返回为-1代表对面断开连接。socketChannel.write返回为在内核缓冲区(SO_SNDBUF)写入的字节数,如果返回0代表内核缓冲区(SO_SNDBUF)满了。TCP的滑动窗口就是根据接收方的SO_RCVBUF来进行的,保证了数据传输的完整性。如对面的接收缓冲区只剩下10个字节就满了,那么根据滑动窗口,发送端只能发送10个字节,直到接收端读取了SO_RCVBUF中的字节,发送端才可以根据SO_RCVBUF的空闲大小来发送相应的字节数。
5.设置和获取参数
参数名 | 作用描述 |
---|---|
SO_SNDBUF | 套接字发送缓冲区大小 |
SO_RCVBUF | 套接字接收缓冲区大小 |
SO_KEEPALIVE | 保活连接 |
O_REUSEADDR | 复用地址 |
SO_LINGER | 有数据传输时延缓关闭Channel (只有在非阻塞模式下有用) |
TCP_NODELAY | 禁用Nagle算法 |
通过setOptions方法可以设置socket套接字的相关参数。
socketChannel.setOption(StandardSocketOptions.SO_KEEPALIVE, Boolean.TRUE)
.setOption(StandardSocketOptions.TCP_NODELAY, Boolean.TRUE);
可以通过getOption获取相关参数的值。如默认的接收缓冲区大小是8192byte。
socketChannel.getOption(StandardSocketOptions.SO_KEEPALIVE)
socketChannel.getOption(StandardSocketOptions.SO_RCVBUF)
ServerSocketChannel
ServerSocketChannel是面向流的监听socket套接字的可选择性通道。从定义中可以看出以下几点:
- 具有阻塞和非阻塞两种模式
- 可以注册到多路复用器上
- 基于TCP连接
- 需要绑定到特定端口上
ServerSocketChannel可以被无参的open()
方法创建。但是改方法只是创建了一个ServerSocketChannel对象,并没有进行绑定操作,仍需要调用bind()
方法进行绑定,使之监听某个套接字。未进行绑定的ServerSocketChannel调用accept()
,将会抛出NotYetBoundException
异常。
ServerSocketChannel支持的可选参数:
参数名 | 作用描述 |
---|---|
SO_RCVBUF | 套接字接收缓冲区大小 |
SO_REUSEADDR | 重新使用地址 |
ServerSocketChannel支持两种模式:阻塞模式和非阻塞模式。且是线程安全的
ServerSocketChannel使用
1.创建ServerSocketChannel
ServerSocketChannel channel = ServerSocketChannel.open(); //创建ServerSocketChannel
2.绑定到本机网络接口
channel.bind(new InetSocketAddress(8091)); //绑定至8091端口
3.接收连接
SocketChannel socketChannel = channel.accept();
ServerSocketChannel的accept方法返回SocketChannel套接字通道,用于读取请求数据和写入响应数据。
ServerSocketChannel的阻塞和非阻塞体现在这里:
- 阻塞模式:在调用accept方法后,将阻塞直到有新的socket连接时返回SocketChannel对象,代表新建立的套接字通道。
- 非阻塞模式:在调用accept方法后,如果无连接建立,则返回
null
;如果有连接,则返回SocketChannel。
简单的client-server
创建简单的server:
public void createServerSocketChannel() throws IOException {
ServerSocketChannel channel = ServerSocketChannel.open();
channel.bind(new InetSocketAddress(8091));
while (true) {
SocketChannel socketChannel = channel.accept();
ByteBuffer byteBuffer = ByteBuffer.allocate(16);
int count = socketChannel.read(byteBuffer);
while(count != -1) {
byteBuffer.flip();
while (byteBuffer.hasRemaining()) {
System.out.print((char) byteBuffer.get());
}
byteBuffer.clear();
count = socketChannel.read(byteBuffer);
}
System.out.println();
}
}
创建简单client:
public void client() throws IOException {
SocketChannel socketChannel = SocketChannel.open(
new InetSocketAddress("10.17.83.11", 8091));
String msg = "ok";
socketChannel.write(ByteBuffer.wrap(msg.getBytes()));
SocketChannel socketChannel3 = SocketChannel.open(
new InetSocketAddress(8091));
socketChannel3.write(ByteBuffer.wrap(msg.getBytes()));
System.out.println("s3");
SocketChannel socketChannel4 = SocketChannel.open(
new InetSocketAddress(8091));
socketChannel4.write(ByteBuffer.wrap(msg.getBytes()));
System.out.println("s4");
socketChannel4.close();
socketChannel3.close();
socketChannel.close();
}
由代码看来非阻塞也是通过while(true) 不断的循环,这样和阻塞没什么区别。而且serverSocketChannel.accept阻塞状态也是支持中断的,即对调用此方法的线程执行中断方法,那么serverSocketChannel.accept会抛出个ClosedByInterruptException异常,可以通过捕捉这个异常来停止监听。那么区别就在于非阻塞的模式下我们可以自定义中断,只需要在循环里加一个中断的判断,跳出循环即可,同时也可以让线程先做别的事情,过段时间再来执行serverSocketChannel.accept,但我感觉实际上没什么意义。
Selector
虽然上述的代码实现了非阻塞io,但是实际上并不好用,上一篇已经说到了,非阻塞io时需要我们经常去read/write去读写数据。这样当前线程就会进行多次用户态和内核态的切换,性能会差很多。所以我们有更好的选择,IO多路复用,即Selector。
我们把连接注册到Selector中,让Selector去在内核中去遍历这些连接,如果有可读的连接(读内核缓冲区SO_RCVBUF有字节)或者有可写的连接(写缓冲区SO_SNDBUF有空闲)。返回可用连接的数量。然后可以通过Selector获取到相应可用的连接,并且可以知道是可读还是可写。我们再让这些连接去读写,其余连接不用管,这样我们就实现了一个线程监听多个连接,让读写没有无意义的阻塞(每次都能有空间写或者有字节读)。用到的是操作系统的select/epoll/poll 方法,在不同的平台上, 底层的实现可能有所不同, 但其基本原理是一样的。
下面我们来使用选择器:
通过 Selector.open()方法, 我们可以创建一个选择器:
Selector selector = Selector.open();
将 Channel 注册到选择器中:
channel.configureBlocking(false);
SelectionKey key = channel.register(selector, SelectionKey.OP_READ);
注意, 如果一个 Channel 要注册到 Selector 中, 那么这个 Channel 必须是非阻塞的, 即channel.configureBlocking(false);因为 Channel 必须要是非阻塞的, 因此 FileChannel 不能够使用选择器, 因为 FileChannel 都是阻塞的.
注意到, 在使用 Channel.register()方法时, 第二个参数指定了我们对 Channel 的什么类型的事件感兴趣, 这些事件有:
- Connect, 即连接事件(TCP 连接), 对应于SelectionKey.OP_CONNECT
- Accept, 即确认事件, 对应于SelectionKey.OP_ACCEPT
- Read, 即读事件, 对应于SelectionKey.OP_READ, 表示 buffer 可读.
- Write, 即写事件, 对应于SelectionKey.OP_WRITE, 表示 buffer 可写.
一个 Channel发出一个事件也可以称为 对于某个事件, Channel 准备好了. 因此一个 Channel 成功连接到了另一个服务器也可以被称为 connect ready.
我们可以使用或运算|来组合多个事件, 例如:
int interestSet = SelectionKey.OP_READ | SelectionKey.OP_WRITE;
注意, 一个 Channel 仅仅可以被注册到一个 Selector 一次, 如果将 Channel 注册到 Selector 多次, 那么其实就是相当于更新 SelectionKey 的 interest set. 例如:
channel.register(selector, SelectionKey.OP_READ);
channel.register(selector, SelectionKey.OP_READ | SelectionKey.OP_WRITE);
上面的 channel 注册到同一个 Selector 两次了, 那么第二次的注册其实就是相当于更新这个 Channel 的 interest set 为 SelectionKey.OP_READ | SelectionKey.OP_WRITE.
但是Java NIO的selector允许一个单一线程监听多个channel输入。我们可以注册多个channel到selector上,然后然后用一个线程来挑出一个处于可读或者可写状态的channel。selector机制使得单线程管理多个channel变得容易。
selector.select()是阻塞方法,但是可以响应中断,并且不会抛出错误直接返回0。selector.selectNow()是非阻塞直接返回
下面我们写一个完整的例子,看一下Selector的用法:
//创建选择器
Selector selector = Selector.open();
channel.configureBlocking(false);
//注册通道
SelectionKey key = channel.register(selector, SelectionKey.OP_READ);
//查看selector中的key是否准备好
while (selector.select()) {
//获取选择器中的key
Set<SelectionKey> selectedKeys = selector.selectedKeys();
Iterator<SelectionKey> keyIterator = selectedKeys.iterator();
while (keyIterator.hasNext()) {
SelectionKey key = keyIterator.next();
//遍历已选择键集中的每个键,并检测各个键所对应的通道的就绪事件
if (key.isAcceptable()) {
// 连接已经被ServerSocketChannel所接受
} else if (key.isConnectable()) {
// 连接已经被远程终止.
} else if (key.isReadable()) {
// 通道已经准备好读数据
} else if (key.isWritable()) {
// 通道已经准备好写数据
}
keyIterator.remove();
}
}
疑问和结束语
为什么java只有FileChannel使用了零拷贝技术(mmap等),其他的像SocketChannel它们都不支持呢,如果使用了mmap的话不也是少了一次拷贝,速度会更快吗,难道是操作系统不支持?
好了,到此NIO基本介绍完了,这一篇注重于原理,比较枯燥,但是还是要了解这些东西后,在实际开发中才不会犯错。那么下一篇写点有意思的,用java NIO写一个http请求。