一、阻塞
服务器端代码:
package com.test.c3.block;
import com.test.utils.ByteBufferUtil;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.util.ArrayList;
import java.util.List;
public class Server {
public static void main(String[] args) {
ByteBuffer byteBuffer = ByteBuffer.allocate(16);
//建立一个服务端通道
try (ServerSocketChannel ssc = ServerSocketChannel.open();){
//绑定端口
ssc.bind(new InetSocketAddress(8080));
List<SocketChannel> list = new ArrayList<>();
while (true){
System.out.println("before connecting...");
//获取链接,当没有链接的时候 会阻塞
SocketChannel sc = ssc.accept();
list.add(sc);
System.out.println("after connecting... " + sc);
for (SocketChannel socketChannel : list) {
System.out.println("before reading");
//等待读取数据,会阻塞
socketChannel.read(byteBuffer);
byteBuffer.flip();
ByteBufferUtil.debugRead(byteBuffer);
byteBuffer.clear();
System.out.println("after reading");
}
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
客户端:
package com.test.c3.block;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.channels.SocketChannel;
public class Client {
public static void main(String[] args) {
try (SocketChannel sc = SocketChannel.open();){
sc.connect(new InetSocketAddress("127.0.0.1", 8080));
System.out.println("go!");
} catch (IOException e) {
e.printStackTrace();
}
}
}
这里主要看的是服务端的代码,这里面有两个地方是阻塞的
- ssc.accept();
- sc.read()
上面的两个方法是阻塞方法,就会出现这样的现象:
1、进入程序的时候,就会阻塞到accept()
2、当进来第一个客户端的时候,就会在read方法上阻塞
3、当第一个客户端发送消息后,程序又会在accept()方法阻塞,这样第一个客户端怎么发送消息也接受不到,只有第二个客户端链接的时候才能读取到数据
二、非阻塞
nio本身就提供了非阻塞的调用方式。configureBlocking() 把channel设置为非阻塞的。
package com.test.c3.block;
import com.test.utils.ByteBufferUtil;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.util.ArrayList;
import java.util.List;
public class Server {
public static void main(String[] args) {
ByteBuffer byteBuffer = ByteBuffer.allocate(16);
//建立一个服务端通道
try (ServerSocketChannel ssc = ServerSocketChannel.open();){
//绑定端口
ssc.bind(new InetSocketAddress(8080));
ssc.configureBlocking(false);
List<SocketChannel> list = new ArrayList<>();
while (true){
//获取链接,当没有链接的时候 会阻塞
SocketChannel sc = ssc.accept();
if (sc != null) {
sc.configureBlocking(false);
list.add(sc);
System.out.println("after connecting... " + sc);
}
for (SocketChannel socketChannel : list) {
//等待读取数据,会阻塞
int read = socketChannel.read(byteBuffer);
if(read > 0){
byteBuffer.flip();
ByteBufferUtil.debugRead(byteBuffer);
byteBuffer.clear();
System.out.println("after reading");
}
}
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
注意的是,使用ssc.accept(); 当没有的时候,会返回null,sc.read()没有数据的时候,返回值是0。
缺点是,线程一直在循环,导致CPU一直在占用,使得性能贬低。
三、Selector多路复用器
多路复用器针对channel进行管理,可以很大的提高单线程下处理网络通信的效率。首先需要把channel注册到selector中,当某个channel出现需要监听的事件的时候,就会把事件放入相关集合中,需要注意channel必须是非阻塞的状态下才能注册到selector中。
selector的事件有四种:
- connect:客户端连接成功后触发,在客户端进行监听
- accept:服务端接收到新的连接触发,在服务端进行监听
- read:当channel中存在可读数据的时候触发
- write:当向channel中写数据的时候触发
下面是一个简单的服务器端例子:
package com.test.c3.selector;
import com.test.utils.ByteBufferUtil;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.Set;
public class Server {
public static void main(String[] args) {
ByteBuffer byteBuffer = ByteBuffer.allocate(16);
try (ServerSocketChannel ssc = ServerSocketChannel.open();
//得到一个多路复用器
Selector selector = Selector.open();
){
ssc.bind(new InetSocketAddress(8080));
ssc.configureBlocking(false);
//把ssc注册到复用器中,同时只监听accept事件
ssc.register(selector, SelectionKey.OP_ACCEPT);
while (true){
//阻塞等待是否存在,监听到的事件,返回值就是事件个数
int eventCount = selector.select();
System.out.println("事件个数:" + eventCount);
//获取到所有事件的集合
Set<SelectionKey> selectionKeys = selector.selectedKeys();
//获取事件的遍历器
Iterator<SelectionKey> iterator = selectionKeys.iterator();
while(iterator.hasNext()){
SelectionKey selectionKey = iterator.next();
//移除处理过的事件,处理过的事件不易出,就会一直存在
iterator.remove();
//判断类型,进行不同的处理
if(selectionKey.isAcceptable()){
//accept事件
//获取对应的channel
ServerSocketChannel serverSocketChannel = (ServerSocketChannel) selectionKey.channel();
SocketChannel socketChannel = serverSocketChannel.accept();
socketChannel.configureBlocking(false);
//注册给selector
socketChannel.register(selector, SelectionKey.OP_READ);
}else if(selectionKey.isReadable()){
//read事件
SocketChannel socketChannel = (SocketChannel) selectionKey.channel();
socketChannel.read(byteBuffer);
byteBuffer.flip();
ByteBufferUtil.debugRead(byteBuffer);
byteBuffer.clear();
}
}
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
流程解释:
1、开启一个selector 调用Selector.open();
2、各个channel注册到selector中,同时监听感兴趣的事件,注意channel必须是非阻塞的
3、使用 selector.select()进行阻塞等待,当存在事件的时候,就会向下执行,传递参数设置超时时间,也可以调用selector.selectNow(),立即返回
4、使用select.selectedKey(),获取全部可执行的事件,Set<SelectionKey>
5、便利Set<SelectionKey>,根据事件不同类型,进行不同的处理
6、处理完成后的SelectionKey必须移除,否则一直存在Set<SelectionKey>中
7、即使不想处理SelectionKey,也要取消cancel()
断开处理
客户端可能存在异常断开,或者正常断开的情况,
- 正常断开,直接判断read返回的数据是否为-1,如果是,则取消key,关闭通道
- 异常断开,会抛出异常,直接在catch中捕获,取消key,关闭通道即可
消息边界
消息边界的处理,主要是因为网络传输过程中,无法确定一次传输的数据大小,所以针对接收ByteBuffer的大小就不好确定,可能出现半包、粘包或者扩容的情况。
下面主要说一下半包、粘包的情况,例如:
ByteBuffer buffer = ByteBuffer.allocate(4); System.out.println(StandardCharsets.UTF_8.decode(buffer));
接收数据的时候,定了一个大小为4的ByteBuffer,当客户端传输过来一个“你好”的时候,因为使用的是UTF-8,所以一个汉字是三个字节,这时候就出现粘包现象,"好"被拆分成了2部分,最终导致乱码的出现。
解决办法:
- 约定固定大小数据(浪费空间)
- 定义分隔符(可能存在扩容、效率底下)
- 息分为2部分(LTV),第一部分是消息体大小,第二部分是消息体,根据第一次读取到的数据来分配byteBuffer
其中第三种是业界常用的方法,http2.0请求就是典型的LTV协议,L=length,T=type,V=value,根据实际接收数据的大小,进行buffer的分配。
附件:
- 绑定附件,注册绑定或者selectionKey.attach()
- 获取附件,selectionKey.attachment()
服务器简单解决办法,使用扩容方式,当发现position=limit的时候,也就是没有读取到分隔符“\n”,证明数据大了,对byteBuffer进行扩容,并把新的byteBuffer放入附件中,整理后代码:
package com.test.c3.selector;
import com.test.utils.ByteBufferUtil;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.nio.charset.StandardCharsets;
import java.sql.SQLOutput;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.Set;
public class Server {
public static void main(String[] args) throws Exception{
//消息边界,就是传递过来的数据大小不确定,导致粘包、半包的情况或者需要扩容情况
//粘包、半包的情况
//1、约定固定大小数据(浪费空间) 2、定义分隔符(可能存在扩容、效率底下) 3、消息分为2部分(LTV),第一部分是消息体大小,第二部分是消息体,根据第一次读取到的数据来分配byteBuffer
ServerSocketChannel ssc = ServerSocketChannel.open();
//得到一个多路复用器
Selector selector = Selector.open();
ssc.bind(new InetSocketAddress(8080));
ssc.configureBlocking(false);
//把ssc注册到复用器中,同时只监听accept事件
ssc.register(selector, SelectionKey.OP_ACCEPT);
while (true){
//阻塞等待是否存在,监听到的事件,返回值就是事件个数
int eventCount = selector.select();
System.out.println("事件个数:" + eventCount);
//获取到所有事件的集合
Set<SelectionKey> selectionKeys = selector.selectedKeys();
//获取事件的遍历器
Iterator<SelectionKey> iterator = selectionKeys.iterator();
while(iterator.hasNext()){
SelectionKey selectionKey = iterator.next();
//移除处理过的事件,处理过的事件不易出,就会一直存在
iterator.remove();
//判断类型,进行不同的处理
if(selectionKey.isAcceptable()){
//accept事件
//获取对应的channel
ServerSocketChannel serverSocketChannel = (ServerSocketChannel) selectionKey.channel();
SocketChannel socketChannel = serverSocketChannel.accept();
socketChannel.configureBlocking(false);
//注册给selector
ByteBuffer byteBuffer = ByteBuffer.allocate(16);
//把byteBuffer按照附件的形式,一起注册到selector中
socketChannel.register(selector, SelectionKey.OP_READ, byteBuffer);
}else if(selectionKey.isReadable()){
//read事件
SocketChannel socketChannel = (SocketChannel) selectionKey.channel();
//获取key上关联的附件
ByteBuffer byteBuffer = (ByteBuffer) selectionKey.attachment();
try{
int read = socketChannel.read(byteBuffer);
if(read == -1){
//处理断开的情况下,取消事件、关闭通道
selectionKey.cancel();
socketChannel.close();
}else{
split(byteBuffer);
//当压缩后 position和limit相等的时候,就需要扩容了。因为没有读到\n
if(byteBuffer.position() == byteBuffer.limit()){
//发现需要扩容之后,创建一个新的byteBuffer,放入附件中
ByteBuffer newByteBuffer = ByteBuffer.allocate(byteBuffer.capacity() * 2);
byteBuffer.flip();
newByteBuffer.put(byteBuffer);
selectionKey.attach(newByteBuffer);
}
}
}catch (Exception e){
e.printStackTrace();
//处理断开的情况下,取消事件、关闭通道
selectionKey.cancel();
socketChannel.close();
}
}
}
}
}
private static void split(ByteBuffer byteBuffer){
byteBuffer.flip();
for (int i = 0; i < byteBuffer.limit() ; i++) {
if(byteBuffer.get(i) == '\n'){
int length = i + 1 - byteBuffer.position();
//存入新的 byteBuffer
ByteBuffer target = ByteBuffer.allocate(length);
for (int j = 0; j < length; j++) {
byte b = byteBuffer.get();
target.put(b);
}
ByteBufferUtil.debugAll(target);
}
}
byteBuffer.compact();
}
}
ByteBuffer的大小
- ByteBuffer 必须一个channel维护一个,否则会导致数据混乱
- ByteBuffer 不可能定义太大
- 可以使用扩容的思想,先定义一个小的然后不够在扩容,但是可能涉及到数据拷贝,传送门
- 可以使用多数组方式进行,一个数组不够写入新的数组,虽然避免了拷贝,但是解析增加复杂度
Write事件
在想channel中写数据的时候,可能由于数据太大,没办法一次性全部写完,所以可以分为多次进行编写,但是如果直接使用while()循环的话又会导致阻塞,所以可以先写一次,然后在把channel注册到selector中,同时带上没有写完的数据,循环关注写事件。
服务器代码整理:
package com.test.c3.selector;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.*;
import java.nio.charset.Charset;
import java.util.Iterator;
public class WriteServer {
public static void main(String[] args) throws IOException {
ServerSocketChannel ssc = ServerSocketChannel.open();
ssc.configureBlocking(false);
Selector selector = Selector.open();
ssc.register(selector, SelectionKey.OP_ACCEPT);
ssc.bind(new InetSocketAddress(8080));
while (true){
selector.select();
Iterator<SelectionKey> iterator = selector.selectedKeys().iterator();
while (iterator.hasNext()){
SelectionKey nextKey = iterator.next();
iterator.remove();
if(nextKey.isAcceptable()){
SocketChannel sc = ssc.accept();
sc.configureBlocking(false);
SelectionKey scKey = sc.register(selector, 0, null);
//写数据,大量的
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 3000000; i++) {
sb.append("a");
}
ByteBuffer buffer = Charset.defaultCharset().encode(sb.toString());
//返回值代表实际写入的字节数
int write = sc.write(buffer);
System.out.println(write);
//判断是否还有剩余内容
if(buffer.hasRemaining()){
//有内容,则关注可写事件,拿出原来的关注事件拼接
scKey.interestOps(scKey.interestOps() + SelectionKey.OP_WRITE);
//未写完的数据 挂在scKey
scKey.attach(buffer);
}
}else if(nextKey.isWritable()){
ByteBuffer buffer = (ByteBuffer) nextKey.attachment();
SocketChannel sc = (SocketChannel) nextKey.channel();
int write = sc.write(buffer);
System.out.println(write);
//清理操作
if(!buffer.hasRemaining()){
nextKey.attach(null);
nextKey.interestOps(nextKey.interestOps() - SelectionKey.OP_WRITE);
}
}
}
}
}
}
客户端代码:
package com.test.c3.selector;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SocketChannel;
public class WriteClinet {
public static void main(String[] args) throws IOException {
SocketChannel sc = SocketChannel.open();
sc.connect(new InetSocketAddress("127.0.0.1", 8080));
//接收数据
int count = 0;
while(true){
ByteBuffer byteBuffer = ByteBuffer.allocate(1024 * 10);
count += sc.read(byteBuffer);
System.out.println(count);
byteBuffer.clear();
}
}
}
以上就是简单学习了一下nio的相关知识,主要是针对三代组件中的ByteBuffer 和 Selector的简单使用进行介绍。
多线程优化
优化思路,为了复用多核CPU:
- 使用一个thread监听连接事件
- 使用多个thread监听读写事件
package com.test.c4;
import com.test.utils.ByteBufferUtil;
import lombok.extern.slf4j.Slf4j;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.*;
import java.util.Iterator;
import java.util.concurrent.ConcurrentLinkedDeque;
@Slf4j
public class MultiThreadServer {
public static void main(String[] args) throws IOException {
Thread.currentThread().setName("Boss");
ServerSocketChannel ssc = ServerSocketChannel.open();
ssc.configureBlocking(false);
Selector boss = Selector.open();
ssc.register(boss, SelectionKey.OP_ACCEPT);
ssc.bind(new InetSocketAddress(8080));
//创建worker,并初始化
//worker有限制,一个worker对应多个 SocketChannel
Worker worker = new Worker("worker-0");
while(true){
boss.select();
Iterator<SelectionKey> iterator = boss.selectedKeys().iterator();
while(iterator.hasNext()){
SelectionKey key = iterator.next();
iterator.remove();
if(key.isAcceptable()){
SocketChannel sc = ssc.accept();
sc.configureBlocking(false);
log.debug("连接了。。。。{}", sc.getRemoteAddress());
//关联,注意selector,把读写事件交给worker中的selector监听
log.debug("注册worker签。。。。{}", sc.getRemoteAddress());
worker.register(sc);
log.debug("注册worker后。。。。{}", sc.getRemoteAddress());
}
}
}
}
static class Worker implements Runnable{
private Thread thread;
private Selector selector;
private String name;
//使用队列进行数据传递
private ConcurrentLinkedDeque<Runnable> queue = new ConcurrentLinkedDeque<>();
//还未初始化
private volatile boolean start = false;
public Worker(String name) {
this.name = name;
}
/**
* 初始化线程 和 selector
*/
public void register(SocketChannel sc) throws IOException {
if(!start){
thread = new Thread(this, name);
selector = Selector.open();
thread.start();
start = true;
}
queue.add(()->{
try {
sc.register(selector, SelectionKey.OP_READ + SelectionKey.OP_WRITE);
} catch (ClosedChannelException e) {
e.printStackTrace();
}
});
//唤醒selector
selector.wakeup();
}
@Override
public void run() {
//监测读写事件
while(true){
try {
//register() 方法执行,就执行select(),导致selector阻塞
//其实就是注意 selector.select()会阻塞住selector,所以需要注意顺序
selector.select();
//把队列中需要执行的代码拿出来,执行,这样保证了 sc.register 一定在 selector.select();后面执行,但是需要唤醒selector
Runnable task = queue.poll();
if(task != null){
task.run();
}
Iterator<SelectionKey> iterator = selector.selectedKeys().iterator();
while(iterator.hasNext()){
SelectionKey key = iterator.next();
iterator.remove();
if(key.isReadable()){
ByteBuffer buffer = ByteBuffer.allocate(10);
SocketChannel channel = (SocketChannel) key.channel();
log.debug("读数据了。。。。{}", channel.getRemoteAddress());
channel.read(buffer);
buffer.flip();
ByteBufferUtil.debugAll(buffer);
}
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
}
客户端:
package com.test.c4;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.channels.SocketChannel;
import java.nio.charset.StandardCharsets;
public class Client {
public static void main(String[] args) throws IOException {
SocketChannel sc = SocketChannel.open();
sc.connect(new InetSocketAddress("localhost", 8080));
sc.write(StandardCharsets.UTF_8.encode("hello word~!~"));
System.in.read();
}
}
因为worker是单独的一个线程,而selector.select()是阻塞的,所以boss线程,向worker线程中的selector注册的时候,如果在selector.select()之后,就会导致无法注册上。所以使用队列的方式,把注册放入队列中,同时对selector进行唤醒,
queue.add(()->{ try { sc.register(selector, SelectionKey.OP_READ + SelectionKey.OP_WRITE); } catch (ClosedChannelException e) { e.printStackTrace(); } }); //唤醒selector selector.wakeup();
这样在就能保证一定能注册上,也是netty的解决方式雏形。
selector.select(); //把队列中需要执行的代码拿出来,执行,这样保证了 sc.register 一定在 selector.select();后面执行,但是需要唤醒selector Runnable task = queue.poll(); if(task != null){ task.run(); }
其实selector.wakeup();就是让selector唤醒,不论selector是否执行select()方法,也就是select()在wakeup()前后执行都无所谓。