NIO 详解
欢迎光临我的博客[http://poetize.cn],前端使用Vue2,聊天室使用Vue3,后台使用Spring Boot
同步非阻塞
NIO之所以是同步,是因为它的accept
read
write
方法的内核I/O操作
都会阻塞当前线程
IO模型 | IO | NIO |
---|---|---|
通信 | 面向流(Stream Oriented) | 面向缓冲区(Buffer Oriented) |
处理 | 阻塞IO(Blocking IO) | 非阻塞IO(Non Blocking IO) |
触发 | (无) | 选择器(Selectors) |
Channel(通道)
Channel是一个对象,可以通过它读取和写入数据。可以把它看做是IO中的流,不同的是:
Channel是双向的(NIO面向缓冲区,双向传输),既可以读又可以写,而流是单向的(传统IO操作是面向流,单向传输)
Channel可以进行异步的读写
对Channel的读写必须通过buffer对象
buffer负责存储数据,channel负责传输数据
在Java NIO中的Channel主要有如下几种类型(Java 针对支持通道的类提供了一个 getChannel() 方法):
FileChannel:从文件读取数据的
DatagramChannel:读写UDP网络协议数据
SocketChannel:读写TCP网络协议数据
ServerSocketChannel:可以监听TCP连接
Buffer(缓冲区)
Buffer是一个对象(实质上是一个数组,通常是一个字节数据):
它包含一些要写入或者读取Stream对象的,用于读写操作(put():存,get():取)
它包含四个属性:
capacity(总容量)
limit(当前可用容量)
position(正在操作数据的位置)
mark(标记当前position,可通过reset()恢复mark位置)
使用 Buffer 读写数据一般遵循以下四个步骤:
1.写入数据到 Buffer;
2.调用 flip() 方法(flip() 方法将 Buffer 从写模式切换到读模式);
3.从 Buffer 中读取数据(读完了所有的数据,就需要清空缓冲区);
4.调用 clear() 方法或者 compact() 方法。
有两种方式能清空缓冲区:
调用 clear() 或 compact() 方法。
clear() 方法会清空整个缓冲区。
compact() 方法只会清除已经读过的数据。任何未读的数据都被移到缓冲区的起始处,新写入的数据将放到缓冲区未读数据的后面。
Buffer主要有如下几种:
ByteBuffer
CharBuffer
DoubleBuffer
FloatBuffer
IntBuffer
LongBuffer
ShortBuffer
直接缓冲区与非直接缓冲区
非直接缓冲区(直接在堆内存中开辟空间,也就是数组):
通过allocate()方法分配缓冲区,将缓冲区建立在JVM的内存中。
在每次调用操作系统的IO之前或者之后,虚拟机都会将缓冲区的内容复制到中间缓冲区(或者从中间缓冲区复制内容),
缓冲区的内容驻留在JVM内,因此销毁容易,但是占用JVM内存开销,处理过程中有复制操作。
非直接缓冲区的写入步骤:
创建一个临时的ByteBuffer对象。
将非直接缓冲区的内容复制到临时缓冲中。
使用临时缓冲区执行低层次I/O操作。
临时缓冲区对象离开作用域,并最终成为被回收的无用数据。
直接缓冲区(直接调用了内存页,让操作系统开辟缓存空间):
通过allocateDirect()方法分配直接缓冲区,将缓冲区建立在物理内存中,减少一次复制过程,可以提高效率。
虽然直接缓冲可以进行高效的I/O操作,但它使用的内存是操作系统分配的,绕过了JVM堆栈,建立和销毁比堆栈上的缓冲区要更大的开销
简单应用
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
public class CopyFile {
public static void copyFile(String src, String dst) throws IOException {
//源文件输入流
FileInputStream fi = new FileInputStream(new File(src));
//目标文件输出流
FileOutputStream fo = new FileOutputStream(new File(dst));
//获得传输通道channel
FileChannel inChannel = fi.getChannel();
FileChannel outChannel = fo.getChannel();
//获得容器buffer
ByteBuffer buffer = ByteBuffer.allocate(1024);
while (true) {
//判断是否读完文件:阻塞式
int eof = inChannel.read(buffer);
if (eof == -1) {
break;
}
//重设一下buffer的limit=position,position=0
buffer.flip();
//开始写
outChannel.write(buffer);
//写完要重置buffer,重设position=0,limit=capacity
buffer.clear();
}
inChannel.close();
outChannel.close();
fi.close();
fo.close();
}
}
分散(Scatter)与聚集(Gather)
分散读取(Scattering Reads):将通道中的数据分散到多个缓冲区中
聚集写入(Gathering Writes):将多个缓冲区中的数据聚集到通道中
public void rw() throws IOException {
// rw:代表读写模式
RandomAccessFile file = new RandomAccessFile("E:\\小视频.mp4","rw");
//获取通道
FileChannel channel = file.getChannel();
// 分配制定缓冲区
ByteBuffer byteBuffer1 = ByteBuffer.allocate(1024*2);
ByteBuffer byteBuffer2 = ByteBuffer.allocate(1024*2);
ByteBuffer byteBuffer3 = ByteBuffer.allocate(1024*2);
// 分散读取
ByteBuffer[] buffers= {byteBuffer1,byteBuffer2,byteBuffer3};
channel.read(buffers);
for (ByteBuffer buffer : buffers) {
buffer.flip();
}
// 聚集写入
RandomAccessFile file2 = new RandomAccessFile("E:\\我的小视频.mp4","rw");
// 获取通道
FileChannel channel2 = file2.getChannel();
channel2.write(buffers);
channel.close();
channel2.close();
}
字符集Charset
设置字符集,解决乱码问题
编码:字符串->字节数组
解码:字节数组->字符串
public static void test() throws CharacterCodingException {
//获取NIO字符集
Charset cs = Charset.forName("utf-8");
//获取编码器
CharsetEncoder ce = cs.newEncoder();
//获取解码器
CharsetDecoder cd = cs.newDecoder();
//申请1024字节的空间地址
CharBuffer cb = CharBuffer.allocate(1024);
//写入内容
cb.put("ld加油");
//转化为读模式
cb.flip();
//编码
ByteBuffer bB = ce.encode(cb);
//编码后的内容
//以什么编码就以什么解码
//GBK一个中文字符占两个字节
//UTF-8一个中文占三个字节
for (int i = 0; i < 8; i++) {
//get()返回字节之后,position会自动加1
//get(index)返回字节后,position并未移动
System.out.println(bB.get());
}
//转化为读模式
bB.flip();
//解码
System.out.println(cd.decode(bB));
}
通道到通道传输
toChannel.transferFrom(fromChannel, position, count)
方法将数据从fromChannel源通道传输到toChannel目的通道
fromChannel.transferTo(position, count, toChannel)
方法将数据从fromChannel源通道传输到toChannel目的通道
//源文件
RandomAccessFile fromFile = new RandomAccessFile("fromFile.txt", "rw");
FileChannel fromChannel = fromFile.getChannel();
//目标文件
RandomAccessFile toFile = new RandomAccessFile("toFile.txt", "rw");
FileChannel toChannel = toFile.getChannel();
long position = 0;
long count = fromChannel.size();
//传输
toChannel.transferFrom(fromChannel, position, count);
fromChannel.transferTo(position, count, toChannel);
NIO的非阻塞方式
Selector(选择器)是非阻塞 IO 的核心:将用于传输的通道全部注册到选择器上
选择器和通道的关系:通道注册到选择器上,选择器监控通道(监控这些通道的IO状况(读,写,连接,接收数据的情况等状况))
当某一通道,某一个事件就绪之后,选择器才会将这个通道分配到服务器端的一个或多个线程上
Selector(选择器)是SelectableChannle(通道)对象的多路复用器,
Selector可以同时监控多个SelectableChannel的 IO 状况
选择器(Selector)
Selector的好处在于:使用更少的线程来就可以来处理通道了,相比使用多个线程,避免了线程上下文切换带来的开销
每次请求到达服务器,都是从connect开始,connect成功后,服务端开始准备accept,准备就绪,开始读数据,并处理,最后写回数据返回
创建 Selector:
通过调用 Selector.open() 方法创建一个 Selector
向选择器注册通道(register()方法会返回一个SelectionKey对象,称之为键对象):
SelectionKey key= SelectableChannel.register(Selector sel, int interestSet);
再次调用:SelectableChannel.register(selector, interestSet);
方法会直接对兴趣进行重新赋值,也就是会覆盖掉之前的兴趣设置
public SelectionKey nioInterestOps(int ops) {
if ((ops & ~channel().validOps()) != 0)
throw new IllegalArgumentException();
interestOps = ops;
selector.setEventOps(this);
return this;
}
ServerSocketChannel只有OP_ACCEPT可用,OP_CONNECT,OP_READ,OP_WRITE用于SocketChannel
可以监听的事件类型(interestSet 可使用 SelectionKey 的四个常量表示):
读 : SelectionKey.OP_READ(1)
写 : SelectionKey.OP_WRITE(4)
连接 : SelectionKey.OP_CONNECT(8)
接收 : SelectionKey.OP_ACCEPT(16)
客户端的SocketChannel支持:
OP_CONNECT, OP_READ, OP_WRITE三个操作。
服务端ServerSocketChannel只支持OP_ACCEPT操作:
在服务端由ServerSocketChannel的accept()方法产生的SocketChannel只支持OP_READ, OP_WRITE操作。
如果你对不止一种事件感兴趣,使用或运算符即可,如下:
int interestSet = SelectionKey.OP_READ | SelectionKey.OP_WRITE;
方法:
public abstract Set<SelectionKey> keys(); //所有注册的SelectionKeys集合
public abstract Set<SelectionKey> selectedKeys(); //至少有一个感兴趣的事件ready的keys集合
protected final Set<SelectionKey> cancelledKeys(); //被cancel掉的keys以及对应channel被close的keys
wakeup():
调用该方法会时,阻塞在select()处的线程会立马返回
(ps:下面一句划重点)
即使当前不存在线程阻塞在select()处,
那么下一个执行select()方法的线程也会立即返回结果,相当于执行了一次selectNow()方法
close():
用完Selector后调用其close()方法会关闭该Selector,
且使注册到该Selector上的所有SelectionKey实例无效。channel本身并不会关闭。
select():
选择已经准备就绪的通道 (这些通道包含你感兴趣的的事件)
Selector几个重载的select()方法:
int select():阻塞到至少有一个通道在你注册的事件上就绪了。
int select(long timeout):和select()一样,但最长阻塞时间为timeout毫秒。无论是否有读写等事件发生,selector每隔timeout都被唤醒一次
int selectNow():非阻塞,只要有通道就绪就立刻返回。
select()方法返回的int值表示有多少通道已经就绪
一旦调用select()方法,并且返回值不为0时,则可以通过调用Selector的selectedKeys()方法来访问已选择键集合
SelectionKey(键对象)
SelectionKey:表示 SelectableChannel 和 Selector 之间的注册关系:
SelectionKey对象包含了以下四种属性:
interest集合(最初,该兴趣集合是通道被注册到Selector时传进来的值):判断Selector是否对Channel的某种事件感兴趣:
int interestSet = selectionKey.interestOps();
boolean isInterestedInAccept = (interestSet & SelectionKey.OP_ACCEPT) == SelectionKey.OP_ACCEPT;
read集合:通道已经就绪的操作的集合
int readSet=selectionKey.readOps();
//等价于selectionKey.readyOps() & SelectionKey.OP_ACCEPT
selectionKey.isAcceptable(); //检测 Channel 中接收是否就绪(server socket channel准备好接收新进入的连接)
//检测 Channel 中连接是否就绪(channel成功连接到一个服务器)(当客户端调用SocketChannel.connect()时,该操作会就绪)
selectionKey.isConnectable();
//检测 Channal 中读事件是否就绪(有数据可读的通道)(当OS的读缓冲区中有数据可读时,该操作就绪)
selectionKey.isReadable();
//检测 Channal 中写事件是否就绪(等待写数据的通道)(当OS的写缓冲区中有空闲的空间时(大部分时候都有),该操作就绪)
selectionKey.isWritable();
SelectableChannel channel():返回该SelectionKey对应的channel通道
Selector selector():返回回该SelectionKey对应的Selector选择器
方法:
public abstract boolean isValid()
判断此密钥是否有效。
密钥在创建时有效并保持不变,直到它被取消,其通道关闭或其选择器关闭。
public abstract void cancel()
请求取消此密钥通道及其选择器的注册。
返回时,密钥将无效,并且将被添加到其选择器的已取消密钥集中。
在下一个选择操作期间,该键将从所有选择器的键集中删除。
//如果try()catch()捕获到该SelectionKey对应的Channel出现了异常,即表明该Channel对应的Client出现了问题
//应从Selector中取消该SelectionKey的注册
key.cancel();
if (key.channel() != null) {
key.channel().close();
}
public abstract SelectionKey interestOps(int ops);
直接对兴趣进行重新赋值,也就是会覆盖掉之前的兴趣设置
public SelectionKey interestOps(int ops) {
ensureValid();
if ((ops & ~channel().validOps()) != 0)
throw new IllegalArgumentException();
int oldOps = (int) INTERESTOPS.getAndSet(this, ops);
if (ops != oldOps) {
selector.setEventOps(this);
}
return this;
}
缓冲区绝大部分事件都是有空闲空间的,所以当你注册写事件后,写操作一直就是就绪的,
这样会导致Selector处理线程会占用整个CPU的资源。
所以最佳实践是当你确实有数据写入时再注册OP_WRITE事件,并且在写完以后马上取消注册。
注册OP_WRITE是比较好的做法,注册方式有两种:
直接注册到SocketChannel:
SocketChannel.register(selector, SelectionKey.OP_WRITE),这种方式直接用SocketChannel来写ByteBuffer
SelectonKey方式注册:
SelectionKey.interestOps(SelectionKey.interestOps() | SelectionKey.OP_WRITE)
取消注册:写操作就绪,将之前写入缓冲区的数据写入到Channel,并取消注册
channel.write(writeBuffer);
key.interestOps(key.interestOps() & ~SelectionKey.OP_WRITE);
当read读操作时:
len > 0 网络正常,且收到数据;
len = 0 网络正常,没有收到数据;
len < 0 网络已中断
客户端断开后,需要手动关闭channel:readChannel.close();
read什么时候返回-1
read返回-1说明客户端的数据发送完毕,并且主动的close socket。
所以在这种场景下,(服务器程序)你需要关闭socketChannel并且取消key,最好是退出当前函数。
注意,这个时候服务端要是继续使用该socketChannel进行读操作的话,就会抛出“远程主机强迫关闭一个现有的连接”的IO异常。
read什么时候返回0
一是某一时刻socketChannel中当前(注意是当前)没有数据可以读,这时会返回0,
其次是bytebuffer的position等于limit了,即bytebuffer的remaining等于0,
最后一种情况就是客户端的数据发送完毕了(注意看后面的程序里有这样子的代码),
这个时候客户端想获取服务端的反馈调用了recv函数,若服务端继续read,这个时候就会返回0。
非阻塞客户端
public static void client() throws IOException, InterruptedException {
//创建socket连接通道
SocketChannel socketChannel = SocketChannel.open(new InetSocketAddress("127.0.0.1", 10000));
//切换成非阻塞模式
socketChannel.configureBlocking(false);
/**
* 发送数据到服务器
*/
System.out.println("开始发送数据!");
//开辟缓存区
ByteBuffer clientBuffer = ByteBuffer.allocate(1024);
String inputStr = "你好,明天!";
clientBuffer.put((new Date().toString() + "---" + inputStr + "\r\n").getBytes());
clientBuffer.flip();
socketChannel.write(clientBuffer);
clientBuffer.clear();
Thread.sleep(1000);
/**
* 从服务端接收数据
*/
System.out.println("从服务端接收数据!");
ByteBuffer buffer = ByteBuffer.allocate(1024);
StringBuffer buf = new StringBuffer();
int count = 0;
while ((count = socketChannel.read(buffer)) > 0) {
byte[] array = buffer.array();
buffer.clear();
buf.append(new String(array, 0, count));
}
if (buf.length() > 0) {
System.out.println(buf.toString());
}
/**
* 关闭客户端管道
*/
System.out.println("关闭客户端!");
socketChannel.close();
}
非阻塞服务端
public static void server() throws IOException {
//ServerSocketChannel是一个可以监听新进来的TCP连接的通道,就像标准IO中的ServerSocket一样
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
serverSocketChannel.configureBlocking(false);
//绑定连接
serverSocketChannel.bind(new InetSocketAddress(10000));
//选择器
Selector selector = Selector.open();
//将通道注册到选择器上,并制定监听事件:服务端首先要监听客户端的接受状态
SelectionKey register = serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
//阻塞式,没有就行就阻塞在这
while (selector.select() > 0) {
System.out.println("selector.select()轮询");
//获取已经就绪的监听事件
Iterator<SelectionKey> selectorIterator = selector.selectedKeys().iterator();
while (selectorIterator.hasNext()) {
// 获取准备就绪的事件
SelectionKey key = selectorIterator.next();
SocketChannel socketChannel = null;
if (key.isAcceptable()) {
//获取服务端通道
ServerSocketChannel serverChannel = (ServerSocketChannel) key.channel();
//接受成功连接到服务器的channel,则获取客户端连接
socketChannel = serverChannel.accept();
socketChannel.configureBlocking(false);
//将该客户端通道注册到选择器上,监控客户端socketChannel的“读就绪”事件
socketChannel.register(selector, SelectionKey.OP_READ);
} else if (key.isValid() && key.isReadable()) {
SocketChannel readChannel = null;
FileChannel outputChannel = null;
try {
System.out.println("开始读!");
//获取当前选择器上“读就绪”状态的通道
readChannel = (SocketChannel) key.channel();
readChannel.configureBlocking(false);
//创建文件输出管道:FileChannel不能使用非阻塞模式
//从服务端接收文件,并将文件写到本地(写方式,如果文件不存在就创建)
outputChannel = FileChannel.open(Paths.get("E:\\小视频.txt"), StandardOpenOption.WRITE, StandardOpenOption.CREATE);
//创建缓冲区,进行读写操作
ByteBuffer readBuffer = ByteBuffer.allocate(1024);
while ((readChannel.read(readBuffer)) > 0) {
readBuffer.flip();
outputChannel.write(readBuffer);
readBuffer.clear();
}
System.out.println("读完了,加入写兴趣!");
key.interestOps(key.interestOps() | SelectionKey.OP_WRITE);
if ((readChannel.read(readBuffer)) == -1) {
System.out.println("客户端断开连接!");
key.cancel();
try {
readChannel.close();
outputChannel.close();
} catch (IOException e1) {
e1.printStackTrace();
}
}
} catch (IOException e) {
System.out.println("异常关闭!");
//当客户端关闭channel时,服务端再往通道缓冲区中写或读数据,都会报IOException
//解决方法是:在服务端这里捕获掉这个异常,并且关闭掉服务端这边的Channel通道
key.cancel();
try {
readChannel.close();
outputChannel.close();
} catch (IOException e1) {
e1.printStackTrace();
}
}
} else if (key.isValid() && key.isWritable()) {
try {
System.out.println("开始写!");
SocketChannel channel = (SocketChannel) key.channel();
channel.write(ByteBuffer.wrap("收到!".getBytes()));
System.out.println("写完了,取消写兴趣!");
key.interestOps(key.interestOps() & ~SelectionKey.OP_WRITE);
} catch (Exception e) {
e.printStackTrace();
}
}
}
/**
* selector不会自己删除selectedKeys()集合中的selectionKey,
* 如果不人工remove(),
* 将导致下次select()的时候selectedKeys()中仍有上次轮询留下来的信息,这样必然会出现错误
* 应此当某个消息被处理后我们需要从该集合里去掉
*/
selectorIterator.remove();
}
}
管道Pipe
Java NIO 管道是2个线程之间的单向数据连接。Pipe有一个sink通道和一个source通道。
数据会写到sink通道,从source通道读取,
打开管道:Pipe pipe = Pipe.open();
public class Pipe {
public static void main(String[] args) throws IOException {
// 1. 获取管道
Pipe pipe = Pipe.open();
// 2. 将缓冲区数据写入到管道
// 2.1 获取一个通道
Pipe.SinkChannel sinkChannel = pipe.sink();
// 2.2 定义缓冲区
ByteBuffer buffer = ByteBuffer.allocate(48);
buffer.put("发送数据".getBytes());
buffer.flip(); //切换数据模式
// 2.3 将数据写入到管道
sinkChannel.write(buffer);
// 3. 从管道读取数据
Pipe.SourceChannel sourceChannel = pipe.source();
buffer.flip();
int len = sourceChannel.read(buffer);
System.out.println(new String(buffer.array(),0,len));
// 4. 关闭管道
sinkChannel.close();
sourceChannel.close();
}
}