OSI 网络七层模型
介绍
为使不同计算机厂家的计算机能够互相通信,以便在更大的范围内建立计算机网络,有必要建立一个国际范围的网络体系结构的标准。
组成
-
低三层,开发人员一般不用关注
物理层:使原始的数据比特流在物理介质上传输。
数据链路层:通过校验、确认和反馈重发等手段,形成稳定的数据链路。(01010101)
网络层:进行路由选择和流量控制。(IP协议)
-
传输层
提供可靠的端口到端口的数据传输服务(TCP/UDP协议)。
-
高三层,一般是web容器做的事情,比如Tomcat
会话层:负责建立、管理和终止进程之间的会话和数据交换
表示层:负责数据格式转换、数据加密与解密、压缩与解压等。
应用层:为用户的应用进程提供网络服务。
TCP 、UDP
TCP
TCP提供面向连接、可靠、有序、字节流传输服务。应用程序在使用TCP之前,必须先建立TCP连接。建立连接需要三次握手、释放连接需要四次挥手。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-wOn2LuFr-1648198220574)(C:\Users\admin\AppData\Roaming\Typora\typora-user-images\image-20220226103608337.png)]
UDP
用户数据报协议UDP是Internet传输层协议。提供无连接、不可靠、数据报尽力传输服务。数据格式比较简单。针对ip和端口有目的地的发送,具有如下几个特点:
- 应用进程更容易控制发送数据以及何时发送(如TCP需要建立连接,连接成功时长受网络影响不可控)
- 无需要建立连接
- 无连接状态
- 首部开销小 源端口
TCP 和UDP比较
TCP | UDP |
---|---|
面向链接 | 无连接 |
提供可靠性保证 | 不保证 |
慢 | 快 |
资源占用多 | 资源占用少 |
Soket编程
调用过程
创建套接字=>端点绑定=>发送数据=>接收数据=>释放套接字
Socket API函数定义
服务端: listen()、accept()
客户端:connect()
两端通用函数:socket()、bind()、send()、recv()、sendto()、recvfrom()、close()
BIO
介绍
-
阻塞I/O(blocking I/O)模型,进程调用recvfrom,其系统调用直到数据报到达且被拷贝到应用进程的缓冲区中或者发生错误才返回。进程从调用recvfrom开始到它返回的整段时间内是被阻塞的。
**阻塞调用是指调用结果返回之前,当前线程会被挂起。**调用线程只有在得到结果之后才会返回,阻塞导致在处理网络I/O时,一个线程只能处理一个网络连接,造成资源极大的浪费。
BIO即阻塞I/O,不管是磁盘I/O还是网络I/O,数据在写入OutputStream或者从InputStream读取时都有可能会阻塞,一旦有阻塞,线程将会失去CPU的使用权,这在当前的大规模访问量和有性能要求的情况下是不能被接受的。虽然当前的网络I/O有一些解决办法,如一个客户端一个处理线程,出现阻塞时只是一个线程阻塞而不会影响其他线程工作,还有为了减少系统线程的开销,采用线程池的办法来减少线程创建和回收的成本,但是有一些使用场景下仍然是无法解决的。
传统Socket阻塞案例代码
网络编程的基本模型是C/S模型,即两个进程间的通信。
服务端提供IP和监听端口,客户端通过连接操作想服务端监听的地址发起连接请求,通过三次握手连接,如果连接成功建立,双方就可以通过套接字进行通信。
传统的同步阻塞模型开发中,ServerSocket负责绑定IP地址,启动监听端口;Socket负责发起连接操作。连接成功后,双方通过输入和输出流进行同步阻塞式通信。
简单的描述一下BIO的服务端通信模型:采用BIO通信模型的服务端,通常由一个独立的Acceptor线程负责监听客户端的连接,它接收到客户端连接请求之后为每个客户端创建一个新的线程进行链路处理没处理完成后,通过输出流返回应答给客户端,线程销毁。即典型的一请求一应答通宵模型。
public class BIOClient {
private static Charset charset = Charset.forName("UTF-8");
public static void main(String[] args) throws Exception {
Socket s = new Socket("localhost", 8080);
OutputStream out = s.getOutputStream();
Scanner scanner = new Scanner(System.in);
System.out.println("请输入:");
String msg = scanner.nextLine();
out.write(msg.getBytes(charset)); // 阻塞,写完成
scanner.close();
s.close();
}
}
/****
* BIOServer 由于reader.readLine() 是阻塞的,启动多个BIOClient,只能处理完第一个才能接下来处理其他的,如果第一个没有处理完第二个连接会阻塞到
* serverSocket.accept()处
*/
public class BIOServer {
public static void main(String[] args) throws Exception {
ServerSocket serverSocket = new ServerSocket(8080);
System.out.println("服务器启动成功");
while (!serverSocket.isClosed()) {
Socket request = serverSocket.accept();//等待接收连接阻塞
System.out.println("收到新连接 : " + request.toString());
try {
// 接收数据、打印
InputStream inputStream = request.getInputStream(); // net + i/o
BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, "utf-8"));
String msg;
while ((msg = reader.readLine()) != null) { // 没有数据,阻塞
if (msg.length() == 0) {
break;
}
System.out.println(msg);
}
System.out.println("收到数据,来自:"+ request.toString());
} catch (IOException e) {
e.printStackTrace();
} finally {
try {
request.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
serverSocket.close();
}
}
该模型最大的问题是,BIOServer是同时只能处理一个BIOCient请求,比如启动两个BIOClient去连接BIOServer,只有第一个完成处理,第二个才能被连接处理,当第一个连接上的BIOClient没有处理完,比如客户端没有输入内容,reader.readLine()因获取不到内容被阻塞,第二个连接会被阻塞在serverSocket.accept()处
伪异步I/O
为了改进这种一连接一线程的模型,我们可以使用线程池来管理这些线程,实现1个或多个线程处理N个客户端的模型(但是底层还是使用的同步阻塞I/O),通常被称为“伪异步I/O模型”。
// 多线程支持,一个线程处理一个BIOClient
public class BIOServer1 {
private static ExecutorService threadPool = Executors.newCachedThreadPool();
public static void main(String[] args) throws Exception {
ServerSocket serverSocket = new ServerSocket(8080);
System.out.println("tomcat 服务器启动成功");
while (!serverSocket.isClosed()) {
Socket request = serverSocket.accept();
System.out.println("收到新连接 : " + request.toString());
threadPool.execute(() -> {
try {
// 接收数据、打印
InputStream inputStream = request.getInputStream();
BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, "utf-8"));
String msg;
while ((msg = reader.readLine()) != null) { // 阻塞
if (msg.length() == 0) {
break;
}
System.out.println(msg);
}
System.out.println("收到数据,来自:"+ request.toString());
} catch (IOException e) {
e.printStackTrace();
} finally {
try {
request.close();
} catch (IOException e) {
e.printStackTrace();
}
}
});
}
serverSocket.close();
}
}
使用线程池一个线程处理一个客户端的请求,多个客户端请求互不影响。
加入 http协议 ,返回http内容
以上代码并不能支持数据返回给浏览器,虽然浏览器可以请求到对应对应的服务,但是并不支持数据返回,因为没有加入http协议.
public class BIOServer2 {
private static ExecutorService threadPool = Executors.newCachedThreadPool();
public static void main(String[] args) throws Exception {
ServerSocket serverSocket = new ServerSocket(8080);
System.out.println("服务器启动成功");
while (!serverSocket.isClosed()) {
Socket request = serverSocket.accept();
System.out.println("收到新连接 : " + request.toString());
threadPool.execute(() -> {
try {
// 接收数据、打印
InputStream inputStream = request.getInputStream();
BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, "utf-8"));
String msg;
while ((msg = reader.readLine()) != null) {
if (msg.length() == 0) {
break;
}
System.out.println(msg);
}
System.out.println("收到数据,来自:"+ request.toString());
// 响应结果 200
OutputStream outputStream = request.getOutputStream();
outputStream.write("HTTP/1.1 200 OK\r\n".getBytes());
outputStream.write("Content-Length: 11\r\n\r\n".getBytes());
outputStream.write("Hello World".getBytes());
outputStream.flush();
} catch (IOException e) {
e.printStackTrace();
} finally {
try {
request.close();
} catch (IOException e) {
e.printStackTrace();
}
}
});
}
serverSocket.close();
}
}
只需要在返回的时候加入http对应的返回协议即可返回数据给浏览器



阻塞、非阻塞、同步、非同步
1.同步和异步
同步:在发出一个同步调用时,在没有得到结果之前,该调用就不返回。
异步:在发出一个异步调用后,调用者不会立刻得到结果,该调用就返回了。
2.阻塞与非阻塞
阻塞调用是指调用结果返回之前,调用者会进入阻塞状态等待。只有在得到结果之后才会返回。
非阻塞调用是指在不能立刻得到结果之前,该函数不会阻塞当前线程,而会立刻返回。
阻塞调用:比如 socket 的 recv(),调用这个函数的线程如果没有数据返回,它会一直阻塞着,也就是 recv() 后面的代码都不会执行了,程序就停在 recv() 这里等待,所以一般把 recv() 放在单独的线程里调用。
非阻塞调用:比如非阻塞socket 的 send(),调用这个函数,它只是把待发送的数据复制到TCP输出缓冲区中,就立刻返回了,线程并不会阻塞,数据有没有发出去 send() 是不知道的,不会等待它发出去才返回的。
同步的定义看起来跟阻塞很像,但是同步跟阻塞是两个概念,同步调用的时候,线程不一定阻塞,调用虽然没返回,但它还是在运行状态中的,CPU很可能还在执行这段代码,而阻塞的话,它就肯定不在CPU中跑这个代码了。这就是同步和阻塞的区别。同步是肯定可以在,阻塞是肯定不在。
异步和非阻塞的定义比较像,两者的区别是异步是说调用的时候结果不会马上返回,线程可能被阻塞起来,也可能不阻塞,两者没关系。非阻塞是说调用的时候,线程肯定不会进入阻塞状态。
上面两组概念,就有4种组合。
同步阻塞调用:得不到结果不返回,线程进入阻塞态等待。
同步非阻塞调用:得不到结果不返回,线程不阻塞一直在CPU运行。
异步阻塞调用:去到别的线程,让别的线程阻塞起来等待结果,自己不阻塞。
异步非阻塞调用:去到别的线程,别的线程一直在运行,直到得出结果。
NIO
Channel和Selector,它们是NIO中的两个核心概念。我们仍用前面的城市交通工具来继续比喻NIO的工作方式,这里的Channel要比Socket更加具体,它可以比做某种具体的交通工具,如汽车或高铁,而Selector可以比做一个车站的车辆运行调度系统,它将负责监控每辆车的当前运行状态,是已经出战,还是在路上等。也就是它可以轮询每个Channel的状态
。当我们调用write()往SendQ中写数据时,当一次写的数据超过SendQ长度时需要按照SendQ的长度进行分割,这个过程中需要将用户空间数据和内核地址空间进行切换,而这个切换不是你可以控制的,但在Buffer中我们可以控制Buffer的容量、是否扩容以及如何扩容.
Selector 一般称 为选择器 ,当然你也可以翻译为 多路复用器 。它是Java NIO核心组件中的一个,用于检查一个或多个NIO Channel(通道)的状态是否处于可读、可写。如此可以实现单线程管理多个channels,也就是可以管理多个网络链接。
使用Selector的好处在于: 使用更少的线程来就可以来处理通道了, 相比使用多个线程,避免了线程上下文切换带来的开销。
Selector Server端原理
Selector的静态工厂创建一个选择器,创建一个服务端的Channel,绑定到一个Socket对象,并把这个通信信道注册到选择器上,把这个通信信道设置为非阻塞模式。然后就可以调用Selector的selectedKeys方法来检查已经注册在这个选择器上的所有通信信道是否有需要的事件发生,如果有某个事件发生,将会返回所有的SelectionKey,通过这个对象Channel方法就可以取得这个通信信道对象,从而可以读取通信的数据,而这里读取的数据是Buffer,这个Buffer是我们可以控制的缓冲器。
NIO是同步阻塞的
我们知道java nio是基于io多路复用模型,也就是我们经常提到的select,poll,epoll。io 多路复用本质是同步io,其需要调用方在读写事件就绪时主动去进行读写。在java nio中,通过selector来获取就绪的事件,当selector上监听的channel中没有就绪的读写时间时,其可以直接返回,或者设置一段超时后返回。可以看出java nio可以实现非则塞,而不像传统io里必须则塞当前线程直到可读或可写。所以,java nio可以实现非阻塞。
NIO 核心组件
Buffer缓冲区
缓存区本质上是一个可以写入数据的内存块(类似数组),然后可以再次读取。此内存块包含在NIO Buffer对象中,该对象提供一组方法,可以轻松的使用内存块。
Buffer三个重要属性:
capacity容量: 作为一个内存块,Buffer具有一定的固定大小,也称为 容量.
positiion位置:写入模式时代表写数据的位置。读取模式时代表读取数据的位置。
limit限制: 写入模式,限制等于buffer的容量。读取模式下,limit等于写入的数据量
flip:在写模式下调用flip方法,那么limit就设置为了position当前的值(即当前写了多少数据),postion会被置为0,以表示读操作从缓存的头开始读。
Channel通道
和标准IO Stream 操作的区别: 在一个通道内进行读取和写入,stream通常是单向的(input或output),可以非阻塞读取和写入通道,通道始终读取和写入缓冲区。
SocketChannel:用于建立TCP网络连接,类似java.net.Socket.有两种创建socketChannel形式:
-
客户端主动发起和服务器的连接
SocketChannel socketChannel = SocketChannel.open(); socketChannel.configureBlocking(false); socketChannel.connect(new InetSocketAddress("127.0.0.1", 8080));
-
服务端获取新的连接
SocketChannel socketChannel = serverSocketChannel.accept(); // 获取新tcp连接通道
write写:write()在尚未写入任何内容时就可能返回了。需要再循环中调用write().
read读: read()方法可能直接返回而根本不读取任何数据,根据返回的int值判断读取了多少字节。
ServerSocketChannel:可以监听新建的TCP连接通道,类似ServerSocket。
serverSocketChannel.accept():如果该通道处于非阻塞模式,那么如果没有挂起的连接,该方法立即返回null。必须检查返回的SocketChannel是否为null。
Selector选择器
Selector是一个Java NIO组件,可以检查一个或多个NIO通道,并确定哪些通道已准备好进行读取或写入。实现单个线程可以管理多个通道,从而管理多个网络连接。
一个线程使用Selector监听多个channel的不同事件:
四个事件分别对应SelectionKey四个常量.
- Connect连接(SelectionKey.OP_CONNECT)
- Accept准备就绪(OP_ACCEPT)
- Read读取(OP_READ)
- Write写入(OP_WRITE)
实现一个线程处理多个通道的核心概念理解:事件驱动机制.
非阻塞的网络通道下,开发者通过Selector注册对于通道感兴趣的事件类型,线程通过监听事件来触发相应的代码执行。(拓展:更底层是操作系统的多路复用机制)
selector相关方法说明
selector.select()//阻塞 ,监控所有注册的通道,当其中有IO操作可以进行时,将对应的SelectionKey加入到内部集合中并返回,参数用来设置超时时间,如果不设置超时时间 ,注册的事件至少有一个通道发生才返回,否则一直阻塞。
selector.select(1000);//阻塞 1000 毫秒,在 1000 毫秒后返回,不管监听的通道是否有事件发生。
selector.wakeup();//唤醒 selector ,唤醒阻塞的selector
selector.selectNow();//不阻塞,立马返还;不进行阻塞立即返回
NIO对比BIO
BIO:阻塞IO,线程等待时间长,一个线程负责一个连接处理,线程多且利用率低
NIO:非阻塞IO,线程利用率更高,一个线程处理多个连接事件,性能更强大。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-FLg19wGB-1648198220578)(C:\Users\admin\AppData\Roaming\Typora\typora-user-images\image-20220313211119238.png)]
NIO 代码示例
NIO Client demo
public class NIOClient {
public static void main(String[] args) throws Exception{
SocketChannel socketChannel=SocketChannel.open();
socketChannel.configureBlocking(false);//设置为非阻塞模式
socketChannel.connect(new InetSocketAddress("127.0.0.1",8080));
while (!socketChannel.finishConnect()){
//没连接上 则一直等待
Thread.yield();
}
Scanner scanner=new Scanner(System.in);
System.out.println("请输入");
//发送内容
String msg=scanner.nextLine();
ByteBuffer buffer=ByteBuffer.wrap(msg.getBytes());
while (buffer.hasRemaining()){//表示读取完毕
socketChannel.write(buffer);
}
//读取相应
System.out.println("收到服务端响应:");
ByteBuffer reqeustBuffer=ByteBuffer.allocate(1024);
while (socketChannel.isOpen() && socketChannel.read(reqeustBuffer)!=-1){
//长链接情况下,需要手动判断数据有没有读取结束(此处做一个简单的判断:超过0字节就认为请求结束了)
if(reqeustBuffer.position()>0)break;;
}
reqeustBuffer.flip();//从头开始读取
byte[]content=new byte[reqeustBuffer.limit()];
reqeustBuffer.get(content); //读取数据到byte[]数组中
System.out.println(new String(content));
scanner.close();;
socketChannel.close();
}
}
/**
* 直接基于非阻塞的写法
*/
public class NIOServer {
public static void main(String[] args) throws Exception {
// 创建网络服务端
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
serverSocketChannel.configureBlocking(false); // 设置为非阻塞模式
serverSocketChannel.socket().bind(new InetSocketAddress(8080)); // 绑定端口
System.out.println("启动成功");
while (true) {
SocketChannel socketChannel = serverSocketChannel.accept(); // 获取新tcp连接通道
// tcp请求 读取/响应
if (socketChannel != null) {
System.out.println("收到新连接 : " + socketChannel.getRemoteAddress());
socketChannel.configureBlocking(false); // 默认是阻塞的,一定要设置为非阻塞
try {
ByteBuffer requestBuffer = ByteBuffer.allocate(1024);
while (socketChannel.isOpen() && socketChannel.read(requestBuffer) != -1) {
// 长连接情况下,需要手动判断数据有没有读取结束 (此处做一个简单的判断: 超过0字节就认为请求结束了)
if (requestBuffer.position() > 0) break;
}
if(requestBuffer.position() == 0) continue; // 如果没数据了, 则不继续后面的处理
requestBuffer.flip();
byte[] content = new byte[requestBuffer.limit()];
requestBuffer.get(content);
System.out.println(new String(content));
System.out.println("收到数据,来自:"+ socketChannel.getRemoteAddress());
// 响应结果 200
String response = "HTTP/1.1 200 OK\r\n" +
"Content-Length: 11\r\n\r\n" +
"Hello World";
ByteBuffer buffer = ByteBuffer.wrap(response.getBytes());
while (buffer.hasRemaining()) {
socketChannel.write(buffer);// 非阻塞
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
// 用到了非阻塞的API, 在设计上,和BIO可以有很大的不同.继续改进
}
}
/**
* 结合Selector实现的非阻塞服务端(放弃对channel的轮询,借助消息通知机制)
*/
public class NIOServerV2 {
public static void main(String[] args) throws Exception{
// 1. 创建网络服务端ServerSocketChannel
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
serverSocketChannel.configureBlocking(false); // 设置为非阻塞模式
// 2. 构建一个Selector选择器,并且将channel注册上去
Selector selector=Selector.open();
SelectionKey selectionKey=serverSocketChannel.register(selector,0,serverSocketChannel);//将serverSocketChannel注册到selector
selectionKey.interestOps(SelectionKey.OP_ACCEPT); // 对serverSocketChannel上面的accept事件感兴趣(serverSocketChannel只能支持accept操作)
// 3. 绑定端口
serverSocketChannel.socket().bind(new InetSocketAddress(8080));
System.out.println("启动成功");
while (true){
// 不再轮询通道,改用下面轮询事件的方式.select方法有阻塞效果,直到有事件通知才会有返回
selector.select();
//获取事件
Set<SelectionKey> selectionKeys=selector.selectedKeys();
// 遍历查询结果
Iterator<SelectionKey> iter = selectionKeys.iterator();
while (iter.hasNext()){
//被分装的查询结果
SelectionKey key=iter.next();
iter.remove();
//关注Read和Accept两个事件
if(key.isAcceptable()){
ServerSocketChannel server = (ServerSocketChannel) key.attachment();
// 将拿到的客户端连接通道,注册到selector上面
SocketChannel clientSocketChannel = server.accept(); // mainReactor 轮询accept
clientSocketChannel.configureBlocking(false);
clientSocketChannel.register(selector, SelectionKey.OP_READ, clientSocketChannel);//accept之后再次注册read事件
System.out.println("收到新连接 : " + clientSocketChannel.getRemoteAddress());
}
if(key.isReadable()){
SocketChannel socketChannel = (SocketChannel) key.attachment();
try{
ByteBuffer requestBuffer = ByteBuffer.allocate(1024);
while (socketChannel.isOpen() && socketChannel.read(requestBuffer) != -1) {
// 长连接情况下,需要手动判断数据有没有读取结束 (此处做一个简单的判断: 超过0字节就认为请求结束了)
if (requestBuffer.position() > 0) break;
}
if(requestBuffer.position() == 0) continue; // 如果没数据了, 则不继续后面的处理
requestBuffer.flip();
byte[] content = new byte[requestBuffer.limit()];
requestBuffer.get(content);
System.out.println(new String(content));
System.out.println("收到数据,来自:" + socketChannel.getRemoteAddress());
// TODO 业务操作 数据库 接口调用等等
// 响应结果 200
String response = "HTTP/1.1 200 OK\r\n" +
"Content-Length: 11\r\n\r\n" +
"Hello World";
ByteBuffer buffer = ByteBuffer.wrap(response.getBytes());
while (buffer.hasRemaining()) {
socketChannel.write(buffer);
}
}catch (IOException e) {
// e.printStackTrace();
key.cancel(); // 取消事件订阅
}
}
}
// 过掉cancelled keys
selector.selectNow();
}
// 问题: 此处一个selector监听所有事件,一个线程处理所有请求事件. 会成为瓶颈! 要有多线程的运用
}
}
NIO存在的问题
- NIO 的类库和 API 繁杂,使用麻烦:需要熟练掌握 Selector、ServerSocketChannel、SocketChannel、ByteBuffer
等。
- 需要具备其他的额外技能:要熟悉 Java 多线程编程,因为 NIO 编程涉及到 Reactor 模式,你必须对多线程
和网络编程非常熟悉,才能编写出高质量的 NIO 程序。
- 开发工作量和难度都非常大:例如客户端面临断连重连、网络闪断、半包读写、失败缓存、网络拥塞和异常流
的处理等等。
- JDK NIO 的 Bug:例如臭名昭著的 Epoll Bug,它会导致 Selector 空轮询,最终导致 CPU 100%。直到 JDK 1.7
版本该问题仍旧存在,没有被根本解决。