技术栈,全栈工程师中的栈是什么意思?就好比调试框中spring的方法调用栈,只有调用了启动方法才能调用接下来的方法。码农的技术栈也是这样积累的。
前置知识:了解多路复用所需要的基础知识
网络IO模型:不同模型对比引出多路复用模型
前置知识
用户态和核心态
内核态与用户态的区别:
- 内核态与用户态是操作系统的两种运行级别,当程序运行在3级特权级上时,就可以称之为运行在用户态。因为这是最低特权级,是普通的用户进程运行的特权级,大部分用户直接面对的程序都是运行在用户态;
- 当程序运行在0级特权级上时,就可以称之为运行在内核态。
- 运行在用户态下的程序不能直接访问操作系统内核数据结构和程序。当我们在系统中执行一个程序时,大部分时间是运行在用户态下的,在其需要操作系统帮助完成某些它没有权力和能力完成的工作时就会切换到内核态。
我们的程序一般运行在用户态,如果要调用系统级别的指令,就会发生线程切换比如,IO,异常的时候会从用户态切换到核心态。这种切换需要保存、恢复当前执行环境的上下文,这时逻辑处理之外的消耗,这也是讨论NIO时多次被提到的概念。
线程之间的创建切换
多线程创建和切换需要保存和恢复线程的上下文,同时,阻塞当前线层、调用目标线程需要从用户态切换为和心态,都带来一定的额外开销。因此这里引入线程池的概念,线程池的概念网上很多文章具有误导性,建议看美团技术团队的文章:Java线程池实现原理及其在美团业务中的实践
摘抄6个参数作用的总结,比较精炼,比较准确了,对于corePoolSize和workQueue后面有解释:
- corePoolSize(线程池基本大小):当向线程池提交一个任务时,若线程池已创建的线程数小于corePoolSize,即便此时存在空闲线程,也会通过创建一个新线程来执行该任务,直到已创建的线程数大于或等于corePoolSize时,(除了利用提交新任务来创建和启动线程(按需构造),也可以通过 prestartCoreThread() 或 prestartAllCoreThreads() 方法来提前启动线程池中的基本线程。)
- maximumPoolSize(线程池最大大小):线程池所允许的最大线程个数。当队列满了,且已创建的线程数小于maximumPoolSize,则线程池会创建新的线程来执行任务。另外,对于无界队列,可忽略该参数。
- keepAliveTime(线程存活保持时间)当线程池中线程数大于核心线程数时,线程的空闲时间如果超过线程存活时间,那么这个线程就会被销毁,直到线程池中的线程数小于等于核心线程数。
- workQueue(任务队列):用于传输和保存等待执行任务的阻塞队列。
- threadFactory(线程工厂):用于创建新线程。threadFactory创建的线程也是采用new Thread()方式,threadFactory创建的线程名都具有统一的风格:pool-m-thread-n(m为线程池的编号,n为线程池内的线程编号)。
- handler(线程饱和策略):当线程池和队列都满了,再加入线程会执行此策略。
首先保证核心数量的线程数,在没有任务时空转(不会阻塞),有任务则执行任务,这里减少了线程的创建和销毁,参考前一节线程创建的开销,权衡利弊;
新的任务是先放到workQueue当中,当workQueue满了,且当前运行的线程数小于maximumPoolSize,才会新建线程去执行workQueue当中的任务。新任务为啥不直接新建线程去处理,对任务队列的操作不是额外开销么,参考前一节线程创建切换的开销,权衡利弊。
线程池的corePoolSize并不是cpu的核心数,例如8核的cpu确实可以设置大于8个的核心线程数。
零拷贝
Java NIO中提供的FileChannel拥有transferTo和transferFrom两个方法,可直接把FileChannel中的数据拷贝到另外一个Channel,或者直接把另外一个Channel中的数据拷贝到FileChannel。该接口常被用于高效的网络/文件的数据传输和大文件拷贝。在操作系统支持的情况下,通过该方法传输数据并不需要将源数据从内核态拷贝到用户态,再从用户态拷贝到目标通道的内核态,同时也避免了两次用户态和内核态间的上下文切换,也即使用了“零拷贝”,所以其性能一般高于Java IO中提供的方法。
使用FileChannel的零拷贝将本地文件内容传输到网络的示例代码如下所示。
public class NIOClient {
public static void main(String[] args) throws IOException, InterruptedException {
SocketChannel socketChannel = SocketChannel.open();
InetSocketAddress address = new InetSocketAddress(1234);
socketChannel.connect(address);
RandomAccessFile file = new RandomAccessFile(
NIOClient.class.getClassLoader().getResource("test.txt").getFile(), "rw");
FileChannel channel = file.getChannel();
channel.transferTo(0, channel.size(), socketChannel);
channel.close();
file.close();
socketChannel.close();
}
}
中断
什么是中断
操作系统需要对连接到计算机上的所有硬件设备进行管理,要管理这些设备,首先得和它们互相通信才行,一般有两种方案可实现这种功能:
- 轮询(polling) 让内核定期对设备的状态进行查询,然后做出相应的处理;
- 中断(interrupt) 让硬件在需要的时候向内核发出信号(变内核主动为硬件主动)。
第一种方案会让内核做不少的无用功,因为轮询总会周期性的重复执行,大量地耗用 CPU 时间,因此效率及其低下,所以一般都是采用第二种方案 。
从物理学的角度看,中断是一种电信号,由硬件设备产生,并直接送入中断控制器(如 8259A)的输入引脚上,然后再由中断控制器向处理器发送相应的信号。处理器一经检测到该信号,便中断自己当前正在处理的工作,转而去处理中断。此后,处理器会通知 OS 已经产生中断。这样,OS 就可以对这个中断进行适当的处理,不同的设备对应的中断不同。【引自Linux 内核中断内幕】。
中断分类
中断可分为同步(synchronous)中断和异步(asynchronous)中断:
- 同步中断是当指令执行时由 CPU 控制单元产生,之所以称为同步,是因为只有在一条指令执行完毕后 CPU 才会发出中断,而不是发生在代码指令执行期间,比如系统调用。
- 异步中断是指由其他硬件设备依照 CPU 时钟信号随机产生,即意味着中断能够在指令之间发生,例如键盘中断。
网络IO模型
几种简单IO模型
这里内容真不多,就是一个逐步避免阻塞的过程。
-
阻塞IO
内核回应以前,线程处于阻塞状态,内核数据准备好之后,线程恢复就绪状态,开始执行后续操作,如前面说的,频繁切换带来不必要的消耗。并且由于线程阻塞,必须开启新的线程去处理其他请求。
-
非阻塞IO
内核回应以前,线程一直空转,看似非常消耗CPU,如果持续不断接收数据的话,比阻塞模型少了新建线程这一步。但是如果无事发生,线程空转是非常消耗CPU的。
-
多路复用IO(事件驱动)
多路复用赫赫有名,java NIO就是采用的多路复用。客户端发送的连接请求都会注册到某个列表中,服务端只要对这个列表进行轮询,对于就绪的事件进行处理,整个过程通过单线程完成。未就绪的事件不会像阻塞模型那样阻塞线程,没有事件发生时也不会像非阻塞模型那样空转。
旧的版本使用的Linux的内核函数select和poll,我表示记不住,只要知道新版本使用了内核函数epull。
epoll实现机制https://www.cnblogs.com/fatmanhappycode/p/12362423.html
总结下
通过我们设置的size预分配一个空间用于存放socket。
将注册的socket文件描述符(Linux万物皆文件)放入红黑树当中,这样查找事件复杂度为log2(n)。并且为系统的相关的中断注册一个回调函数,告诉系统一旦有相关事件,请从红黑树中查找相应的socket连接到双向链表当中。
这时候用户线程轮巡的时候只要去读取双向链表的值就可以了。
这里用hashmap查询不是更快?
-
信号驱动IO
-
异步IO
BIO
public class SocketServer {
public static void main(String[] args) throws IOException {
ServerSocket serverSocket = new ServerSocket(9000);
while (true) {
System.out.println("等待连接。。");
Socket socket = serverSocket.accept();//阻塞方法
System.out.println("有客户端连接了。。");
new Thread(new Runnable() {
@Override
public void run() {
try {
handler(socket);
} catch (IOException e) {
e.printStackTrace();
}
}
}).start();
//handler(socket);
}
}
private static void handler(Socket socket) throws IOException {
System.out.println("thread id = " + Thread.currentThread().getId());
byte[] bytes = new byte[1024];
System.out.println("准备read。。");
//接收客户端的数据,阻塞方法,没有数据可读时就阻塞
int read = socket.getInputStream().read(bytes);
System.out.println("read完毕。。");
if (read != -1) {
System.out.println("接收到客户端的数据:" + new String(bytes, 0, read));
System.out.println("thread id = " + Thread.currentThread().getId());
}
socket.getOutputStream().write("HelloClient".getBytes());
socket.getOutputStream().flush();
}
}
如果只关注服务端,响应一次请求主要有三处阻塞,连接等待.accept(),读等待.read(),写等待.write()。当IO没有就绪的时候,线程会在这三个位置出让cpu执行时间片,这里会转为核心态。为了能够处理接下来的请求,会创建新的线程去处理IO。如果对前面的内容有所了解,可以发现这几个点都是BIO的性能瓶颈所在。
非阻塞IO
public class SocketServer {
public static void main(String[] args) throws IOException {
ServerSocket serverSocket = new ServerSocket(9000);
while (true) {
System.out.println("等待连接。。");
Socket socket = serverSocket.accept();//阻塞方法,没有连接返回null
if(socket!=null){//接收到连接
//handler(socket);
}}}}
对比阻塞IO模型,这里.accept()没有收到连接请求不会一直阻塞。最近事情太多了,参照多路复用吧。
为何不用AIO?
Not faster than NIO (epoll) on unix systems (which is true)
There is no daragram suppport Unnecessary threading model (too much abstraction without usage)