1、常见术语
1、Socket
Socket是应用层与TCP/IP协议族通信的中间软件抽象层,它是一组接口,其实就是一个门面模式,它把复杂的TCP/IP协议族隐藏在Socket接口后面,对用户来说,一组简单的接口就是全部,让Socket去组织数据,以符合指定的协议。
主机A的应用程序要能和主机B的应用程序通信,必须通过Socket建立连接,而建立Socket连接必须需要底层TCP/IP协议来建立TCP连接 。建立TCP连接需要底层IP协议来寻址网络中的主机。我们知道网络层使用IP协议可以帮助我们根据IP地址来找到目标主机,但是一台主机上可能运行着多个应用程序,如果才能与指定的应用程序通信就要通过TCP或UDP的地址也就是端口号来指定,这样就可以通过一个Socket实例唯一代表一个主机上的一个应用程序的通信链接了。
TCP用主机的IP地址加上主机上的端口号作为TCP连接的端点,这种端点就叫做套接字(socket)
2、短连接
连接->传输数据->关闭连接
传统的HTTP是无状态的,浏览器和服务器每进行一次HTTP操作,就建立一次连接,但任务结束就中断连接。
也就是短连接是指Socket连接后发送后接收完数据后马上断开连接。
3、长连接
连接->传输数据->保持连接 -> 传输数据-> 。。。 ->关闭连接
长连接指建立Socket连接后不管是否使用都保持连接。
什么时候用长连接,什么时候用短连接?
长连接多用于操作频繁,点对点的通讯,而且连接数不能太多的情况,每个TCP连接都需要三步握手,这需要时间,如果每个操作都是先连接,再操作的话那么处理速度会降低很多,所以每个操作完后都不断开,下次处理时直接发送数据包就OK了,不用建立TCP连接,例如:数据库的连接用的就是长连接,如果用短连接频繁的通信会造成socket错误,而且频繁的socket创建也是对资源的浪费。
而像WEB网站的http服务一般都用短连接,因为长连接对于服务端来说会耗费一定的资源,而像WEB网站这么频繁的成千上万甚至上亿客户端的连接用短接会更省一些资源。
2、网络编程里通用常识
1、InetAddress类
是java对IP地址(包括IPv4和IPv6)的高层表示。大多数其他网 络类都要用到这个类,包括 Socket、ServerSocket、URL、DatagramSocket,DatagramPacket 等。它包括一个主机名和一个 IP 地址。
2、NetworkInterface类
由于 Ne tworkInterface 对象表示物理硬件和虚拟地址网络接口,所以不能任意构造
3、原生JDK网络编程-BIO
传统的同步阻塞模型开发中,ServerSocket负责绑定IP地址,启动监听端口;Socket负责发起连接操作,连接成功后,双方通过输入和输出流进行同步阻塞式通信。
传统BIO通信模型:采用BIO通信模型的服务端,通常由一个独立的Acceptor线程负责监听客户端的连接,它接收到客户端连接请求后为每个客户端创建一个新的线程进行链路处理,处理完成后,通过输出流返回应答给客户端,线程销毁。是典型的一请求一应答模型,同时数据的读取写入也必须阻塞在一个线程内等待其完成。该模型的问题是缺乏弹性伸缩能力,当客户端并发访问量增加后,服务端的线程个数和客户端并发访问数呈1:1的正比关系,java中的线程也是比较宝贵的系统资源,线程数量快速膨胀后,系统的性能将急剧下降,随着访问量的继续增大,系统也会挂掉。
为了改进这种一连接一线程的模型,我们可以使用线程池来管理这些线程,实现1个或多个线程处理N个客户端的模型,通常被称为“伪异步I/O模型”如以下的传统方式到代码改造:
客户端代码:
package com.example.test;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.net.InetSocketAddress;
import java.net.Socket;
public class Client {
public static void main(String[] args) throws IOException {
Socket socket = null;
ObjectInputStream inputStream = null;
ObjectOutputStream outputStream = null;
InetSocketAddress addr = new InetSocketAddress("127.0.0.1",1001);
try{
socket = new Socket();
socket.connect(addr);
outputStream = new ObjectOutputStream(socket.getOutputStream());
inputStream = new ObjectInputStream(socket.getInputStream());
outputStream.writeUTF("zhangsan");
outputStream.flush();
String str = inputStream.readUTF();
System.out.println("服务端回应:"+str);
}catch (Exception e){
e.printStackTrace();
}finally {
if(socket!=null) socket.close();
if(outputStream!=null) outputStream.close();
if(inputStream!=null) inputStream.close();
}
}
}
服务端代码:
package com.example.test;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* BIO里的服务端
*/
public class Server {
//定义线程池(CPU核心数*2)
private static ExecutorService executorService =
Executors.newFixedThreadPool(
Runtime.getRuntime().availableProcessors());
public static void main(String[] args) throws IOException {
ServerSocket serverSocket = new ServerSocket();
serverSocket.bind(new InetSocketAddress(1001));
System.out.println("Server is Started ....");
while (true){
executorService.execute(new ServerTask(serverSocket.accept()));
}
}
private static class ServerTask implements Runnable{
private Socket socket = null;
public ServerTask(Socket socket){
this.socket = socket;
}
@Override
public void run(){
try(//输入流
ObjectInputStream inputStream = new ObjectInputStream(socket.getInputStream());
//输出流
ObjectOutputStream outputStream = new ObjectOutputStream(socket.getOutputStream());
){
String username = inputStream.readUTF();
System.out.println("Accept Client Message:"+username);
outputStream.writeUTF("Hello,"+username);
outputStream.flush();
}catch(Exception e){
e.printStackTrace();
}finally {
try {
socket.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
}
真正场景中,需要改为线程池来实现,如上的改造。
但是,正因为限制了线程数量,如果发生读取数据较慢时(比如数据量大,网络传输慢等),大量并发的情况下,其他接入的消息,只能一直等待,这就是最大的弊端。
BIO应用-RPC框架
RPC(Remote Procedure Call—远程过程调用):它是一种通过网络从远程计算机程序上请求服务,而不需要了解底层网络的技术。
1、一次完整的RPC同步调用流程:
-
服务消费方(client)以本地调用方式调用客户端存根
-
客户端存根就是远程方法在本地的模拟对象,一样的也有方法名,也有方法参数,client stub接收到调用后负责将方法名,方法的参数等包装,并将包装后的信息通过网络发送到服务端
-
服务端收到消息后,交给代理存根在服务器的部分后进行解码为实际的方法名和参数
-
server stub根据解码结果调用服务器上本地的实际服务
-
本地服务执行并将结果返回给server stub
-
server stub将返回结果打包成消息并发送至消费方
-
client stub接收到消息,并进行解码
-
服务消费方得到最终结果
RPC框架的目标就是要中间步骤都封装起来,让我们进行远程方法调用的时候感觉到就像在本地调用一样。
2、RPC和HTTP
rpc只是对不同应用间相互调用的一种描述,一种思想。具体怎样调用?实现方式可以是最直接的tcp通信,也可以是http方式,在很多的消息中间件里,甚至还有使用消息中间件来实现RPC调用的,dubbo是基于tcp通信的,gRPC是Google公布的开源软件,基于最新的HTTP2.0协议,底层使用到了Netty框架的支持。所以总结来说,rpc和http是完全两个不同层级的东西,他们之间并没有什么可比性。RPC是一个思想,Http是一种具体实现。
3、一个RPC框架需要解决的哪些问题?
-
代理问题(动态代理)
代理要解决的是被调用的服务本质上是远程的服务,但是调用者不知道也不关心,调用者只要结果,具体的事情由代理的那个对象来负责这件事,既然是远程代理,就得用到代理模式。
代理模式:通过代理对象访问目标对象,这样做的好处是:可以在目标对象实现的基础上,增强额外的功能操作,即扩展目标对象的功能。
jdk的代理有两种实现方式:静态代理和动态代理。
-
序列化问题(Serializable)
我们的方法调用,有方法名,方法参数,这些可能是字符串,可能是我们自己定义的java类,但是在网络上传输或者保存在硬盘的时候,网络或硬盘并不认得什么字符串或者javabean,它只认识二进制的01串,怎么办?要进行序列化,网络传输后要进行实际调用,就是把二进制的01串变回我们实际的java的类,这个叫反序列化,java里已经为我们提供了相关的机制Serializable。
- 通信问题
我们在用序列化把东西变成了可以在网络上传输的二进制01串,但具体如何通过网络传输?使用JDK为我们提供的BIO。
-
登记的服务实例化(反射)
登记的服务有可能为我们的系统中的一个名字,怎么变成实际执行的对象实例,当然是使用反射机制。
反射机制是在运行状态中,对于任意一个类,都能够知道这个类的所有属性和方法;对于任意一个对象,都能够调用它的任意一个方法和属性;这种动态获取的信息以及动态调用的对象的方法的功能称为java语言的反射机制。
反射主要提供以下功能:
(1)在运行时判断任意一个对象所属的类
(2)在运行时构造任意一个类的对象
(3)在运行时判断任意一个类所具有的成员变量和方法
(4)在运行时调用任意一个对象的方法
(5)生成动态代理
4、基于TCP的RPC实现
Dubbo:阿里巴巴公司开源的一个高性能优秀的服务框架,使得应用可通过高性能的RPC实现服务的输出和输入功能,可以和Spring框架无缝集成。
Registry:服务注册与发现的注册中心
Provider:暴露服务的服务提供方
Consumer:调用远程服务的服务消费方
Monitor:统计服务的调用次数和调用时间的监控中心
Container:服务运行容器
在Dubbo里:
服务容器负责启动,加载,运行服务提供者。
服务提供者在启动时,向注册中心注册自己提供的服务。
服务消费者在启动时,向注册中心订阅自己所需的服务。
注册中心返回服务提供者地址列表给消费者,如果有变更,注册中心将基于长连接推送变更数据给消费者。
服务消费者,从提供者地址列表中,基于软负载均衡算法,选一台提供者进行调用,如果调用失败,再选另一台调用。
服务消费者和提供者,在内存中累计调用次数和调用时间,定时每分钟发送一次统计数据到监控中心。
5、TCP(Doubbo)和HTTP(SpringCloud)哪个更好?
通用性:http协议更规范,更标准,更通用
性能:TCP协议的性能要高(比http高大概2倍左右)
服务的全面性:差不多
热度:目前来说Doubbo的热度比SpringCloud大
具体选型还是要以业务为主,如果是对外开放用SpringCloud好一点,如果是内部调用,尽可能使用Doubbo就好了。
4、原生JDK网络编程-NIO
NIO和BIO的主要区别:
BIO:面向流,阻塞线程
NIO:面向缓冲,非阻塞
NIO三大核心组件:Selector选择器,Channel管道,Buffer缓冲区
Selector
应用程序将向 Selector 对象注册需要它关注的 Channel,以及具体的某一个 Channel 会对哪些 IO 事件感兴趣。Selector 中也会维护一个“已经注册的 Channel”的容器
Channels
应用程序可以通过通道读取数据,也可以通过通道向操作系统写数据,而且可以同时进行读写。
- 所有被Selector(选择器)注册的通道,只能是继承了SelectableChannel类的子类。
- ServerSocketChannel:应用服务器程序的监听通道。只有通过这个通道,应用程序才能向操作系统注册支持“多路复用IO”的端口监听。同时支持UDP协议和TCP协议。
- ScoketChannel:TCP Socket套接字的监听通道,一个Socket套接字对应了一个客户端IP:端口到服务器IP:端口的通信连接。
通道中的数据总是要先读到一个Buffer,或者总是要从一个Buffer中写入。
buffer缓冲区
SelectionKey
什么是SelectionKey
SelectionKey是一个抽象类,表示selectableChannel在Selector中注册的标识。每个channel向Selector注册时,都将会创建一个SelectionKey。SelectionKey 将 Channel 与 Selector 建立了 关系,并维护了 channel 事件。
可以通过 cancel 方法取消键,取消的键不会立即从 selector 中移除,而是添加到 cancelledKeys 中,在下一次 select 操作时移除它.所以在调用某个 key 时,需要使用 isValid 进行 校验
SelectionKey类型和就绪条件
在向 Selector 对象注册感兴趣的事件时,JAVA NIO 共定义了四种:OP_READ、OP_WRITE、 OP_CONNECT、OP_ACCEPT(定义在 SelectionKey 中),分别对应读、写、请求连接、接受 连接等网络 Socket 操作。
服务端和客户端分别感兴趣的类型
ServerSocketChannel和SocketChannel可以注册自己感兴趣的操作类型,当对应操作类型的就绪条件满足时OS会通知channel,下表描述各种Channel允许注册的操作类型,Y表示允许注册,其中服务器SocketChannel 指由服务器ServerSocketChannel.accept()返回的对象。
服务器启动 ServerSocketChannel,关注 OP_ACCEPT 事件,
客户端启动 SocketChannel,连接服务器,关注 OP_CONNECT 事件
服务器接受连接,启动一个服务器的 SocketChannel,这个SocketChannel 可以关注
OP_READ、OP_WRITE 事件,一般连接建立后会直接关注 OP_READ 事件 客户端这边的客户端 SocketChannel 发现连接建立后,可以关注OP_READ、OP_WRITE
事件,一般是需要客户端需要发送数据了才关注 OP_READ 事件连接建立后客户端与服务器端开始相互发送消息(读写),根据实际情况来关注OP_READ、 OP_WRITE 事件。
原生JDK网络编程-Buffer
Buffer用于和NIO通道进行交互。数据是从通道读入缓冲区,从缓冲区写入到通道中的。
写:应用程序都是将数据写入缓冲,再通过通道把缓冲的数据发送出去
读:数据总是先从通道读到缓冲,应用程序再读缓冲的数据。
缓冲区本质上是一块可以写入数据,然后可以从中读取数据的内存( 其实就是数组)。 这块内存被包装成 NIO Buffer 对象,并提供了一组方法,用来方便的访问该块内存。
重要属性
capacity
作为一个内存块,Buffer 有一个固定的大小值,也叫“capacity”。你只能往里写capacity个 byte、long,char 等类型。一旦 Buffer 满了,需要将其清空(通过读数据或者清除数据)才能继续写数据往里写数据。
position
当你写数据到 Buffer 中时,position 表示当前能写的位置。初始的 position 值为 0.当一个 byte、long 等数据写到 Buffer 后, position 会向前移动到下一个可插入数据的 Buffer 单 元。position 最大可为 capacity-1.
当读取数据时,也是从某个特定位置读。当将 Buffer 从写模式切换到读模式,position会被重置为 0. 当从 Buffer 的 position 处读取数据时,position 向前移动到下一个可读的位置。
limit
在写模式下,Buffer 的 limit 表示你最多能往 Buffer 里写多少数据。 写模式下,limit 等 于 Buffer 的 capacity。
Buffer的分配
要想获得一个Buffer对象首先要进行分配,每一个Buffer类都有allocate方法(可以在堆上分配,也可以在直接内存上分配)
分配 48 字节 capacity 的 ByteBuffer 的例子:ByteBuffer buf = ByteBuffer.allocate(48);
分配一个可存储 1024 个字符的 CharBuffer:CharBuffer buf = CharBuffer.allocate(1024);
wrap方法:把一个byte数组或byte数组的一部分包装成ByteBuffer:
ByteBuffer wrap(byte [] array)
ByteBuffer wrap(byte [] array, int offset, int length)
直接内存
HeapByteBuffer 与 DirectByteBuffer,在原理上,前者可以看出分配的 buffer 是在 heap 区域的,其实真正 flush 到远程的时候会先拷贝到直接内存,再做下一步操作;在 NIO 的框 架下,很多框架会采用DirectByteBuffer 来操作,这样分配的内存不再是在 java heap 上,经 过性能测试,可以得到非常快速的网络交互,在大量的网络交互下,一般速度会比 HeapByteBuffer 要快速好几倍。
NIO 可以使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆里面的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能, 因为避免了在 Java 堆和 Native 堆中来回复制数据。
Buffer的读写
向Buffer中写数据
写数据到Buffer中有两种方式:
-
读取channel写到buffer。
-
通过buffer的put()方法写到buffer里。
从 Channel 写到 Buffer 的例子 :
int bytesRead = inChannel.read(buf); //read into buffer.
通过 put 方法写 Buffer 的例子:
buf.put(127);
flip()方法
flip 方法将 Buffer 从写模式切换到读模式。调用 flip()方法会将 position 设回 0,并将 limit 设置成之前 position 的值。
从Buffer中读取数据
从Buffer中读取数据有两种方式:
-
从Buffer读取数据写入到Channel。
-
使用get()方法从Buffer中读取数据。
从 Buffer 读取数据到 Channel 的例子:
int bytesWritten = inChannel.write(buf);
使用 get()方法从 Buffer 中读取数据的例子
byte aByte = buf.get();
使用Buffer读写数据常见步骤
1、写入数据到Buffer
2、调用flip()方法
3、从Buffer中读取数据
4、调用clear()方法或者compact()方法,准备下一次的写入
当向 buffer 写入数据时,buffer 会记录下写了多少数据。一旦要读取数据,需要通过 flip() 方法将 Buffer 从写模式切换到读模式。在读模式下,可以读取之前写入到 buffer 的所有数据。
一旦读完了所有的数据,就需要清空缓冲区,让它可以再次被写入。有两种方式能清空 缓冲区:调用 clear()或 compact()方法。clear()方法会清空整个缓冲区。compact()方法只会清 除已经读过的数据。
其他常用操作
绝对读写
put(int index, byte b) 绝对写,向 byteBuffer 底层的 bytes 中下标为 index 的位置插入byte b,不改变 position 的值。
get(int index)属于绝对读,读取 byteBuffer 底层的 bytes 中下标为 index 的 byte,不改变 position。
rewind()方法
Buffer.rewind()将 position 设回 0,所以你可以重读 Buffer 中的所有数据。limit 保持不变,仍然表示能从 Buffer 中读取多少个元素(byte、char 等)。
compact()方法将所有未读的数据拷贝到 Buffer 起始处。然后将 position 设到最后一个未 读元素正后面。limit 属性依然像 clear()方法一样,设置成 capacity。现在 Buffer 准备好写数据了,但是不会覆盖未读的数据。
Buffer方法总结
方法 | 解析 |
---|---|
limit(), limit(10等) | 其中读取和设置这 4 个属性的方法的命名和 jQuery 中的 val(),val(10)类似,一个负责get,一个负责set |
reset() | 把 position 设置成 mark 的值,相当于之前做过一个标记,现在要退回到之前标记的地方 |
clear() | position = 0;limit = capacity;mark =-1;有点初始化的味道,但是并不影响底层byte数组的内容 |
flip() | limit = position;position = 0;mark =-1;翻转,也就是让 flip 之后的 position到 limit 这块区域变成之前的 0 到 position 这块,翻转就是将一个处于存数据状态的缓冲区变为一个处于准备取数据的状态 |
rewind() | 把 position 设为 0,mark 设为-1,不改变 limit 的值 |
remaining() | return limit - position;返回 limit 和 position 之间相对位置差 |
hasRemaining () | return position < limit 返回是否还有未读内容 |
compact() | 把从 position 到 limit 中的内容移到 0 到 limit-position 的区域内,position 和 limit 的取值也分别变成 limit-position、capacity。如果先将 positon 设置到 limit,再 compact,那么相当于 clear() |
get() | 相对读,从 position 位置读取一个 byte,并将 position+1,为下次读写作准备 |
get(int index) | 绝对读,读取 byteBuffer 底层的 bytes 中下标为 index 的 byte,不改变 position |
get(byte[] dst, int offset, int length) | 从 position 位置开始相对读,读 length 个 byte,并写入 dst 下标从 offset 到 offset+length 的区域 |
put(byte b) | 相对写,向 position 的位置写入一个 byte,并将 postion+1,为下次读写作准备 |
put(int index, byte b) | 绝对写,向 byteBuffer 底层的 bytes 中下标为 index 的位置插入 byte b,不改变 position |
put(ByteBuffer src) | 用相对写,把 src 中可读的部分(也就是 position 到 limit)写入此 byteBuffer |
put(byte[] src,int offset, int length) | 从 src 数组中的 offset 到 offset+length 区域读取数据并使用相对写写入此 byteBuffer |
5、原生JDK网络编程-NIO之Reactor模式
1、Selector 对象是通过调用静态工厂方法 open()来实例化的,
Selector Selector=Selector.open();
2、要实现 Selector 管理 Channel,需要将 channel 注册到相应的 Selector 上,如下:
channel.configureBlocking(false);
SelectionKey key= channel.register(selector,SelectionKey,OP_READ);
3、在实际运行中,我们通过 Selector 的 select()方法可以选择已经准备就绪的通道(这 些通道包含你感兴趣的的事件)。
下面是 Selector 几个重载的 select()方法:
select():阻塞到至少有一个通道在你注册的事件上就绪了
select(long timeout):和 select()一样,但最长阻塞事件为 timeout 毫秒。
selectNow():非阻塞,立刻返回。select()方法返回的 int 值表示有多少通道已经就绪,是自上次调用 select()方法后有多少通道变成就绪状态。一旦调用 select()方法,并且返回值不为 0 时,则可以通过调用 Selector 的selectedKeys()方法来访问已选择键集合
Set selectedKeys=selector.selectedKeys()
这时,循环循环遍历 selectedKeys 集中的每个键,并检测各个键所对应的通道的就绪事件,再通过 SelectionKey 关联的 Selector 和 Channel 进行实际的业务处理。
注意每次迭代末尾的 keyIterator.remove()调用。Selector 不会自己从已选择键集中 移除 SelectionKey 实例。必须在处理完通道时自己移除,否则的话,下次该通道变成就绪 时,Selector 会再次将其放入已选择键集中。
单线程Reactor模式流程
单线程Reactor,工作者线程池
多Reactor线程模式
和观察者模式的区别
观察者模式:
也可以称为为 发布-订阅 模式,主要适用于多个对象依赖某一个对象的状态并,当某 对象状态发生改变时,要通知其他依赖对象做出更新。是一种一对多的关系。当然,如果依 赖的对象只有一个时,也是一种特殊的一对一关系。通常,观察者模式适用于消息事件处理,监听者监听到事件时通知事件处理者对事件进行处理(这一点上面有点像是回调,容易与反应器模式和前摄器模式的回调搞混淆)。
Reactor模式
reactor 模式,即反应器模式,是一种高效的异步 IO 模式,特征是回调,当 IO 完成时,回调对应的函数进行处理。这种模式并非是真正的异步,而是运用了异步的思想,当 IO 事件触发时,通知应用程序作出 IO 处理。模式本身并不调用系统的异步 IO 函数。
reactor 模式与观察者模式有点像。不过,观察者模式与单个事件源关联,而反应器模式则与多个事件源关联 。当一个主体发生改变时,所有依属体都得到通知