《一线大厂Java面试题解析+核心总结学习笔记+最新讲解视频+实战项目源码》,点击传送门,即可获取!
我们准备用两个客户端同时发起连接请求、来模拟单线程阻塞模式的现象。同时发起连接,通过服务端日志,我们发现此时服务端只接受了其中一个连接,主线程被阻塞在上一个连接的read方法上。
我们尝试关闭第一个连接,看第二个连接的情况,我们希望看到的现象是,主线程返回,新的客户端连接被接受。
从日志中发现,在第一个连接被关闭后,第二个连接的请求被处理了,也就是说第二个连接请求在排队,直到主线程被唤醒,才能接收下一个请求,符合我们的预期。
此时不仅要问,为什么呢?
主要原因在于accept、read、write三个函数都是阻塞的,主线程在系统调用的时候,线程是被阻塞的,其他客户端的连接无法被响应。
通过以上流程,我们很容易发现这个过程的缺陷,服务器每次只能处理一个连接请求,CPU没有得到充分利用,性能比较低。如何充分利用CPU的多核特性呢?自然而然的想到了——多线程逻辑。
2.1.2 多线程阻塞
对工程师而言,代码解释一切,直接上代码。
BIO多线程
package net.io.bio;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.ServerSocket;
import java.net.Socket;
public class BioTest {
public static void main(String[] args) throws IOException {
final ServerSocket server=new ServerSocket(8081);
while(true) {
new Thread(new Runnable() {
public void run() {
Socket socket=null;
try {
socket = server.accept();
System.out.println(“accept port:”+socket.getPort());
BufferedReader in=new BufferedReader(new InputStreamReader(socket.getInputStream()));
String inData=null;
while ((inData = in.readLine()) != null) {
System.out.println(“client port:”+socket.getPort());
System.out.println(“input data:”+inData);
if(“close”.equals(inData)) {
socket.close();
}
}
} catch (IOException e) {
e.printStackTrace();
} finally {
}
}
}).start();
}
}
}
同样,我们并行发起两个请求;
两个请求,都被接受,服务端新增两个线程来处理客户端的连接和后续请求。
我们用多线程解决了,服务器同时只能处理一个请求的问题,但同时又带来了一个问题,如果客户端连接比较多时,服务端会创建大量的线程来处理请求,但线程本身是比较耗资源的,创建、上下文切换都比较耗资源,又如何去解决呢?
2.2 非阻塞
如果我们把所有的Socket(文件句柄,后续用Socket来代替fd的概念,尽量减少概念,减轻阅读负担)都放到队列里,只用一个线程来轮训所有的Socket的状态,如果准备好了就把它拿出来,是不是就减少了服务端的线程数呢?
一起看下代码,单纯非阻塞模式,我们基本上不用,为了演示逻辑,我们模拟了相关代码如下;
package net.io.bio;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.ServerSocket;
import java.net.Socket;
import java.net.SocketTimeoutException;
import java.util.ArrayList;
import java.util.List;
import org.apache.commons.collections4.CollectionUtils;
public class NioTest {
public static void main(String[] args) throws IOException {
final ServerSocket server=new ServerSocket(8082);
server.setSoTimeout(1000);
List sockets=new ArrayList();
while (true) {
Socket socket = null;
try {
socket = server.accept();
socket.setSoTimeout(500);
sockets.add(socket);
System.out.println(“accept client port:”+socket.getPort());
} catch (SocketTimeoutException e) {
System.out.println(“accept timeout”);
}
//模拟非阻塞:轮询已连接的socket,每个socket等待10MS,有数据就处理,无数据就返回,继续轮询
if(CollectionUtils.isNotEmpty(sockets)) {
for(Socket socketTemp:sockets ) {
try {
BufferedReader in=new BufferedReader(new InputStreamReader(socketTemp.getInputStream()));
String inData=null;
while ((inData = in.readLine()) != null) {
System.out.println(“input data client port:”+socketTemp.getPort());
System.out.println(“input data client port:”+socketTemp.getPort() +“data:”+inData);
if(“close”.equals(inData)) {
socketTemp.close();
}
}
} catch (SocketTimeoutException e) {
System.out.println(“input client loop”+socketTemp.getPort());
}
}
}
}
}
}
系统初始化,等待连接;
发起两个客户端连接,线程开始轮询两个连接中是否有数据。
两个连接分别输入数据后,轮询线程发现有数据准备好了,开始相关的逻辑处理(单线程、多线程都可)。
再用一张流程图辅助解释下(系统实际采用文件句柄,此时用Socket来代替,方便大家理解)。
服务端专门有一个线程来负责轮询所有的Socket,来确认操作系统是否完成了相关事件,如果有则返回处理,如果无继续轮询,大家一起来思考下?此时又带来了什么问题呢。
CPU的空转、系统调用(每次轮询到涉及到一次系统调用,通过内核命令来确认数据是否准备好),造成资源的浪费,那有没有一种机制,来解决这个问题呢?
2.3 IO多路复用
server端有没专门的线程来做轮询操作(应用程序端非内核),而是由事件来触发,当有相关读、写、连接事件到来时,主动唤起服务端线程来进行相关逻辑处理。模拟了相关代码如下;
IO多路复用
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.Charset;
import java.util.Iterator;
import java.util.Set;
public class NioServer {
private static Charset charset = Charset.forName(“UTF-8”);
public static void main(String[] args) {
try {
Selector selector = Selector.open();
ServerSocketChannel chanel = ServerSocketChannel.open();
chanel.bind(new InetSocketAddress(8083));
chanel.configureBlocking(false);
chanel.register(selector, SelectionKey.OP_ACCEPT);
while (true){
int select = selector.select();
if(select == 0){
System.out.println(“select loop”);
continue;
}
System.out.println(“os data ok”);
Set selectionKeys = selector.selectedKeys();
Iterator iterator = selectionKeys.iterator();
while (iterator.hasNext()){
SelectionKey selectionKey = iterator.next();
if(selectionKey.isAcceptable()){
ServerSocketChannel server = (ServerSocketChannel)selectionKey.channel();
SocketChannel client = server.accept();
client.configureBlocking(false);
client.register(selector, SelectionKey.OP_READ);
//继续可以接收连接事件
selectionKey.interestOps(SelectionKey.OP_ACCEPT);
}else if(selectionKey.isReadable()){
//得到SocketChannel
SocketChannel client = (SocketChannel)selectionKey.channel();
//定义缓冲区
ByteBuffer buffer = ByteBuffer.allocate(1024);
StringBuilder content = new StringBuilder();
while (client.read(buffer) > 0){
buffer.flip();
content.append(charset.decode(buffer));
}
System.out.println(“client port:”+client.getRemoteAddress().toString()+",input data: "+content.toString());
//清空缓冲区
buffer.clear();
}
iterator.remove();
}
}
} catch (Exception e) {
e.printStackTrace();
}
}
}
同时创建两个连接;
两个连接无阻塞的被创建;
无阻塞的接收读写;
再用一张流程图辅助解释下(系统实际采用文件句柄,此时用Socket来代替,方便大家理解)。
当然操作系统的多路复用有好几种实现方式,我们经常使用的select(),epoll模式这里不做过多的解释,有兴趣的可以查看相关文档,IO的发展后面还有异步、事件等模式,我们在这里不过多的赘述,我们更多的是为了解释Redis线程模式的发展。
三、NIO线程模型解释
===========
我们一起来聊了阻塞、非阻塞、IO多路复用模式,那Redis采用的是哪种呢?
Redis采用的是IO多路复用模式,所以我们重点来了解下多路复用这种模式,如何在更好的落地到我们系统中,不可避免的我们要聊下Reactor模式。
首先我们做下相关的名词解释;
Reactor:类似NIO编程中的Selector,负责I/O事件的派发;
Acceptor:NIO中接收到事件后,处理连接的那个分支逻辑;
Handler:消息读写处理等操作类。
3.1 单Reactor单线程模型
处理流程
- Reactor监听连接事件、Socket事件,当有连接事件过来时交给Acceptor处理,当有Socket事件过来时交个对应的Handler处理。
优点
-
模型比较简单,所有的处理过程都在一个连接里;
-
实现上比较容易,模块功能也比较解耦,Reactor负责多路复用和事件分发处理,Acceptor负责连接事件处理,Handler负责Scoket读写事件处理。
缺点
-
只有一个线程,连接处理和业务处理共用一个线程,无法充分利用CPU多核的优势。
-
在流量不是特别大、业务处理比较快的时候系统可以有很好的表现,当流量比较大、读写事件比较耗时情况下,容易导致系统出现性能瓶颈。
怎么去解决上述问题呢?既然业务处理逻辑可能会影响系统瓶颈,那我们是不是可以把业务处理逻辑单拎出来,交给线程池来处理,一方面减小对主线程的影响,另一方面利用CPU多核的优势。这一点希望大家要理解透彻,方便我们后续理解Redis由单线程模型到多线程模型的设计的思路。
3.2 单Reactor多线程模型
这种模型相对单Reactor单线程模型,只是将业务逻辑的处理逻辑交给了一个线程池来处理。
处理流程
-
Reactor监听连接事件、Socket事件,当有连接事件过来时交给Acceptor处理,当有Socket事件过来时交个对应的Handler处理。
-
Handler完成读事件后,包装成一个任务对象,交给线程池来处理,把业务处理逻辑交给其他线程来处理。
优点
-
让主线程专注于通用事件的处理(连接、读、写),从设计上进一步解耦;
-
利用CPU多核的优势。
缺点
- 貌似这种模型已经很完美了,我们再思考下,如果客户端很多、流量特别大的时候,通用事件的处理(读、写)也可能会成为主线程的瓶颈,因为每次读、写操作都涉及系统调用。
有没有什么好的办法来解决上述问题呢?通过以上的分析,大家有没有发现一个现象,当某一个点成为系统瓶颈点时,想办法把他拿出来,交个其他线程来处理,那这种场景是否适用呢?
3.3 多Reactor多线程模型
这种模型相对单Reactor多线程模型,只是将Scoket的读写处理从mainReactor中拎出来,交给subReactor线程来处理。
处理流程
-
mainReactor主线程负责连接事件的监听和处理,当Acceptor处理完连接过程后,主线程将连接分配给subReactor;
-
subReactor负责mainReactor分配过来的Socket的监听和处理,当有Socket事件过来时交个对应的Handler处理;
Handler完成读事件后,包装成一个任务对象,交给线程池来处理,把业务处理逻辑交给其他线程来处理。
优点
-
让主线程专注于连接事件的处理,子线程专注于读写事件吹,从设计上进一步解耦;
-
利用CPU多核的优势。
缺点
- 实现上会比较复杂,在极度追求单机性能的场景中可以考虑使用。
四、Redis的线程模型
============
4.1 概述
以上我们聊了,IO网路模型的发展历史,也聊了IO多路复用的reactor模式。那Redis采用的是哪种reactor模式呢?在回答这个问题前,我们先梳理几个概念性的问题。
Redis服务器中有两类事件,文件事件和时间事件。
文件事件:在这里可以把文件理解为Socket相关的事件,比如连接、读、写等;
时间时间:可以理解为定时任务事件,比如一些定期的RDB持久化操作。
分享
1、算法大厂——字节跳动面试题
2、2000页互联网Java面试题大全
3、高阶必备,算法学习
《一线大厂Java面试题解析+核心总结学习笔记+最新讲解视频+实战项目源码》,点击传送门,即可获取!
优点
-
让主线程专注于连接事件的处理,子线程专注于读写事件吹,从设计上进一步解耦;
-
利用CPU多核的优势。
缺点
- 实现上会比较复杂,在极度追求单机性能的场景中可以考虑使用。
四、Redis的线程模型
============
4.1 概述
以上我们聊了,IO网路模型的发展历史,也聊了IO多路复用的reactor模式。那Redis采用的是哪种reactor模式呢?在回答这个问题前,我们先梳理几个概念性的问题。
Redis服务器中有两类事件,文件事件和时间事件。
文件事件:在这里可以把文件理解为Socket相关的事件,比如连接、读、写等;
时间时间:可以理解为定时任务事件,比如一些定期的RDB持久化操作。
分享
1、算法大厂——字节跳动面试题
[外链图片转存中…(img-8ZKe7ezU-1714742069407)]
2、2000页互联网Java面试题大全
[外链图片转存中…(img-4sahpKTE-1714742069408)]
3、高阶必备,算法学习
[外链图片转存中…(img-uj0ajwqK-1714742069408)]
《一线大厂Java面试题解析+核心总结学习笔记+最新讲解视频+实战项目源码》,点击传送门,即可获取!