什么是NIO?
NIO的三大组件 Channel&Buffer&Selector
Channel表示数据传输通道,Buffer表示容纳数据的缓冲区,通道负责传输,缓冲区负责存储
常见的Channel有以下四种:
- FileChannel:文件传输通道
- DatagramChannel:UDP传输通道
- SocketChannel:TCP传输通道
- ServerSocketChannel:服务端传输通道
Buffer有以下几种,其中使用较多的是ByteBuffer
-
ByteBuffer
1.MappedByteBuffer 2.DirectByteBuffer 3.HeapByteBuffer
1.Selector 选择器
selector 的作用就是配合一个线程来管理多个 channel(fileChannel因为是阻塞式的,所以无法使用selector),获取这些 channel 上发生的事件,这些 channel 工作在非阻塞模式下,当一个channel中没有执行任务时,可以去执行其他channel中的任务。适合连接数多,但流量较少的场景
若事件未就绪,调用 selector 的 select() 方法会阻塞线程,直到 channel 发生了就绪事件。这些事件就绪后,select 方法就会返回这些事件交给 thread 来处理
ByteBuffer的结构
ByteBuffer的核心属性
- capacity:容量
- position:读写指针
- limit:读写限制
字符串与ByteBuffer的相互转换
编码:通过StandardCharsets的encode方法获得ByteBuffer,此时获得的ByteBuffer为读模式,无需通过flip切换模式
解码:通过StandardCharsets的decoder方法解码
public class TestByteBufferToString {
public static void main(String[] args) {
String str="hello world";
ByteBuffer byteBuffer = StandardCharsets.UTF_8.encode(str);
// 读取ByteBuffer中的数据
// while (byteBuffer.hasRemaining()){
// System.out.print((char)byteBuffer.get()+" ");
// }
String s = StandardCharsets.UTF_8.decode(byteBuffer).toString();
System.out.println(s);
}
}
粘包与半包
现象
网络上有多条数据发送给服务端,数据之间使用 \n 进行分隔
但由于某种原因这些数据在接收时,被进行了重新组合,例如原始数据有3条为
- Hello,world\n
- I’m Nyima\n
- How are you?\n
变成了下面的两个 byteBuffer (粘包,半包)
- Hello,world\nI’m Nyima\nHo(粘包)
- w are you?\n(半包)
出现原因
粘包
发送方在发送数据时,并不是一条一条地发送数据,而是将数据整合在一起,当数据达到一定的数量后再一起发送。这就会导致多条信息被放在一个缓冲区中被一起发送出去
半包
接收方的缓冲区的大小是有限的,当接收方的缓冲区满了以后,就需要将信息截断,等缓冲区空了以后再继续放入数据。这就会发生一段完整的数据最后被截断的现象
解决办法
编写split方法对数据进行分包,同时需要注意get(i)方法不会改变position的值,而get()方法读取数据后会使position的值+1,调用compact方法切换为写模式,因为缓冲区中可能还有未读的数据。
public static void main(String[] args) {
// 模拟粘包+半包
ByteBuffer buffer = ByteBuffer.allocate(32);
buffer.put("Hello,world\nI'm Nyima\nHo".getBytes());
split(buffer);
buffer.put("w are you?\n".getBytes());
split(buffer);
}
static void split(ByteBuffer buffer){
//切换读模式
buffer.flip();
for (int i = 0; i < buffer.limit(); i++) {
if (buffer.get(i)=='\n'){
//写入新buffer
int length=i-buffer.position()+1;
ByteBuffer newbuffer =ByteBuffer.allocate(length);
for (int j = 0; j < length; j++) {
newbuffer.put( buffer.get());
}
debugAll(newbuffer);
}
}
//切换写模式
buffer.compact();
}
文件编程-----FileChannel
获取
不能直接打开 FileChannel,必须通过 FileInputStream、FileOutputStream 或者 RandomAccessFile 来获取 FileChannel,它们都有 getChannel 方法
- 通过 FileInputStream 获取的 channel 只能读
- 通过 FileOutputStream 获取的 channel 只能写
- 通过 RandomAccessFile 是否能读写根据构造 RandomAccessFile 时的读写模式决定 读取
两个Channel传输数据
public static void main(String[] args) {
try(
FileChannel from = new FileInputStream("data.txt").getChannel();
FileChannel to = new FileInputStream("to.txt").getChannel();
) {
//效率高,底层使用了零拷贝
from.transferTo(0,from.size(),to);
}catch (Exception e){
e.printStackTrace();
}
}
当传输的文件大于2G时,需要使用以下方法进行多次传输
public static void main(String[] args) {
try(
FileChannel from = new FileInputStream("data.txt").getChannel();
FileChannel to = new FileInputStream("to.txt").getChannel();
) {
long size = from.size();
for (long left = size; left >=0; ) {
//actualByte是实际的传输字节数
long actualByte = from.transferTo(size-left, left, to);
left=left-actualByte;
}
}catch (Exception e){
e.printStackTrace();
}
}
网络编程
单线程可以配合一个 Selector 完成对多个 Channel 可读写事件的监控,这称之为多路复用
Selector实现IO多路复用
1.获取Selector和Channel
2.将Channel设置成非阻塞模式
3.将Channel注册到Selector中,并设绑定对应的事件
绑定的事件类型可以有
- connect - 客户端连接成功时触发
- accept - 服务器端成功接受连接时触发
- read - 数据可读入时触发,有因为接收能力弱,数据暂不能读入的情况
- write - 数据可写出时触发,有因为发送能力弱,数据暂不能写出的情况
Accpet事件
public void testServer1() throws IOException {
// 1. 创建 selector 来管理多个channel
Selector selector = Selector.open();
// 创建一个 服务器 对象 通道
ServerSocketChannel ssc = ServerSocketChannel.open();
// selector 必须工作在非阻塞模式下 影响 accept 变成 非阻塞方法
ssc.configureBlocking(false);
/**2. 建立 selector 和 服务器 的连接 (将服务器连接通道 注册 到 selector)
* 通过SelectionKey 可以知道事件 和 知道哪个channel通道
* 第二参数:0 不关注任何事件
*/
SelectionKey sscKey = ssc.register(selector, 0, null);
// 指明 SelectionKey 绑定的事件 selector 才会关心
sscKey.interestOps(SelectionKey.OP_ACCEPT);
log.debug("register sscKey: {}", sscKey);
// 绑定一个 监听端口
ssc.bind(new InetSocketAddress(8080));
while(true)
{
System.out.println("before 循环 ......");
// 3. 选择器,有未处理或未取消事件,不阻塞 继续运行 ; 否则 阻塞
selector.select();
// 4. 处理事件
// selectKeys 内部包含所有发生的事件,譬如两个客户端连上了 会有两个key
// 如果在遍历里 还可以删除 必须用迭代器
Iterator<SelectionKey> iter = selector.selectedKeys().iterator();
while(iter.hasNext())
{
SelectionKey key = iter.next();
log.debug("--------------- 有事件 进来 了,whatKey ----------------");
// 每次迭代完 要移除掉,不然 可读事件 进来 循环时, 先判断肯定是ssc accept事件,
// 但是此时没有连接事件(这个事件还是上一次的,事件不会自己删除),所以在处理时 sc=channel.accept() 是null ,
// 下面进一步处理时,就报空指针异常
iter.remove(); // selectedKeys 里删除
// 5. 根据 事件类型 处理
if(key.isAcceptable()) // accept -客户端连接请求触发 (服务端 事件)
{
log.debug("acceptKey: {}", key);
// 获取 服务器对象通道
ServerSocketChannel channel = (ServerSocketChannel) key.channel();
// 获取 读写通道
// 调用accept方法表示处理Accept事件
SocketChannel sc = channel.accept();
// selector 必须工作在非阻塞模式下 影响 read 变成 非阻塞方法
sc.configureBlocking(false);
// 读写通道 SocketChannel 注册到 selector 上
SelectionKey scKey = sc.register(selector, 0, null);
scKey.interestOps(SelectionKey.OP_READ);
log.debug("SocketChannel sc : {}", sc);
}
}
System.out.println("end 循环 ......");
}
}
事件发生后能否不处理?
事件发生后,要么处理,要么取消(cancel),不能什么都不做,否则下次该事件仍会触发,这是因为 nio 底层使用的是水平触
Read事件
- 在Accept事件中,若有客户端与服务器端建立了连接,需要将其对应的SocketChannel设置为非阻塞,并注册到选择其中
- 添加Read事件,触发后进行读取操作
public class SelectServer {
public static void main(String[] args) {
ByteBuffer buffer = ByteBuffer.allocate(16);
// 获得服务器通道
try(ServerSocketChannel server = ServerSocketChannel.open()) {
server.bind(new InetSocketAddress(8080));
// 创建选择器
Selector selector = Selector.open();
// 通道必须设置为非阻塞模式
server.configureBlocking(false);
// 将通道注册到选择器中,并设置感兴趣的实践
server.register(selector, SelectionKey.OP_ACCEPT);
// 为serverKey设置感兴趣的事件
while (true) {
// 若没有事件就绪,线程会被阻塞,反之不会被阻塞。从而避免了CPU空转
// 返回值为就绪的事件个数
int ready = selector.select();
System.out.println("selector ready counts : " + ready);
// 获取所有事件
Set<SelectionKey> selectionKeys = selector.selectedKeys();
// 使用迭代器遍历事件
Iterator<SelectionKey> iterator = selectionKeys.iterator();
while (iterator.hasNext()) {
SelectionKey key = iterator.next();
// 判断key的类型
if(key.isAcceptable()) {
// 获得key对应的channel
ServerSocketChannel channel = (ServerSocketChannel) key.channel();
System.out.println("before accepting...");
// 获取连接
SocketChannel socketChannel = channel.accept();
System.out.println("after accepting...");
// 设置为非阻塞模式,同时将连接的通道也注册到选择其中
socketChannel.configureBlocking(false);
socketChannel.register(selector, SelectionKey.OP_READ);
// 处理完毕后移除
iterator.remove();
} else if (key.isReadable()) {
SocketChannel channel = (SocketChannel) key.channel();
System.out.println("before reading...");
channel.read(buffer);
System.out.println("after reading...");
buffer.flip();
ByteBufferUtil.debugRead(buffer);
buffer.clear();
// 处理完毕后移除
iterator.remove();
}
}
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
删除事件
当处理完一个事件后,一定要调用迭代器的remove方法移除对应事件,否则会出现错误。因为selecionKey不会自动移除
断开处理
当客户端与服务器之间的连接断开时,会给服务器端发送一个读事件,对异常断开和正常断开需要加以不同的方式进行处理
正常断开
正常断开时,服务器端的channel.read(buffer)方法的返回值为-1,所以当结束到返回值为-1时,需要调用key的cancel方法取消此事件,并在取消后移除该事件
int read = channel.read(buffer);
// 断开连接时,客户端会向服务器发送一个写事件,此时read的返回值为-1
if(read == -1) {
// 取消该事件的处理
key.cancel();
channel.close();
} else {
...
}
// 取消或者处理,都需要移除key
iterator.remove();
异常断开
异常断开时,会抛出IOException异常, 在try-catch的catch块中捕获异常并调用key的cancel方法即可
Writer事件
服务器通过Buffer向通道中写入数据时,可能因为通道容量小于Buffer中的数据大小,导致无法一次性将Buffer中的数据全部写入到Channel中,这时便需要分多次写入,具体步骤如下
- 执行一次写操作,向将buffer中的内容写入到SocketChannel中,然后判断Buffer中是否还有数据
- 若Buffer中还有数据,则需要将SockerChannel注册到Seletor中,并关注写事件,同时将未写完的Buffer作为附件一起放入到SelectionKey中
int write = socket.write(buffer);
// 通道中可能无法放入缓冲区中的所有数据
if (buffer.hasRemaining()) {
// 注册到Selector中,关注可写事件,并将buffer添加到key的附件中
socket.configureBlocking(false);
socket.register(selector, SelectionKey.OP_WRITE, buffer);
}
- 添加写事件的相关操作key.isWritable(),对Buffer再次进行写操作
- 每次写后需要判断Buffer中是否还有数据(是否写完)。若写完,需要移除SelecionKey中的Buffer附件,避免其占用过多内存,同时还需移除对写事件的关注
if (key.isWritable()) {
SocketChannel socket = (SocketChannel) key.channel();
// 获得buffer
ByteBuffer buffer = (ByteBuffer) key.attachment();
// 执行写操作
int write = socket.write(buffer);
System.out.println(write);
// 如果已经完成了写操作,需要移除key中的附件,同时不再对写事件感兴趣
if (!buffer.hasRemaining()) {
key.attach(null);
key.interestOps(0);
}
}
整体代码如下
public class WriteServer {
public static void main(String[] args) {
try(ServerSocketChannel server = ServerSocketChannel.open()) {
server.bind(new InetSocketAddress(8080));
server.configureBlocking(false);
Selector selector = Selector.open();
server.register(selector, SelectionKey.OP_ACCEPT);
while (true) {
selector.select();
Set<SelectionKey> selectionKeys = selector.selectedKeys();
Iterator<SelectionKey> iterator = selectionKeys.iterator();
while (iterator.hasNext()) {
SelectionKey key = iterator.next();
// 处理后就移除事件
iterator.remove();
if (key.isAcceptable()) {
// 获得客户端的通道
SocketChannel socket = server.accept();
// 写入数据
StringBuilder builder = new StringBuilder();
for(int i = 0; i < 500000000; i++) {
builder.append("a");
}
ByteBuffer buffer = StandardCharsets.UTF_8.encode(builder.toString());
// 先执行一次Buffer->Channel的写入,如果未写完,就添加一个可写事件
int write = socket.write(buffer);
System.out.println(write);
// 通道中可能无法放入缓冲区中的所有数据
if (buffer.hasRemaining()) {
// 注册到Selector中,关注可写事件,并将buffer添加到key的附件中
socket.configureBlocking(false);
socket.register(selector, SelectionKey.OP_WRITE, buffer);
}
} else if (key.isWritable()) {
SocketChannel socket = (SocketChannel) key.channel();
// 获得buffer
ByteBuffer buffer = (ByteBuffer) key.attachment();
// 执行写操作
int write = socket.write(buffer);
System.out.println(write);
// 如果已经完成了写操作,需要移除key中的附件,同时不再对写事件感兴趣
if (!buffer.hasRemaining()) {
key.attach(null);
key.interestOps(0);
}
}
}
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
多线程优化
充分利用多核CPU,分两组选择器
BOSS选择器专门负责处理Accept事件,worke选择器r专门处理读写事件