本文阐述了socket编程、IO网络模型,netty的原理以及Netty源代码的分析。
RPC架构设计
2022/03/01 校对完成
文章更新历史
2022/03/01 初稿。
socket
socket网络编程
socket概述
socket套接字是两台主机之间逻辑连接的端点。
TCP/IP协议是传输层协议,主要解决数据在网络中的传输
socket是网络通信之间的抽象接口,它包含网络通信的五种基础信息:连接使用的协议、本地主机的ip地址、本地进程协议端口、远程主机ip地址、远程进程的协议端口。
socket整体流程
socket编程主要包括客户端和服务端两个方面。
首先在服务端创建一个服务端套接字(ServerSocket),并把它附加到一个端口上,服务端从这个端口监听链接。
端口范围是 0-65536
,注意 0-1024
是特权服务保留的端口。可选择任意一个不被其他进程使用的端口。
客户端请求与服务端连接时,根据服务端的域名或者ip地址,加上端口号,打开一个套接字。当服务器接受连接后,服务器和客户端之间的操作可以像输入输出流一样操作。
代码实现
-
服务端代码
/** * 服务端 * * @name: ServerDemo * @author: terwer * @date: 2022-04-17 14:20 **/ public class ServerDemo { public static void main(String[] args) throws IOException { // 1.创建一个线程池,如果有客户端链接就创建一个线程与之通信 ExecutorService executorService = Executors.newCachedThreadPool(); // 2.创建ServerSocket ServerSocket serverSocket = new ServerSocket(9999); System.out.println("服务器已启动"); while (true) { // 3.监听客户端 Socket socket = serverSocket.accept(); System.out.println("有客户端链接"); executorService.execute(new Runnable() { @Override public void run() { handle(socket); } }); } } private static void handle(Socket socket) { try { System.out.println("线程ID:" + Thread.currentThread().getId() + ",线程名称:" + Thread.currentThread().getName()); // 从连接中取出输入流 InputStream inputStream = socket.getInputStream(); byte[] b = new byte[1024]; int read = inputStream.read(b); System.out.println("客户端" + new String(b, 0, read)); // 链接中取出输出流并回话 OutputStream outputStream = socket.getOutputStream(); outputStream.write("没有".getBytes()); } catch (Exception e) { e.printStackTrace(); } finally { try { socket.close(); } catch (IOException e) { e.printStackTrace(); } } } }
-
客户端代码
/** * 客户端 * * @name: ClientDemo * @author: terwer * @date: 2022-04-17 15:30 **/ public class ClientDemo { public static void main(String[] args) throws IOException { while (true) { // 1.创建客户端socket Socket s = new Socket("127.0.0.1", 9999); // 2.从连接中获取输出流并发送消息 OutputStream os = s.getOutputStream(); System.out.println("请输入:"); Scanner sc = new Scanner(System.in); String msg = sc.nextLine(); os.write(msg.getBytes()); // 3.从连接中取出输入流并接受会话 InputStream is = s.getInputStream(); byte[] b = new byte[1024]; // 下面写法错了 // int read = is.read(); // 应该是 int read = is.read(b); System.out.println("老板说:" + new String(b, 0, read).trim()); s.close(); } } }
IO模型
IO模型说明
-
简单理解:用什么样的通道进行数据的发送和接收。在很大程度上决定了程序通信的性能。
-
Java支持三种网络编程模型I/O模式:BIO(同步阻塞)、NIO(同步非阻塞)、AIO(异步非阻塞)
阻塞与非阻塞
指的是网络IO的线程是否处于阻塞或者等待状态
线程访问资源,该资源是否准备就绪的一种处理方式
同步和异步
指的是数据的请求方式,同步和异步是请求数据的一种方式。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-iJfy3ctZ-1651642751001)(https://cdn.jsdelivr.net/gh/terwer/upload/img/image-20220417215010935.png)]
BIO(同步阻塞)
Java BIO就是传统的socket编程
BIO(blocking IO):同步阻塞,服务器实现方式为一个链接一个线程,即客户端有一个链接时,服务端就要启动一个线程进行处理,如果这个链接不作任何事情,会造成不必要的线程开销,可以通过线程池改善,实现多个客户端链接服务器。
工作机制
BIO的问题分析
- 每个请求都要创建独立的线程,与对应的客户端进行read,业务处理,数据write
- 并发量大的时候,需要创建大量的线程来处理链接,系统资源占用较大
- 链接建立后,如果当前线程没有数据可读,线程就阻塞在read上,造成线程资源浪费
NIO(同步非阻塞)
同步非阻塞,服务器实现模式为一个线程处理多个请求(链接),客户端发送的链接请求会注册到多路复用器上,多路复用器轮训到链接有IO请求就进行处理。
AIO(异步非阻塞)
AIO引入了异步通道的概念,采用了Proactor模式,简化了程序编写,有效的请求才启动线程。
他的特点是先由操作系统完成后才通知服务端启动线程去处理,一般适用于连接数较多且连接时间较长的应用。
Proactor模式是一个消息异步通知的模式,Proactor通知的不是就绪事件,而是操作完成事件,这也是操作系统异步IO的主要模型。
https://www.zhihu.com/question/26943938
生活中的例子:
BIO、NIO、AIO的适用场景分析
- BIO(同步阻塞模式)适用于连接数比较小,且固定的架构。对服务器资源的要求比较高,并发局限于应用中,jdk1.4以前的唯一选择,代码容易理解。
- NIO(同步非阻塞模式)适用于链接数目多且链接比较短(轻操作)的架构,比如聊天服务器、弹幕系统、服务器之间的通讯等。编程比较复杂,jdk1.4开始支持。
- AIO(异步非阻塞模式)适用于连接数目比较多并且连接时间比较长(重操作)的架构,比如相册服务器,充分调用OS参与并发操作。编程比较复杂,jdk1.7开始支持。
nio编程
NIO介绍
Java NIO,全称为 java non-blocking IO
,是指JDK提供得到新API。从JDK1.4开始,Java提供了一系列改进的输入/输出的新特性,被统称为NIO(New IO),是同步非阻塞的。
-
NIO有三大核心部分,Channel(通道),Buffer(缓冲区),Selector(选择器)。
-
NIO是面向缓冲区编程。
数据读取到一个缓冲区中,需要时可以再缓冲区前后移动,增加了处理过程中的灵活性,使用它可以提供非阻塞式的高伸缩网络。
-
Java NIO的非阻塞模式,使一个线程从通道发送或者读取数据,但是它仅能得到目前可用的数据。如果目前没有数据可用时,就什么都不会获取,而不是保持线程阻塞。所以只至数据变的可用之前,该线程可以继续做其他事情。
非阻塞写也是这样,一个线程请求写入一些数据到某个通道,但是不需要等待它完全写入,这个线程可以去做别的事情。
通俗理解:NIO可以做到用一个线程来处理多个操作。
假设有10000个请求过来,根据实际情况,可以分配50或者100个线程来处理。而不是像之前阻塞IO那样,必须分配10000个线程。
NIO和BIO的比较
-
BIO以流的方式处理数据,NIO以缓冲区方式处理数据,缓冲区I/O效率比流I/O效率高很多。
-
BIO是阻塞的,NIO是非阻塞的
-
BIO基于字节流和字符流进行操作,NIO基于Channel(通道)和Buffer(缓冲区)进行操作,数据总是从通道读取到缓冲区,或者从缓冲区写入通道。
Selector(选择器)用于监听多个通道的事件(连接请求、数据到达等),因此单个线程可以监听多个客户端通道。
NIO三大核心原理示意图
NIO的Selector、Channel、Buffer的关系
-
每个Channel都会对应一个Buffer
-
Selector对应一个线程,一个线程对应多个Channel(连接)
-
每个Channel都注册到选择器上
-
Selector不断轮询查看Channel上的事件,事件是Channel(通道)的重要概念
-
Selector会根据不同的事件完成不同的操作
-
Buffer是一个内存块,底层是一个数组
-
数据的读取和写入都是通过Buffer。
跟BIO有区别,BIO中,要么是输入流,要么是输出流,不能是双向的。
NIO的Buffer可读可写,Channel是双向的。
缓冲区(Buffer)
基本介绍
缓冲区(Buffer):缓冲区本质上是一个可读可写的内存块。
可以理解成一个数组,该对象提供了一组方法,可以轻松的操作内存块。
缓冲区内置了一些机制,能够跟踪和记录缓冲区的状态变化情况。
Channel提供从网络读取数据的通道,但是读取或者写入数据都必须经过Buffer。
Buffer常用API介绍
-
Buffer类及其子类
在NIO中,Buffer是一个顶层父类,他是一个抽象类。常用的缓冲区分别对应byte,char,double,float,int,long,short供7种。
-
缓冲区对象创建
方法名 说明 static ByteBuffer allocate(长度) 创建byte类型的指定长度的缓冲区 static ByteBuffer wrap(byte[] array) 创建一个有内容的byte类型的缓冲区 示例代码
/** * 创建缓冲区 * * @name: CreateBufferDemo * @author: terwer * @date: 2022-04-18 17:38 **/ public class CreateBufferDemo { public static void main(String[] args) { // 1.创建一个指定长度的缓冲区,ByteBuffer为例 ByteBuffer byteBuffer = ByteBuffer.allocate(4); for (int i = 0; i < 4; i++) { System.out.println(byteBuffer.get()); } // 在此调用会报错 // System.out.println(byteBuffer.get()); System.out.println("=================="); System.out.println(); // 2.创建一个有内容的缓冲区 ByteBuffer wrap = ByteBuffer.wrap("test".getBytes(StandardCharsets.UTF_8)); for (int i = 0; i < 4; i++) { System.out.println(wrap.get()); } } }
-
缓冲区对象添加数据
方法名 说明 Int position()/position(int newPosition) 获取当前要操作的索引/修改当前要操作的索引 int lkimit()/limit(int newLimit) 最多能操作到哪个索引/修改最多能操作的索引位置 int capacity() 返回缓冲区的总长度 int remaining()/boolean hasRemaining() 还有多少能操作的索引个数/是否还能操作 put (byte b)/put(byte[] src) 添加一个字节/添加字节数组 示例代码:
/** * 添加缓冲区 * * @name: PutBufferDemo * @author: terwer * @date: 2022-04-18 19:27 **/ public class PutBufferDemo { public static void main(String[] args) { // 1.创建一个指定长度的缓冲区 ByteBuffer byteBuffer = ByteBuffer.allocate(10); System.out.println(byteBuffer.position());// 获取当前索引所在的位置 System.out.println(byteBuffer.limit());// 最多能操作到哪个索引 System.out.println(byteBuffer.capacity());// 返回缓冲区总长度 System.out.println(byteBuffer.remaining());// 还有多少个能操作 // byteBuffer.position(2); // byteBuffer.limit(4); // System.out.println(); // System.out.println("============"); // System.out.println(byteBuffer.position());// 获取当前索引所在的位置 // System.out.println(byteBuffer.limit());// 最多能操作到哪个索引 // System.out.println(byteBuffer.capacity());// 返回缓冲区总长度 // System.out.println(byteBuffer.remaining());// 还有多少个能操作 // 添加一个字节 byteBuffer.put((byte) 97); System.out.println(); System.out.println("============"); System.out.println(byteBuffer.position());// 获取当前索引所在的位置 System.out.println(byteBuffer.limit());// 最多能操作到哪个索引 System.out.println(byteBuffer.capacity());// 返回缓冲区总长度 System.out.println(byteBuffer.remaining());// 还有多少个能操作 // 添加一个字节数组 byteBuffer.put("test".getBytes(StandardCharsets.UTF_8)); System.out.println(); System.out.println("============"); System.out.println(byteBuffer.position());// 获取当前索引所在的位置 System.out.println(byteBuffer.limit());// 最多能操作到哪个索引 System.out.println(byteBuffer.capacity());// 返回缓冲区总长度 System.out.println(byteBuffer.remaining());// 还有多少个能操作 // 超过缓冲区长度会报错 // byteBuffer.put("1234567".getBytes(StandardCharsets.UTF_8)); // System.out.println(); // System.out.println("============"); // System.out.println(byteBuffer.position());// 获取当前索引所在的位置 // System.out.println(byteBuffer.limit());// 最多能操作到哪个索引 // System.out.println(byteBuffer.capacity());// 返回缓冲区总长度 // System.out.println(byteBuffer.remaining());// 还有多少个能操作 // 如果缓冲区满了,可以调整position的位置,会覆盖之前对应索引的值 byteBuffer.position(0); byteBuffer.put("1234567".getBytes(StandardCharsets.UTF_8)); System.out.println(); System.out.println("============"); System.out.println(byteBuffer.position());// 获取当前索引所在的位置 System.out.println(byteBuffer.limit());// 最多能操作到哪个索引 System.out.println(byteBuffer.capacity());// 返回缓冲区总长度 System.out.println(byteBuffer.remaining());// 还有多少个能操作 } }
-
缓冲区对象读取数据
方法名 介绍 flip() 切换读模式,limit设置position位置,position设置0 get() 读一个字节 get(byte[] dst) 读多个字节 get(int index) 读指定索引的字节 rewind() 将position设置为0,可重复读 clear() 切换写模式,position设置为0,limit设置为capacity array() 将缓冲区转换成字节数组返回 flip方法
clear方法:
示例代码:
/** * 从缓冲区读取数据 * * @name: GetBufferDemo * @author: terwer * @date: 2022-04-18 19:51 **/ public class GetBufferDemo { public static void main(String[] args) { // 1.创建一个指定长度的缓冲区 ByteBuffer byteBuffer = ByteBuffer.allocate(10); byteBuffer.put("0123".getBytes(StandardCharsets.UTF_8)); System.out.println("position:" + byteBuffer.position()); System.out.println("limit:" + byteBuffer.limit()); System.out.println("capacity:" + byteBuffer.capacity()); System.out.println("remaining:" + byteBuffer.remaining()); // 切换读模式 System.out.println(); System.out.println("================="); System.out.println("准备读数据:"); byteBuffer.flip(); System.out.println("position:" + byteBuffer.position()); System.out.println("limit:" + byteBuffer.limit()); System.out.println("capacity:" + byteBuffer.capacity()); System.out.println("remaining:" + byteBuffer.remaining()); for (int i = 0; i < byteBuffer.limit(); i++) { System.out.println(byteBuffer.get()); } // 读取完毕后,继续读取会报错,超过limit // System.out.println(byteBuffer.get()); // 读取指定字节 // System.out.println("读取指定索引:"); // System.out.println(byteBuffer.get(2)); System.out.println("读取多个字节:"); // 重复读取 byteBuffer.rewind(); byte[] dst = new byte[4]; byteBuffer.get(dst); System.out.println(new String(dst)); // 将缓冲区转化为字节数组返回 System.out.println(); System.out.println("==========="); System.out.println("将缓冲区转化为字节数组:"); byte[] array = byteBuffer.array(); System.out.println(new String(array)); // 切换写模式,会覆盖之前所有的值 System.out.println(); System.out.println("================"); System.out.println("切换写模式,覆盖之前的值:"); byteBuffer.clear(); byteBuffer.put("test".getBytes(StandardCharsets.UTF_8)); System.out.println(new String(byteBuffer.array())); } }
注意:
- capacity:容量(长度) limit:界限(最多能读/写到哪里) position:位置(读/写哪个索引)
- 获取缓冲区的数据之前,要先调用flip()方法,重复读需要调用rewind()方法
- 再次写数据之前,需要先调用clear()方法,此时数据还未消失。再次写入数据完成,数据覆盖了才会消失。
通道(Channel)
基本介绍
NIO中所有的IO都是从通道(Channel)开始的。NIO的通道类似于流,但是有区别:
-
通道可读可写,流一般是单向的(只能读或者写,所以之前socket的demo里面分别创建一个输入流和输出流)。
-
通道可以异步读写。
-
通道总是基于缓冲区Buffer来读写
Channel的常用类介绍
-
Channel接口
常用的Channel实现类有:FileChannel、DatagramChannel、ServerSocketChannel和SocketChannel
FileChannel用于文件的数据读写,DatagramChannel用于UDP数据的读写,ServerSocketChannel和SocketChannel用于TCP数据的读写。
ServerSocketChannel类似于ServerSocket,SocketChannel类似于Socket。
-
SocketChannel和ServerSocketChannel
类似于Socket和ServerSocket,可用于客户端与服务器的通信。
ServerSocketChannel
服务端实现步骤:
- 打开一个服务端通道
- 绑定对应的端口号
- 通道默认是阻塞的,需要设置为非阻塞
- 检查是否有客户端连接,有客户端连接会返回对应的通道
- 获取客户端传递过来的数据,并把数据放在byteBuffer这个缓冲区中
- 给客户端回写数据
- 释放资源
/**
* 服务端
*
* @name: NIOServer
* @author: terwer
* @date: 2022-04-18 21:59
**/
public class NIOServer {
public static void main(String[] args) throws IOException, InterruptedException {
// 1. 打开一个服务端通道
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
// 2. 绑定对应的端口号
serverSocketChannel.bind(new InetSocketAddress(9999));
// 3. 通道默认是阻塞的,需要设置为非阻塞
// true为阻塞,false为非阻塞
serverSocketChannel.configureBlocking(false);
System.out.println("服务端启动成功=========");
while (true) {
// 4. 检查是否有客户端连接,有客户端连接会返回对应的通道
SocketChannel socketChannel = serverSocketChannel.accept();
if (socketChannel == null) {
System.out.println("没有客户端连接,做别的事情");
Thread.sleep(2000);
continue;
}
// 5. 获取客户端传递过来的数据,并把数据放在byteBuffer这个缓冲区中
ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
// 正数,读到的字节
// 0,没有读到数据
// -1,读到了文件末尾
int read = socketChannel.read(byteBuffer);
System.out.println("客户端发来的消息:" + new String(byteBuffer.array(), 0, read));
// 6. 给客户端回写数据
socketChannel.write(ByteBuffer.wrap("你好,我是服务端".getBytes(StandardCharsets.UTF_8)));
// 7. 释放资源
socketChannel.close();
}
}
}
SocketChannel
客户端实现步骤:
- 打开通道
- 设置连接IP和端口号
- 写出数据
- 读取服务器写回的数据
/**
* 客户端
*
* @name: NIOClient
* @author: terwer
* @date: 2022-04-18 22:11
**/
public class NIOClient {
public static void main(String[] args) throws IOException {
// 1. 打开通道
SocketChannel socketChannel = SocketChannel.open();
// 2. 设置连接IP和端口号
socketChannel.connect(new InetSocketAddress("127.0.0.1", 9999));
// 3. 写出数据
socketChannel.write(ByteBuffer.wrap("你好,我是客户端".getBytes(StandardCharsets.UTF_8)));
// 4. 读取服务器写回的数据
ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
int read = socketChannel.read(byteBuffer);
System.out.println("服务端回话:" + new String(byteBuffer.array(), 0, read));
// 5.释放资源
socketChannel.close();
}
}
选择器(Selector)
基本介绍
用一个线程,处理多个客户端连接,就会用到NIO的Selector(选择器)。
Selector能够检测多个注册的服务端通道上是否有事件发生。如果有事件发生,便获取事件,然后针对每个事件进行响应的处理。
这样可以用单线程去管理多个通道,也就是管理多个连接和请求。
在没有选择器的情况下,每个连接对应一个请求,但是连接不能马上发送消息,所以会产生资源浪费。
有了选择器之后,只有在通道真正有读写事件发生时,才会进行读写。这样大大减小了系统开销,不必为每个连接创建一个线程,不用去维护多个线程,避免了多线程上下文切换导致的开销。
常用API介绍
-
Selector是一个抽象类
常用方法:
Selector.open();// 得到一个选择器对象
Selector.select();// 阻塞,监控所有注册的通道,当有对应的事件时,会将SelectionKey放入集合内部并返回事件数量
Selector.select(1000);// 阻塞1000毫秒,监控所有注册的通道,当有对应的事件时,会将SelectionKey放入集合内部并返回
Selector.selectedKeys;// 返回存有SelectionKey的集合
-
SelectionKey
- 常用方法
- SelectionKey.isAcceptable();// 是否是连接继续事件
- SelectionKey.isConnectable();// 是否是连接就绪事件
- SelectionKey.isReadable();// 是否是读就绪事件
- SelectionKey.isWritable();// 是否是写就绪事件
- SelectionKey中定义的4种事件
- SelectionKey.OP_ACCEPT;// 接收连接继续事件,表示服务器监听到了客户端连接,服务器可以接受这个连接了
- SelectionKey.OP_CONNECT;// 连接就绪事件,表示客户端与服务器连接已经建立成功
- SelectionKey.OP_READ;// 读就绪事件,表示通道中已经有了可以读取的数据,可以执行读操作
- SelectionKey.OP_WRITE;// 写就绪事件,表示可以向通道写数据了
- 常用方法
Selector编码
服务端
-
实现步骤
- 打开一个服务端通道
- 绑定对应的端口号
- 通道默认是阻塞的,需要设置为非阻塞
- 创建选择器
- 将服务端通道注册到选择器上,并指定注册监听的事件为OP_ACCEPT
- 检查选择器是否有事件
- 获取事件集合
- 判断事件是否是客户端连接事件SelectionKey.isAcceptable()
- 得到客户端通道,并将通道注册到选择器上, 并指定监听事件为OP_READ
- 判断是否是客户端读就绪事件SelectionKey.isReadable() 11. 得到客户端通道,读取数据到缓冲区
- 给客户端回写数据
- 从集合中删除对应的事件, 因为防止二次处理.
-
代码实现
/** * 基于选择器实现服务端 * * @name: NIOSelectorServer * @author: terwer * @date: 2022-04-18 23:07 **/ public class NIOSelectorServer { public static void main(String[] args) throws IOException { // 1. 打开一个服务端通道 ServerSocketChannel serverSocketChannel = ServerSocketChannel.open(); // 2. 绑定对应的端口号 serverSocketChannel.bind(new InetSocketAddress(9999)); // 3. 通道默认是阻塞的,需要设置为非阻塞 serverSocketChannel.configureBlocking(false); // 4. 创建选择器 Selector selector = Selector.open(); // 5. 将服务端通道注册到选择器上,并指定注册监听的事件为OP_ACCEPT serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT); System.out.println("服务端已启动"); while (true) { // 6. 检查选择器是否有事件 int select = selector.select(2000); if (select == 0) { continue; } // 7. 获取事件集合 Set<SelectionKey> selectionKeys = selector.selectedKeys(); Iterator<SelectionKey> iterator = selectionKeys.iterator(); while (iterator.hasNext()) { // 8. 判断事件是否是客户端连接事件SelectionKey.isAcceptable() SelectionKey key = iterator.next(); // 9. 得到客户端通道,并将通道注册到选择器上, 并指定监听事件为OP_READ if (key.isAcceptable()) { SocketChannel socketChannel = serverSocketChannel.accept(); System.out.println("客户端已链接:" + socketChannel); // 设置为非阻塞 socketChannel.configureBlocking(false); socketChannel.register(selector, SelectionKey.OP_READ); } // 10. 判断是否是客户端读就绪事件SelectionKey.isReadable() if (key.isReadable()) { // 11. 得到客户端通道,读取数据到缓冲区 SocketChannel channel = (SocketChannel) key.channel(); ByteBuffer byteBuffer = ByteBuffer.allocate(1024); int read = channel.read(byteBuffer); if (read > 0) { System.out.println("获取到的客户端消息:" + new String(byteBuffer.array(), 0, read)); // 12. 给客户端回写数据 channel.write(ByteBuffer.wrap("给客户端的回复".getBytes(StandardCharsets.UTF_8))); channel.close(); } } // 13. 从集合中删除对应的事件, 因为防止二次处理. iterator.remove(); } } } }
客户端
同NIOClient。