netty学习(一)JAVA中的IO方式
1. 为什么要学习netty?
别问,必学
2. BIO、NIO、AIO
这里再复习一下java中的三种IO模型,要将java中的IO模型,首先离不开操作系统中的IO模型
2.1. 操作系统中的IO模型
一次IO的过程包括两部分: 1. 内核空间接收到外来数据 2. 将内核空间的数据拷贝到用户空间供应用使用
由此诞生了操作系统中的5种IO模型
同步并阻塞
在该模型下,每一个线程在调用读取函数之后就一直进入阻塞,直到读取到数据
同步非阻塞
与上面不同,每一个线程在调用读取函数时马上返回,并不断询问数据是否到达,在这个阶段是非阻塞的,但当进行到第二个阶段时会进入阻塞
信号驱动
在此种模型下,不需要不断询问数据是否到达,而是注册一个函数,等待数据到达内核空间后进入阻塞,进行数据的复制
异步非阻塞
在异步情况下,每一个线程在调用读取函数后会注册一个回调函数,然后处理其他事情直到两个阶段都完成再回调进行处理
多路复用
与前三种不同的是,在多路复用模型下,每一个线程可以同时处理多个连接,然后根据各连接的情况进行处理,这里就不详细讲啦
2.2 java中的IO模型
而在java中实现了三种IO模型
阻塞IO BIO
非阻塞IO NIO
异步IO AIO
注意的是java中的NIO对应的实际上是操作系统中的多路复用
每种IO都有各自的优缺点,而当下使用最多的是NIO模型
各种IO使用场景
BIO: 连接数目标少且固定的场景
NIO:连接数多且连接比较短的场景(聊天服务器、弹幕系统、服务期间通讯)
AIO:连接数目比较多且连接比较长
2.2.1 BIO
java原生BIO的实现包括以下部分:
- 建立一个线程池
- 创建一个ServerSocket
- 调用accpet等待客户端连接,获取Socket对象
- 取一个线程进行处理,利用Socket进行传输
- 关闭Socket
代码儿
public class BIOServer {
public static void main(String[] args) throws Exception {
// 1. 创建线程池
ExecutorService executorService = Executors.newCachedThreadPool();
// 2. 创建ServerSocket
ServerSocket serverSocket = new ServerSocket(5555);
System.out.println("服务器启动!");
while(true){
// 3. 等待客户端的连接
final Socket socket = serverSocket.accept();
System.out.println("有客户端连接了");
// 4. 取一个线程执行IO
executorService.execute(new Runnable() {
public void run() {
try {
handler(socket);
}catch (Exception e){
e.printStackTrace();
}finally {
System.out.println("连接关闭");
try {
socket.close(); // 5. 关闭Socket
}catch (Exception e){
e.printStackTrace();
}
}
}
});
}
}
public static void handler(Socket socket) throws Exception{
InputStream inputStream = socket.getInputStream();
byte[] bytes = new byte[1024];
while(true){
int read = inputStream.read(bytes);
if(read!=-1){
System.out.println(new String(bytes));
}else{
break;
}
}
}
}
2.2.2 NIO
上面说到java中的NIO其实就是多路复用的技术,为什么多路复用可以实现高并发呢?
首先,操作系统的最大线程数量是有限的,若有1w个请求到达,而操作系统只支持1000个线程,则此时另外9000个请求就会进入等待,而正在进行的线程不是时时刻刻都在执行的,大部分都在等待数据的传输而进入阻塞,这就造成了资源的浪费,且操作系统需要不停的切换线程来执行任务,这也造成了运行的缓慢。
而是用多路复用技术,则大大减少了线程的数量,让每一个线程同时处理多个请求也减少了阻塞情况的发生。
那么java中的NIO是怎么实现的呢?换句话说,java是怎么实现一个线程处理多个连接的呢?

如上图,NIO模型由三大组件组成
selector负责监视channel提供给线程
channel负责管理服务端的IO传输
buffer负责跟客户端的IO传输
在这种结构下,不同于BIO使用流式的传输,将数据传输到buffer中可以实现块式传输,从而能让线程不必实时监听一个连接。
而channel跟selector的互动则可以满足各个连接的切换
要注意的是,buffer和channel都是双向传输的,即可以读也可以写
2.2.2.1 buffer(缓冲区)
buffer是怎样实现双向读写的呢?
我们跟着源码看一下
buffer是一个抽象类,对于每一个基本数据类型(除了boolean),都有一个子类的实现,我们以byteBuffer为例,该类维护了一个byte数组:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-UKXbbnFU-1619023129199)(uploads/image-20210401214038619.png)]
这个数据即数据存放的结构
buffer维护了4个重要的属性

其中:
-
mark是一个标记
-
position代表我们进行操作时对应元素的位置
-
limit代表最大访问的位置
-
capacity代表了数组的容量
每一次get和put操作,即将position+1
那怎么实现同时读写呢?
每次进行读写切换时,我们需要显示的调用flip方法:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-QwCLytsS-1619023129201)(uploads/image-20210401214350776.png)]
可以看到,该方法将limit设置成了position的位置,然后position从0开始。
这样就很巧妙的实现了读写的切换
2.2.2.2 channel(通道)
同样,为了能和buffer进行交互,channel也是能支持双向传输的。
channel是一个接口,其也有很多实现类:

比较常用的为FileChannel、ServerSocketChannel和SocketChannel
对于FileChannel,我们可以理解为其对标准输入输出的一层封装(但其实channel是写在标准IO里面的),使用该通道可以将文件中的内容和buffer中的内容进行读写:
栗子,复制1.txt中的内容到2.txt
public class Channel {
public static void main(String[] args) throws Exception{
FileInputStream fileInputStream = new FileInputStream("1.txt"); // 创建两个文件流
FileOutputStream fileOutputStream = new FileOutputStream("2.txt");
FileChannel channel = fileInputStream.getChannel(); // 获取文件流的channel
FileChannel channel1 = fileOutputStream.getChannel();
ByteBuffer bb = ByteBuffer.allocate(1024); // 创建buffer
while(true){
int read = channel.read(bb); // 将文件中的一部分读入buffer中
if(read!=-1){
bb.flip();
channel1.write(bb); // 将buffer写入2.txt
bb.flip();
}else{
break;
}
}
}
}
ServerSocketChannel和SocketChannel的关系和ServerSocket和Socket的关系类似,可以实现客户端和服务器的连接,这也是完成NIO的主要类,将在后面详细讨论。
2.2.2.3 Selector(选择器)
作为NIO中最为核心的组件,Selector实现了对连接的监听然后返回给线程,那么它是怎么实现跟SocketChannel联系的呢,我们看看源码:
我们知道ServerSocketChannel就好比ServerSocket,调用accpt方法之后就会产生一个SocketChannel对象,在SocketChannel的抽象类中,我们找到了一个register方法:

可以看到,该方法是传入一个selector对象,然后返回一个selectionKey对象,该方法中做了什么呢?
我们一路追踪,找到了如下关键:
- 创建了一个selectionKey对象:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Kdlw8Bkw-1619023129202)(uploads/image-20210402174940144.png)]
- 将这个selectionKey对象放入到了一个Keys集合中:

- 返回这个selectionKey
这个Keys集合里其实就是我们注册的所有连接,之后我们我们就可以通过这个selectionKey来反向找到我们的SocketChannel。
既然channel注册进来了,那么怎么进行监听呢?
这就来到了我们的selector类里面进行解释:
首先,Selector是一个抽象类,其中有3个核心的方法:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Dti0iioF-1619023129204)(uploads/image-20210402172108087.png)]
selectorNow(): 非阻塞的selector方法,该方法执行后不论怎样都会立即返回
selector(long):有最大等待时间的selector方法,若没有事件发生,该方法会在等待超时后才返回
selector(): 前面两种方法都是调用了selector方法,我们来看其具体实现
一路追踪,我们找到了processSelectedKeys(long updateCount)这个方法

可以发现该方法根据POLLIN、POLLOUT、POLLCONN执行了不同的操作,看到这里,学过操作系统中多路复用的朋友可能就有点印象了,这和select多路复用是不是很像?
然后我们继续看processFDSet()方法:


代码太长我们看到前半段和中间就懂了,该方法在遍历一个fds数组,然后若有事件发生的则将返回的int类型+1,并且将该事件加入到selectedKeys集合中,
selector中维护了多个selectionKey的集合:

通过注释得知 keys为注册的事件集合, selectedKeys为发生事件的集合,而后两个则为前两个集合的public版本,所以我们通过selector的selectKeys()方法即可获得发生事件的集合。
前面原理可能有点点乱,我们再来总结一下一个连接进行处理的流程:
- 将ServerSocketChannel注册到selector中,ServerSocketChannel调用accpet() 等待客户端连接
- 客户端连接后返回一个SocketChannel,然后调用register()方法将该通道注册进selector中并得到一个selectionKey用于反向取回通道
- selector调用select方法遍历所有的selectionKey,并统计发生事件的连接数量
- 再进一步调用selectKeys()方法得到发生事件的selectionKey,然后取得通道Channel进行业务处理
这样一来是不是清晰明了?当然这只是大概的流程,里面还有很多很多的细节
我们用代码的形式来演示上面的流程
先写一个服务端:
public class SelectorServer {
public static void main(String[] args) throws Exception{
// 创建selector
Selector selector = Selector.open();
// 创建serverSocketChannel
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
// 绑定端口
// 设置为非阻塞
serverSocketChannel.configureBlocking(false);
serverSocketChannel.socket().bind(new InetSocketAddress(5555));
// 1. 将serverSocketChannel注册到selector中, 将事件定义为accept事件
serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
// 等待客户端连接
while(true){
// 3. 统计发生时间的数量
if(selector.select(1000)==0){
System.out.println("服务端等待了一秒,没有事件发生");
}
// 4. 遍历发生的事件进行业务处理
Iterator<SelectionKey> iterator = selector.selectedKeys().iterator();
while(iterator.hasNext()){
SelectionKey selectionKey = iterator.next();
// 若为accept事件,则获取连接
if(selectionKey.isAcceptable()){
// 2. 获取SocketChannel,注册进selector中
ServerSocketChannel serverSocketChannel1 = (ServerSocketChannel)selectionKey.channel();
SocketChannel channel = serverSocketChannel1.accept();
// 同样要设置成非阻塞模式
channel.configureBlocking(false);
// 这里同时可以创建并绑定一个buffer对象进行传输
channel.register(selector, SelectionKey.OP_READ, ByteBuffer.allocate(1024));
}
// 若为可读事件
if(selectionKey.isReadable()){
// 反向获取channel
SocketChannel channel = (SocketChannel) selectionKey.channel();
// 获取对应的buffer对象
ByteBuffer buffer = (ByteBuffer) selectionKey.attachment();
channel.read(buffer);
// 打印读取到的值
System.out.println(new String(buffer.array()));
}
// 这里很重要,处理后记得把事件删除
iterator.remove();
}
}
}
}
再来一个客户端:
public class SelectorClient {
public static void main(String[] args) throws Exception{
// 创建一个连接
SocketChannel channel = SocketChannel.open();
// 绑定端口
channel.connect(new InetSocketAddress("127.0.0.1", 5555));
String str = "hello, 树先生";
// 创建一个buffer传输数据
ByteBuffer byteBuffer = ByteBuffer.wrap(str.getBytes());
// 设置为非阻塞模式
channel.configureBlocking(false);
channel.write(byteBuffer);
}
}
结果:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-P1mk2eIW-1619023129205)(uploads/image-20210402210554015.png)]
可以看到,服务器在读了一次数据之后并没有阻塞,而是继续监听等待别的事件,所以达到了非阻塞的效果
到这里,java中的NIO原理和使用应该都大致了解了,那么有了NIO为什么还要使用netty呢?,下一章我们继续学习。