1. 基础知识
1.1 一次网络IO
![图片](https://i-blog.csdnimg.cn/blog_migrate/fa262078223555e717131e3a17094f7e.png)
1.1.1 网络IO流程
- 用户发送数据到服务器【网卡】
- 网卡使用DMA方式将数据拷贝到内核的Socket缓冲区的RevBuffer 【内核Socket缓冲区】
- 应用程序读数据时,将内核Socket缓冲区中的数据拷贝到当前线程的缓冲区里【用户缓冲区】
- 应用程序执行相应的业务处理,如果要操作磁盘文件,需要切换到内核态,调用系统函数,将磁盘上的数据拷贝到内核缓冲区中,然后再从内核缓冲区拷贝到用户缓冲区中。
- 应用程序写数据时,先将用户缓冲区里的数据拷贝到内核的Socket缓冲区的sendBuffer中
- 内核程序再通过DMA方式将数据拷贝到网卡上
1.1.2 什么是DMA?
- DMA: Direct Memory Acces的缩写,直接内存存取的意思。
- 一种允许【硬件直接访问内存】的机制
- 基于DMA访问方式,可以【省去CPU调度】
1.1.3 CPU如何知道接收数据?
- 问题:数据从网卡到内核Socket缓冲区中采用DMA方式,那么CPU是如何感知的?
- 解析:中断, CPU实现线程切换就是基于时钟中断。中断可以分为多种:①外部中断,计算机外设发出的中断,比如:键盘、鼠标、网卡 ②内部中断,计算机内部产生问题,比如:运算出错(除数为零)。 ③软中断:用户程序主动调用中断指令。 当DMA拷贝完数据,就会给CPU发送一个外部中断,这样CPU就能感知到数据已经准备好了,执行相应的处理逻辑。
1.2 CPU调度
1.2.1 阻塞为什么不消耗CPU?
1.2.1.1 工作队列
- 操作系统为了实现进程调度,维护了一个【工作队列】
- 工作队列中存放的是【运行态】的进程
- 操作系统会采用时间片的方式执行运行态的进程
1.2.1.2 等待队列
- 客户端连接服务器后,服务器会为其创建一个Socket对象,也就是对应的文件描述符【Socket文件描述符】。
- Socket文件描述符中包含【发送缓冲区】、【接收缓冲区】、【等待队列】
- 如果客户端还未发送数据,读取Socket数据时就会阻塞。此时会将当前线程从工作队列移出到SocketFD的等待队列中。这样,阻塞的线程就不会消费CPU资源。
- 当Socket接收到数据后,DMA会发送个中断请求给CPU,CPU就会执行中断程序,将Socket等待队列中的线程移出到工作队列中,此时线程再继续执行代码,就会获取到Socket中的数据了。
2.BIO
2.1 简介下什么是BIO?
![图片](https://i-blog.csdnimg.cn/blog_migrate/49bc4950bb532982383d254f9159ba0c.png)
- BIO中的B指的是”Blocking“的意思,即”阻塞的IO"
- 我们开发服务端时,我们先使用ServerSocket绑定要监听端口,然后调用accept方法获取一个连接,该方法底层调用了内核的recvFrom函数。如果客户端此时还未连接,该方法会阻塞。
- 当我们通过accept方法获取到Socket后,就可以获取这个Socket的输入输出流,对应就是read和write方法。但是如果客户端没有输入数据,该方法会阻塞,此时其他客户端连接服务器也会阻塞,服务器无法及时响应。
- 为了解决BIO阻塞的问题,我们一般采用多线程的模式,为每个请求建立一个线程来处理。即BIO是【线程驱动模型】
- BIO最大的问题是:线程开销大,很容易导致系统故障。
2.2 什么是C10K问题?
- 我们每接到一个客户端请求,服务器就需要分配一个进程来处理这个请求。假如有10K个请求,我们就需要创建1万个进程,显然,我们单机系统是承受不住的。
- 当我们的线程多了,线程上下文切换开销会增大,导致系统崩溃。
- 解决思路:一个线程负责多个连接,即IO多路复用。
2.3 BIO代码示范
static int port = 8080;
public static void main(String[] args) {
ServerSocket serverSocket = null;
Socket socket = null;
InputStream inputStream = null;
OutputStream outputStream = null;
try {
serverSocket = new ServerSocket(port);
System.out.println("start server socket....");
while (true){
socket = serverSocket.accept();
System.out.println("获取到socket连接...");
inputStream = socket.getInputStream();
byte[] buffer = new byte[1024];
int length = 0;
while ((length = inputStream.read(buffer)) > 0) {
String msg = new String(buffer, 0, length);
System.out.println("接收到客户端数据:" + msg);
outputStream = socket.getOutputStream();
outputStream.write("server get data".getBytes());
}
}
} catch (IOException e) {
e.printStackTrace();
} finally {
try {
serverSocket.close();
socket.close();
inputStream.close();
outputStream.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
telnet localhost 8080
2.4 BIO存在的问题
- C10K问题:由于read方法会阻塞当前线程,因此我们一般会为每个客户端创建一个线程。如果我们的客户端请求很多,我们服务器就会因为创建多个线程而OOM,或者直接系统崩溃。虽然我们可以使用线程池来管理线程,但是如果客户端访问量很大,那么用户体验会非常差,线程池满了后,后续的请求就无法处理。这就是C10K问题。
- 如果要解决C10K问题,我们就需要引入:NIO
- NIO
3.1 简介下什么是NIO?
- Java中的NIO指的是NewIO,给我们提供了一套非阻塞的接口,底层通过JVM虚拟机调用操作系统kernel去实现。.
- NIO三大核心组件:Buffer(缓冲区)、Selector(选择器)、Channel(通道)
- 操作系统的NIO指的是Not Blocking IO,也就是非阻塞IO.NIO不会为每个请求创建一个线程,而是一个线程取监听多个Socket请求。
- 操作流程: NIO包中提供了一个selector,我们需要先打开服务器端的管道,然后绑定端口,通过open方法找到Selector,再把需要检查的Socket注册到这个selector中。主线程会阻塞在selctor的select方法里,然后当select发现某个socket就绪了,就会唤醒主线程。然后主线程就可以通过selector获取到就绪状态的Socket,进行相应的处理。
- NIO是基于事件驱动模型
3.2 NIO核心
3.2.1 通道
- 通道-channel,表示打开到IO设备的连接,比如:Socket连接
3.2.2 缓冲区
3.2.2.1 Buffer简介
![图片](https://i-blog.csdnimg.cn/blog_migrate/018c11fcfaa47a432e23398a4d524345.png)
- buffer主要负责存储数据,底层实现是数组。
- 根据数据类型不同,可以分为不同类型的缓冲区(boolean除外),ByteBuffer、CharBuffer、ShortBuffer、IntBuffer、LongBuffer、FloatBuffer、DoubleBuffer
- buffer四大属性: ①capacity:容量,表示缓冲区最大容量,一旦声明就不可改变。 ②limit:界限,表示可操作数据大小 ③position:位置,表示正在操作数据的位置 ④mark:存档,记录当前操作的位置,通过reset恢复到mark的位置 ⑤0<=mark <= postion <=limt <=capacity
- buffer核心方法:①allocate:分配缓冲区 ②put:存储数据到缓冲区 ③get:获取缓冲区中的数据 ④flip:切换到写模式
public class TestBuffer {
public static void main(String[] args) {
System.out.println("=======allocate()=======");
ByteBuffer buf = ByteBuffer.allocate(1024);
System.out.println("position:" + buf.position());
System.out.println("limit:" + buf.limit());
System.out.println("capacity:" + buf.capacity());
System.out.println("=======put()=======");
String msg = "newio";
buf.put(msg.getBytes());
System.out.println("position:" + buf.position());
System.out.println("limit:" + buf.limit());
System.out.println("capacity:" + buf.capacity());
System.out.println("=======flip()=======");
buf.flip();
System.out.println("position:" + buf.position());
System.out.println("limit:" + buf.limit());
System.out.println("capacity:" + buf.capacity());
System.out.println("=======get()=======");
byte[] dst = new byte[buf.limit()];
buf.get(dst);
System.out.println("data:" + new String(dst, 0, dst.length));
System.out.println("position:" + buf.position());
System.out.println("limit:" + buf.limit());
System.out.println("capacity:" + buf.capacity());
System.out.println("=======rewind()=======");
buf.rewind();
System.out.println("position:" + buf.position());
System.out.println("limit:" + buf.limit());
System.out.println("capacity:" + buf.capacity());
System.out.println("=======clear()=======");
buf.clear();
System.out.println("position:" + buf.position());
System.out.println("limit:" + buf.limit());
System.out.println("capacity:" + buf.capacity());
System.out.println("=======mark()=======");
buf.put(msg.getBytes());
buf.flip();
byte[] result = new byte[buf.limit()];
buf.get(result, 0, 2);
System.out.println("result:" + new String(result, 0, 2));
System.out.println("position:" + buf.position());
buf.mark();
buf.get(result, 2, 2);
System.out.println("result:" + new String(result, 2, 2));
System.out.println("position:" + buf.position());
System.out.println("=======reset()=======");
buf.reset();
System.out.println("position:" + buf.position());
System.out.println("=======remaining()=======");
if (buf.hasRemaining()) {
System.out.println("remaining:"+buf.remaining());
}
}
}
3.2.2.2 缓冲区分类
![图片](https://i-blog.csdnimg.cn/blog_migrate/318eb9e6be629d1195fda6269567a328.png)
- 缓冲区可以分为【直接缓冲区】和【非直接缓冲区】
- 使用【allocate】方法创建的是非直接缓冲区,使用【allocateDirect】方法创建的是直接缓冲区
- 非直接缓冲区分配在【JVM堆】内存中,直接缓冲区分配在【物理内存】中
- 直接缓冲区底层实现了【零拷贝】
- 直接缓冲区缺陷: ①开销大 ②不归JVM管
3.2 如何提高NIO性能?
3.2.1 reactor
3.3 NIO与IO的区别
4. IO多路复用
1.4.1 select函数
1.4.1.1 简介下select函数的工作原理?
- 我们每次调用kenel的select函数,都需要切换用户态和内核态,还要把需要检查的Socket集合传过去,其实就是Socket的文件描述符。
- Linux系统中,一切皆文件,操作系统会为每个socket都生成一个文件描述符。
- 操作系统会根据传过去的Socket集合,去检查内存中Socket套接字的状态,这个复杂度是O(N)级别的,检查一遍后,如果有就绪状态的Socket,会为socket对应文件描述符打上一个标记,表示这个socket就绪了,然后返回就绪Socket数量。否则会阻塞调用线程,直到有某个Socket有数据后,才会唤醒调用线程
- 调用者
1.4.1.2 select函数监听socket时,socket有没有数量限制?
- select函数默认最大可以监听1024个socket,实际肯定是比这个数小的。
- 由于select函数有个参数,是传进来的socket集合。这个集合长度是1024,如果需要这个长度,比较麻烦,需要重新编译操作系统内核。
- 这个长度设置为1024,应该是处于性能考虑
1.4.1.3 第一次查找未发现就绪Socket,后续Socket就绪后,select如何感知,是轮询吗?
- 知识铺垫: ①操作系统调度:一个CPU在同一时刻只能运行一个进程,CPU会在不同进程间来回切换。没有挂起的进程都在工作队列里,是有机会获取到CPU执行权。挂起的进程,会从工作队列内移除出去,就是阻塞了,没有机会去获取CPU执行权。 ②操作系统中断: 首先介绍下时钟中断,这个底层是借助晶振实现的,CPU每收到一个时钟中断就会切换下进程。然后是IO中断,比如:我们使用键盘打字,如果CPU正执行其他程序一直不释放,那么我们就无法打字,事实肯定不是这样的。我们摁下键时,会产生一个IO中断,触发CPU中断,然后CPU会保存现在执行线程的信息,然后处理紧急需求。这样中断程序就拿到了CPU执行权。
- select第一次轮询,如果没有发现就绪状态的Socket,就把这个进程保存到socket的等待队列中,然后会把这个当前进程从工作队列移除了,然后就阻塞了。select函数也不会执行了。
- 客户端发送数据,通过网卡到达内存中,整个过程CPU不参与,自然也无法感知。数据完成传输后,会触发网络中断,CPU就会处理这个中断程序,然后根据数据包中的端口信息,分析出来是哪个Socket的数据,然后把数据复制到Socket的读缓冲区里。数据导入完成后,就会检查Socket的等待队列,看是不是有等待者,如果有,就把等待着移动到工作队列中,然后select函数就会再执行了,然后再次检查,就发现有就绪的socket了,然后就会返回就绪数量给Java客户端。
- 由于select函数返回的是就绪的数量,客户端没法获取就绪进程信息,因此还要再次遍历,检查socket是否就绪,如果就绪就处理。
1.4.2 poll
1.4.2.1 poll和select的区别是啥?
- 参数不一致,select使用的是bitmap表示需要检查的socket集合,poll使用数组表示需要检查的Socket集合,这样就解决了select最多只能监听1024个socket缺陷。
1.4.2.2 select 和 poll的缺陷是啥?
- select和poll的返回值是个整型值,表示有几个socket就绪了,无法表达具体是哪个socket就绪了。程序被唤醒后,要新的一轮系统调用去检查哪个socket是就绪状态。系统调用设计用户态和内核态切换。
1.4.3 epoll
1.4.3.1 为什么会有poll?
1.4.3.1 epoll的工作原理是啥?
- epoll中有自己的数据结构是eventpoll对象,可以通过系统函数epoll_create去创建,创建返程回,返回这个对象的文件号。
- Socket对象有三块区域,读缓冲区、写缓冲区和等待队列。
- EventPoll对象有两个核心区域,一块用于存放需要监听的socket文件描述符列表,另一块存放就绪状态的Socket信息。EventPoll对象有两个核心函数,epoll_ctl和epoll_wait。epoll_ctl用于维护需要关注的Socket文件描述符列表,