1. 网络分层模型
因特网协议栈共有五层:应用层、传输层、网络层、链路层、物理层。不同于OSI七层模型这也是实际使用中使用的分层方式。
- 应用层
支持网络应用,应用协议仅仅是网络应用的一个组成部分,运行在不同主机上的进程则使用应用层协议进行通信。主要的协议有:http、ftp、telnet、smtp、pop3等。 - 传输层
负责为信源和信宿提供应用程序进程间的数据传输服务,这一层上主要定义了两个传输协议,传输控制协议即TCP和用户数据报协议UDP。 - 网络层
负责将数据报独立地从信源发送到信宿,主要解决路由选择、拥塞控制和网络互联等问题。 - 链路层
负责将IP数据报封装成合适在物理网络上传输的帧格式并传输,或将从物理网络接收到的帧解封,取出IP数据报交给网络层。 - 物理层
负责将比特流在结点间传输,即负责物理传输。该层的协议既与链路有关也与传输介质有关。
网络分层模型优点:
- 分层结构将应用系统正交地划分为若干层,每一层只解决问题的一部分,通过各层的协作提供整体解决方案。大的问题被分解为一系列相对独立的子问题,局部化在每一层中,这样就有效的降低了单个问题的规模和复杂度,实现了复杂系统的第一步也是最为关键的一步分解。
- 分层结构具有良好的可扩展性,为应用系统的演化增长提供了一个灵活的框架,具有良好的可扩展性。增加新的功能时,无须对现有的代码做修改,业务逻辑可以得到最大限度的重用。同时,层与层之间可以方便地插入新的层来扩展应用。
- 分层架构易于维护。在对系统进行分解后,不同的功能被封装在不同的层中,层与层之间的耦合显著降低。因此在修改某个层的代码时,只要不涉及层与层之间的接口,就不会对其他层造成严重影响。
2. Socket概念
套接字(Socket) 是通信的基石,是支持TCP/IP协议的网络通信的基本操作单元。它是网络通信过程中端点的抽象表示,包含进行网络通信必须的五种信息:
- 连接使用的协议
- 本地主机的IP地址
- 本地进程的协议端口
- 远地主机的IP地址
- 远地进程的协议端口
网络异常,图片无法展示|
Socket是应用层与TCP/IP协议族通信的中间软件抽象层,它是一组接口。在设计模式中,Socket其实就是一个门面模式,它把复杂的TCP/IP协议族隐藏在Socket接口后面,对用户来说,一组简单的接口就是全部,让Socket去封装复杂的传输层协议(TCP/IP)并承载应用层数据格式协议(http\telnet\ftp)完成服务间数据传输交互。
网络异常,图片无法展示|
一个Socket是一对IP地址和端口。Socket可以看成在两个程序进行通讯连接中的一个端点,一个程序将一段信息写入Socket中,该Socket将这段信息发送给另外一个Socket中,使这段信息能传送到其他程序中。你可以这么理解:socket是进程之间用来对话的中间层工具。
3. Socket作用
应用层通过传输层进行数据通信时,TCP和UDP会遇到同时为多个应用程序进程提供并发服务的问题。为了区别不同的应用程序进程和连接,许多计算机操作系统为应用程序与TCP/IP协议交互提供了称为套接字(Socket)的接口,区分不同应用程序进程间的网络通信和连接。
- Socket(套接字) 维护的是一对服务ip、端口之间的映射关系,通过ip、端口能够溯源到通信双方的信息。
- Socket本身并不是协议 Http协议是处于应用层的数据传输组装方式,而TCP/IP协议是处于传输层的数据组装方式,在网络分层模型中独立分隔各自在对应的层级中发挥作用。
- Socket 是对TCP/IP 或者UDP/IP协议封装的调用接口(API) , 是协调应用层和传输层的交互的通道和工具,通过这个API程序员在开发网络应用程序的时候,就可以不用关心底层是怎么实现的,减轻开发的难度。
4. Socket、Http、TCP/IP的关系
名称 |
含义 |
网络模型层级 |
作用 |
http、ftp、telnet |
数据文本格式协议 |
应用层 |
解决如何包装数据 |
socket |
套接字 |
应用层<=>传输层 |
封装TCP/IP传输层协议,连接的抽象,对应用层使用传输层提供API支持 |
TCP/IP |
传输通信协议 |
传输层 |
服务数据传输交互 |
- 创建Socket连接时,可以指定使用的传输层协议,Socket可以支持不同的传输层协议(TCP或UDP),当使用TCP协议进行连接时,该Socket连接就是一个TCP连接。
- 我们在传输数据时,可以只使用(传输层)TCP/IP协议,但是那样的话,如 果没有应用层,便无法识别数据内容,如果想要使传输的数据有意义,则必须使用到应用层协议,应用层协议有很多,比如HTTP、FTP、TELNET等,也 可以自己定义应用层协议。WEB使用HTTP协议作应用层协议,以封装HTTP文本信息,然后使用TCP/IP做传输层协议将它发到网络上。
5. Socket交互
服务端(Server)
- socket() socket初始化
- listen() 开启服务端口监听
- accept() 等待Client连接
- read() 读取Client发送的数据
- write() 向Client发送数据
- close() 关闭socket连接
客户端(Client)
- socket() socket初始化
- read() 读取Server发送的数据
- write() 向Server发送数据
- close() 关闭socket连接
6. Socket模型实现
所谓模型实现,可以泛泛认为是网络IO模型的实现,Socket可以视为一个网络IO交互的通道,是数据载体的抽象,也就是说输入(Input)、输出(Output)数据最终都是作为数据流通过Socket架起的桥梁进行服务间通信的,因此这里通过IO模型配合Socket进行论述。
6.1 单线程 BIO 模型
单线程BIO模型不能支持并发请求,同一时刻只能对一个服务请求进行响应,其他服务请求将会被阻塞,而且在数据也是阻塞读,所有的服务请求都是顺序执行的,这是单线程BIO模型的缺点。
缺点
- accept()方法 调用accept方法后意味着阻塞等待服务连接请求,一旦有服务请求连接则不再接收其他服务请求的连接。当一次服务请求结束后需要再次调用accept方法才可以再次接收服务请求。因此一般会进行while轮询,上一次处理完随即调用accept将服务状态就绪,等待新服务连接的请求。
- read()方法 调用read方法后意味着在Socket连接中对服务连接方的输入流进行读取,此时方法是阻塞读取的,无论此时是否有对端数据输入进来,方法会一直阻塞。因此在此场景下会迟迟无法再次调用accept方法,其他新服务请求也就无法连接。
以下是单线程 BIO Server代码:
/**
* 每次只能处理一个连接请求,其他连接请求会被阻塞
* created by guanjian on 2021/1/12 9:09
*/
public class SingleThreadBIOSocketServer {
private static byte[] bytes = new byte[1024];
public static void main(String[] args) throws IOException, InterruptedException {
ServerSocket socketServer = new ServerSocket();
socketServer.bind(new InetSocketAddress(9999));
System.out.println("Server starting ...");
while (true) {
System.out.println("Server waiting connect ...");
//accept方法会阻塞
Socket socket = socketServer.accept();
long start = System.currentTimeMillis();
System.out.println("Server begin receive data ...");
//read方法会阻塞
socket.getInputStream().read(bytes);
long end = System.currentTimeMillis();
System.out.format("Server finish receive data: %s , cost: %s \n", new String(bytes), end - start);
}
}
}
以下是Client代码:
/**
* 单独一个socket请求
* created by guanjian on 2021/1/12 9:09
*/
public class SingleThreadNIOSocketClient {
public static void main(String[] args) throws IOException {
SocketChannel socketClient = SocketChannel.open();
socketClient.connect(new InetSocketAddress("127.0.0.1", 9999));
System.out.println("Client connected ...");
Scanner scanner = new Scanner(System.in);
while (true){
//会阻塞socket等待输入
String input = scanner.next();
if ("close".equals(input)){
socketClient.close();
return;
}
socketClient.write(ByteBuffer.wrap(input.getBytes()));
System.out.format("Client sending content:%s \n", input);
}
}
}
6.2 多线程 BIO 模型
针对单线程 BIO 模型进行优化,将单线程处理服务连接请求改为 多线程并行处理 ,解决了accept阻塞问题,但是read仍然是 阻塞读 。
- 优点
①通过多线程接收服务请求,即每次accept收到连接请求随即开启线程处理,之后立刻调用accept接收新服务请求,accept阻塞问题得到解决
②由于一个线程对应一个连接请求,服务请求之前互不影响,Socket 1 出现阻塞和异常不会影响Socket 2 - 缺点
当连接量巨大时,使用大量线程资源处理服务请求撑爆服务器,比如双十一大促场景
Server代码如下:
**
* 开启多线程来接收处理Socket请求
* 不会被阻塞socket请求,但是会耗费大量线程资源
*
* created by guanjian on 2021/1/12 9:09
*/
public class MultiThreadBIOSocketServer {
private static byte[] bytes = new byte[1024];
public static void main(String[] args) throws IOException, InterruptedException {
ServerSocket socketServer = new ServerSocket();
socketServer.bind(new InetSocketAddress(9999));
System.out.println("Server starting ...");
while (true) {
System.out.println("Server waiting connect ...");
Socket socket = socketServer.accept();
//接收请求后开启线程来处理,避免accept阻塞
if (null != socket) {
new SockectServerHandler(socket).start();
}
}
}
static class SockectServerHandler extends Thread {
private Socket socket;
public SockectServerHandler(Socket socket) {
this.socket = socket;
}
@Override
public void run() {
try {
long start = System.currentTimeMillis();
System.out.println("Server begin receive data ...");
//read方法会阻塞
socket.getInputStream().read(bytes);
long end = System.currentTimeMillis();
System.out.format("Server finish receive data: %s , cost: %s \n", new String(bytes), end - start);
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
6.3 单线程 NIO 模型
尽管多线程BIO通过多线程解决了并发处理的问题,但是read仍然是 阻塞读 ,且accept连接依然是 阻塞等待 ,也存在资源占用浪费的问题。下面通过JDK的NIO包提供的NIO模型来进一步解决accept、read阻塞问题。
- 优点
①ServerSocketChannel是SocketServer的NIO版本,支持accept函数阻塞、非阻塞控制,通过configureBlocking进行配置,解决accept阻塞等待耗费资源的问题
②SocketChannel是Socket的NIO版本,支持read函数阻塞、非阻塞控制,通过configureBlocking进行配置,解决read阻塞等待耗费资源的问题 - 缺点
①需要维护SocketChannel,遍历检查是否有新的连接(accept)到达,且检查是否有新的数据流(read)输入
②由于是遍历操作,当SocketChannel集合非常巨大时会成有性能问题,需要及时处理的Channel无法立刻进行处理,比如当前有10万个channel保持连接,遍历需要从1到10万依次遍历,而可能第10万个Channel此时需要处理,而大部分时间和性能都耗费在前10万-1个Channel的遍历和轮询上
③遍历channel每次进行read都会经历用户态、内核态切换
Client可以使用SocketChannel这种NIO模型,也可以使用之前介绍的Socket这种BIO模型,主要的是Server要使用NIO模型。Server代码如下:
/**
* 单线程处理
* created by guanjian on 2021/1/12 9:09
*/
public class SingleThreadNIOSocketChannelServer {
/**
* 存储SocketChannel集合
*/
private static List<SocketChannel> channels = Lists.newArrayList();
public static void main(String[] args) throws IOException, InterruptedException {
ServerSocketChannel socketServer = ServerSocketChannel.open();
socketServer.socket().bind(new InetSocketAddress(9999));
//设置非阻塞模式,这里控制的是accept函数是否阻塞
socketServer.configureBlocking(false);
System.out.println("Server starting ...");
while (true) {
System.out.println("Server waiting connect ...");
SocketChannel socketChannel = socketServer.accept();
if (null != socketChannel) {
System.out.println("Server receive connect ...");
//设置非阻塞模式,这里控制的是read函数是否阻塞
socketChannel.configureBlocking(false);
channels.add(socketChannel);
}
System.out.format("============ channels:%s ============\n", channels.size());
Optional.ofNullable(channels).ifPresent(channels->{
channels.stream().filter(channel -> channel.isConnected()).forEach(channel -> {
try {
System.out.println("Server read data begin ...");
ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
int len = channel.read(byteBuffer);
if (len > 0) {
byteBuffer.flip();
System.out.format("Server receive data : %s ...\n", byteBufferToString(byteBuffer));
} else {
System.out.println("Server receive data is empty ...");
}
System.out.println("Server read data end ...");
} catch (IOException e) {
e.printStackTrace();
}
});
});
//为了打印日志,故意设置时间间隔
Thread.sleep(2000);
}
}
public static String byteBufferToString(ByteBuffer buffer) {
CharBuffer charBuffer = null;
try {
Charset charset = Charset.forName("UTF-8");
CharsetDecoder decoder = charset.newDecoder();
charBuffer = decoder.decode(buffer);
return charBuffer.toString();
} catch (Exception ex) {
ex.printStackTrace();
return null;
}
}
}
6.4 单线程 NIO 多路复用 模型
在JDK的NIO模型中,除了上述解决阻塞问题外,还提供了强大的选择器功能,可以做到多路复用的效果。选择器是基于事件驱动的,相比于遍历自身维护的Channel集合,Selector选择器的select函数会依赖系统底层提供epoll函数一次性获取到监听的channel文件描述符,因此无需遍历所有channel。
- 优点:
依赖系统底层事件驱动函数epoll一次性获取所关心触发监听事件的socketChannel,时间复杂度从全局遍历的O(n) 降低到 O(1) - 注意:
由于依赖操作系统底层API支持,因此不同OS效果不同,Linux下实现了epoll函数 - 总结:
如上所述,Socket是对底层协议的封装,所有的API也都是依赖底层的映射和封装
Client代码可以复用以上,Server代码如下:
/**
* 单线程处理 多路复用
* created by guanjian on 2021/1/12 9:09
*/
public class SingleThreadNIOSocketChannelSelectorServer {
public static void main(String[] args) throws IOException, InterruptedException {
// 创建ServerSocketChannel通道,绑定监听端口为8080
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
serverSocketChannel.socket().bind(new InetSocketAddress(9999));
// 设置为非阻塞模式
serverSocketChannel.configureBlocking(false);
// 注册选择器,设置选择器选择的操作类型
Selector selector = Selector.open();
serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
System.out.println("Server starting ...");
while (true) {
System.out.println("Server receive request ...");
// 等待请求,每次等待阻塞3s,超过时间则向下执行,若传入0或不传值,则在接收到请求前一直阻塞
if (selector.select(1000) > 0) {
System.out.println("Server receive event ...");
// 获取待处理的选择键集合
Iterator<SelectionKey> keyIterator = selector.selectedKeys().iterator();
while (keyIterator.hasNext()) {
SelectionKey selectionKey = keyIterator.next();
// 如果是连接请求,调用处理器的连接处理方法
if (selectionKey.isAcceptable()) {
System.out.println("Server receive connect ...");
handleAccept(selectionKey);
}
// 如果是读请求,调用对应的读方法
if (selectionKey.isReadable()) {
System.out.println("Server receive read ...");
handleRead(selectionKey);
}
// 处理完毕从待处理集合移除该选择键
keyIterator.remove();
}
}
//为了打印日志,故意设置时间间隔
Thread.sleep(2000);
}
}
public static void handleAccept(SelectionKey selectionKey) throws IOException {
SocketChannel socketChannel = ((ServerSocketChannel) selectionKey.channel()).accept();
socketChannel.configureBlocking(false);
socketChannel.register(selectionKey.selector(), SelectionKey.OP_READ, ByteBuffer.allocate(1024));
}
public static void handleRead(SelectionKey selectionKey) throws IOException {
// 获取套接字通道
SocketChannel socketChannel = (SocketChannel) selectionKey.channel();
// 获取缓冲器并进行重置,selectionKey.attachment()为获取选择器键的附加对象
ByteBuffer byteBuffer = (ByteBuffer) selectionKey.attachment();
byteBuffer.clear();
// 没有内容则关闭通道
if (socketChannel.read(byteBuffer) == -1) {
socketChannel.close();
} else {
// 将缓冲器转换为读状态
byteBuffer.flip();
// 将缓冲器中接收到的值按localCharset格式编码保存
String receivedRequestData = Charset.forName("UTF-8").newDecoder().decode(byteBuffer).toString();
System.out.format("Server receive data:" + receivedRequestData);
// 关闭通道
//socketChannel.close();
}
}
}
6.5 性能对比
模型 |
线程资源 |
IO类型 |
优点 |
缺点 |
单线程 BIO 模型 |
单线程 |
阻塞IO |
线程资源消耗小 |
不能支持并发 |
多线程 BIO 模型 |
多线程 |
阻塞IO |
支持并发 |
线程资源消耗大 |
单线程 NIO 模型 |
单线程 |
非阻塞IO |
①线程消耗资源小 ②接收请求(accept)非阻塞、读取数据流(read)非阻塞 |
需遍历socket,时间复杂度O(n) |
单线程 NIO 多路复用 模型 |
单线程 |
非阻塞IO |
①线程消耗资源小 ②接收请求(accept)非阻塞、读取数据流(read)非阻塞 ③事件驱动,获取socket时间复杂度O(1) |
依赖OS底层支持和实现 |
7. 参考
https://www.bilibili.com/video/BV1Sh411o77h
https://www.bilibili.com/video/BV1E54y1i7pA
https://baike.baidu.com/item/网络分层/659207
https://www.cnblogs.com/pipci/p/12527394.html
https://www.cnblogs.com/wangcq/p/3520400.html
https://www.cnblogs.com/yinyu/p/5298916.html
https://blog.csdn.net/phoenix_cat/article/details/84595505
https://www.cnblogs.com/lxyit/p/9209407.html
https://blog.csdn.net/ycgslh/article/details/79604074
https://www.cnblogs.com/chenss15060100790/p/9368959.html