NIO 的起源
同步阻塞IO(也就是 BIO)在网络通信当中有很多缺点:
- 线程的创建和销毁开销大
- 线程本身占用用内存较大
- 线程切换成本很高
高并发的需求却越来越普通,随着移动端应用的兴起和各种网络游戏的盛行,百万级长连接日趋普遍,此时,必然需要一种更高效的I/O处理组件——这就是Java的NIO编程组件。
NIO 简介
在1.4版本之前,JavaIO类库是阻塞式IO;从1.4版本开始,引进了新的异步IO库,被称为Java New IO类库,简称为Java NIO。
Java NIO 类库的目标,就是要让 Java 支持非阻塞 IO,基于这个原因,更多的人喜欢称 Java NIO 为非阻塞 IO(Non-Block IO),称“老的”阻塞式 Java IO 为 OIO(Old IO)。总体上说,NIO 弥补了原来面向流的 OIO 同步阻塞的不足,它为标准Java代码提供了高速的、面向缓冲区的 IO。
NIO 的好处
主要应用在网络通信中,能够合理利用资源,提高系统的并发效率。支持高并发的系统访问。
NIO 的三个核心组件
Channel (通道)
在 NIO 中,一个网络连接使用一个通道表示,所以的 NIO 的 IO 操作都是通过连接通道完成的。
Channel和Stream的一个显著的不同是:Stream是单向的,譬如InputStream是单向的只读流,OutputStream是单向的只写流;而Channel是双向的,既可以用来进行读操作,又可以用来进行写操作。
NIO 的 Channel 主要实现有:
- FileChannel:用于文件的 IO 操作
- DatagramChannel:用于 UDP 的 IO 操作
- SocketChannel:用于 TCP 连接的传输操作
- ServerSocketChannel:用于 TCP 连接连接监听操作
Buffer (缓冲区)
实际上是一个容器,底层是一个数组。
Channel 提供从文件、网络读取数据的渠道,但是读写的数据都必须经过Buffer。
Channel 读取或者写入数据,都要写到 Buffer 中,才可以被程序操作。
因为 Channel 是双向的,没有方向性,所以 Buffer 为了区分读写,引入了写模式和读模式。
Buffer 是一个抽象类,位于 java.nio 包中,它有如下子类:
- ByteBuffer
- CharBuffer
- DoubleBuffer
- FloatBuffer
- IntBuffer
- LongBuffer
- ShortBuffer
- MappedByteBuffer
其中最常用的是 ByteBuffer。
Selecter (选择器)
Selector选择器可以理解为一个IO事件的监听与查询器。通过选择器,一个线程可以查询多个通道的IO事件的就绪状态。
selector 单从字面意思不好理解,需要结合服务器的设计演化来理解它的用途:
- 多线程版设计:
一个连接请求对应一个线程,会有如下缺点:
- 内存占用高
- 线程上下文切换成本高
- 线程不能无限创建,所有只适合连接数较少的场景
- 线程池版设计:
缺点:
- 阻塞模式下,线程仅能处理一个 socket 连接
- 仅适合短连接场景
- selector 版设计:
调用 selector 的 select() 会阻塞直到 channel 发生了读写就绪事件,这些事件发生,select 方法就会返回这些事件交给 thread 来处理。当 channel 没有读写事件时,线程可以去处理其他 channel。
详解 NIO Buffer 类
Buffer 类的重要属性
为了记录读写的状态和位置,Buffer 类额外提供了一些重要的属性:
- capacity(容量)
- limmit(读写的限制)
- position(读写位置)
capacity 属性
capacity 属性表示内容容量的大小,一旦写入的对象数量超过了容量,缓存区就满了,就不能在写入。
Buffer 的 capacity 属性一旦初始化,就不能在更改。原因是什么呢?Buffer类的对象在初始化时,会按照capacity分配内部数组的内存,在数组内存分配好之后,它的大小当然就不能改变了。
capacity容量并不是指内部的内存块byte[]数组的字节数量,而是指能写入的数据对象的最大限制数量。
limmit 属性
Buffer类的limit属性,表示可以写入或者读取的最大上限,其属性值的具体含义,也与缓冲区的读写模式有关。
调用 Buffer 的 limmit() 方法可以获取 limmit 值。
在不同的模式下,limit的值的含义是不同的,具体分为以下两种情况:
(1)在写入模式下,limit属性值的含义为可以写入的数据最大上限。在刚进入到写入模式时,limit的值会被设置成缓冲区的capacity容量值,表示可以一直将缓冲区的容量写满。
(2)在读取模式下,limit的值含义为最多能从缓冲区中读取到多少数据。
position 属性
Buffer类的position属性,表示当前的位置。position属性的值与缓冲区的读写模式有关。在不同的模式下,position属性值的含义是不同的,在缓冲区进行读写的模式改变时,position值会进行相应的调整。
调用 Buffer 的 position() 方法可以获取 position 值。
在写入模式下,position的值变化规则如下:
(1)在刚进入到写入模式时,position值为0,表示当前的写入位置为从头开始。
(2)每当一个数据写到缓冲区之后,position会向后移动到下一个可写的位置。
(3)初始的position值为0,最大可写值为limit–1。当position值达到limit时,缓冲区就已经无空间可写了。
在读模式下,position的值变化规则如下:
(1)当缓冲区刚开始进入到读取模式时,position会被重置为0。
(2)当从缓冲区读取时,也是从position位置开始读。读取数据后,position向前移动到下一个可读的位置。
(3)在读模式下,limit表示可以读上限。position的最大值,为最大可读上限limit,当position达到limit时,表明缓冲区已经无数据可读。
Buffer的读写模式具体如何切换呢?当新建了一个缓冲区实例时,缓冲区处于写入模式,这时是可以写数据的。在数据写入完成后,如果要从缓冲区读取数据,这就要进行模式的切换,可以使用(即调用)flip()翻转方法,将缓冲区变成读取模式。
在从写入模式到读取模式的flip翻转过程中,position和limit属性值会进行调整,具体的规则是:
(1)limit属性被设置成写入模式时的position值,表示可以读取的最大数据位置;
(2)position由原来的写入位置,变成新的可读位置,也就是0,表示可以从头开始读。
capacity、limmit、position值变化图解:
Buffer 中切换读写模式的方法:
- flip():切换为读模式。
- clear():切换为写模式。
- compact():切换为写模式,同时将未读写完的数据向前压缩。
Buffer 分配空间后,处于写模式下:
向 Buffer 中写入数据后:
上图中向 Buffer 中写入了四个字节,调用 filp() 方法切换为读模式,position 更改为读取位置,limit 更改为读取限制:
position 指向的是将要读取的位置,读取四个字节后变为:
调用 clear() 方法切换为写模式,调用后 position 会指向初始位置,但是不会清空数据(图中没表示出来),写入时数据会覆盖之前的:
compact() 方法,是把未读完的部分向前压缩,然后切换至写模式:
Buffer 相关的重要方法
分配空间
- allocate 方法
使用 allocate 方法为 ByteBuffer 分配空间,其它 buffer 类也有该方法:
Bytebuffer byteBuffer = ByteBuffer.allocate(20);
一个缓冲区在创建后,处于写模式,position 属性的值为 0;容量 capacity 的值为 20,limmit 值也为 20。
向 Buffer 中写入数据
- Buffer 自己的 put() 方法
buffer.put((byte)127);
- channel 的 read 方法
int readBytes = channel.read(buf);
从 Buffer 中读取数据
- buffer 自己的 get 方法
byte b = buffer.get();
调用 get() 方法,每次从 position 的位置上读取一个数据,读取后 position 的值会更改为下一个数据的位置(加1)。
也可以传入 position 获取指定位置的值,比如 get(1)
,但是这样 position 的值不会发生改变。
- channel 的 write 方法
int writeBytes = channel.write(buf);
flip 方法
向缓冲区写入数据过后,还不能直接读取数据,因为现在缓冲区还处于写模式,需要调用 flip() 方法切换到读模式。
compact 方法
compact 方法,是把未读完的部分向前压缩,然后切换至写模式。
rewind 方法
已经读完的数据,如果需要再读一遍,可以调用rewind()方法。rewind()也叫倒带,就像播放磁带一样倒回去,再重新播放。
mark 方法和 reset 方法
mark 是在读取时,做一个标记,即使 position 改变,只要调用 reset 就能回到 mark 的位置。
注意:rewind 和 flip 都会清除 mark 位置
clear 方法
在读模式下,调用 clear() 方法清空缓冲区,并切换为写模式。
调研该方法后,会讲 position 属性的值更改为 0;
详解 NIO Channel 类
FileChannel 文件通道
FileChannel是专门操作文件的通道。通过FileChannel,既可以从一个文件中读取数据,也可以将数据写入到文件中。特别申明一下,FileChannel为阻塞模式,不能设置为非阻塞模式。
- 获取 FileChannel 通道
- 通过文件的输入输出流获取 FileChannel 通道
// 输入流获取的 channel 只能读
FileInputStream fis = new FileInputStream(srcFile);
FileChannel inChannel = fis.getChannel();
// 输出流获取的 channel 只能写
FileOutputStream fos = new FileOutputStream(destFile);
FileChannel outchannel = fos.getChannel();
- 通过RandomAccessFile文件随机访问类,获取FileChannel文件通道实例:
RandomAccessFile rFile = new RandomAccessFile(“filename.txt”,“rw”);
FileChannel channel = rFile.getChannel();
- 读取 FileChannel 通道
在大部分应用场景,从通道读取数据都会调用通道的 int read(ByteBuffer buf)
方法,它从通道读取到数据写入到ByteBuffer缓冲区,并且返回读取到的数据量。
RandomAccessFile aFile = new RandomAccessFile(fileName, “rw”);
//获取通道(可读可写)
FileChannel channel= aFile.getChannel();
//获取一个字节缓冲区
ByteBuffer buf = ByteBuffer.allocate(CAPACITY);
int length;
//调用通道的read方法,读取数据并买入字节类型的缓冲区
while ((length = channel.read(buf)) != -1) {
//……省略buf中的数据处理
}
- 写入 FileChannel 通道
写入数据到通道,在大部分应用场景,都会调用通道的write(ByteBuffer)方法,此方法的参数是一个ByteBuffer缓冲区实例,是待写数据的来源。
- 关闭 FileChannel 通道
Channel 使用完后必须关闭,不过调用输入输出流的 close() 方法会间接的关闭 Channel。
黏包、半包
网络上有多条数据发送给服务端,数据之间使用 \n 进行分隔但由于某种原因这些数据在接收时,被进行了重新组合,例如原始数据有3条为
- Hello,world\n
- I’m zhangsan\n
- How are you?\n
变成了下面的两个 byteBuffer
- Hello,world\nI’m zhangsan\nHo
- w are you?\n
第一条数据结尾包含了下一条数据的部分,叫做黏包,第二条数据前半部分不完成,叫做半包。
要将数据还原为原始得三条数据,需要在读取数据时做处理黏包和半包:
public class Test {
public static void main(String[] args) {
ByteBuffer buffer = ByteBuffer.allocate(32);
buffer.put("Hello,world\nI'm zhangsan\nHo".getBytes());
split(buffer);
buffer.put("w are you?\nhaha!\n".getBytes());
split(buffer);
}
// 处理 黏包、半包
private static void split(ByteBuffer buffer) {
// 切换为读模式
buffer.flip();
int oldLimit = buffer.limit();
for(int i = 0; i < oldLimit; i ++) {
if(buffer.get(i) == '\n') {
ByteBuffer target = ByteBuffer.allocate(i + 1 - buffer.position());
// 将 limit 属性设置为 \n 的位置
buffer.limit(i + 1);
// 将 buffer 中的数据读到 target 中
// limit 从新设置过了,所有会读到 \n 的位置
target.put(buffer);
target.flip();
while (target.hasRemaining()) {
System.out.print((char)target.get());
}
// 将 limit 设置为 oldLimit
buffer.limit(oldLimit);
}
}
// 最后一个 \n 后的数据没有读取,所以调用 compact 方法将数据压缩到最前面
buffer.compact();
}
}
文件操作
- 读取文件内容
@Test
public void testReadFile() throws Exception {
FileInputStream fis = new FileInputStream("niotest1.txt");
FileChannel channel = fis.getChannel();
ByteBuffer buffer = ByteBuffer.allocate(20);
while(channel.read(buffer) != -1) {
// 设置 buffer 为读模式
buffer.flip();
// 读取 buffer 中的数据
while (buffer.hasRemaining()) {
byte b = buffer.get();
System.out.print( (char) b);
}
// 读完之后设置 buffer 为写模式
buffer.clear();
}
fis.close();
}
- 写入文件内容
@Test
public void testWriteFile() throws Exception{
FileChannel channel = new FileOutputStream("data.txt").getChannel();
ByteBuffer buffer = Charset.forName("UTF-8").encode("1234567890");
int write = channel.write(buffer);
System.out.println("write = " + write);
channel.close();
}
- 文件复制
@Test
public void testCopyFile() throws Exception {
FileChannel from = new FileInputStream("data.txt").getChannel();
FileChannel to = new FileOutputStream("data2.txt").getChannel();
// transferTo 方法最大限制2g, 如果文件大小超过 2g,文件会复制不全
// left 为剩余要复制内容的数量
long left = from.size();
while (left > 0) {
// transferTo 返回值为剩余为复制的内容数量
left = left - from.transferTo(from.size()-left, left, to);
}
to.close();
from.close();
}