一、TCP/IP四层模型和OSI七层模型
我们主要关注TCP/IP四层模型,OSI七层模型了解即可
TCP/IP并不是简单指TCP协议和IP协议,而是全称
Transmission Control Protocol/Internet Protocol,中文名是:传输控制协议/因特网互联协议,是指利用IP进行通信时所必须用到的协议群的统称。
二、TCP的基本特性
1.面向连接
TCP通过三次握手建立端对端的连接之后,才能进行通讯,当通讯完成后需要断掉连接。
(1)TCP建立连接时要进行三次握手
为什么要进行,三次握手?
为了保证能够正常通讯,逻辑上是应该进行四次握手的,即:
A向B发送同步请求,B回应A收到请求,这表示A传递信息给B是没有问题的。
然后B给A发送同步请求,A回应收到请求,这表示B传递信息给A是没有问题的。
这逻辑上的四次握手,就能保证A和B能够互相通信。
那为什么最后变成了三次握手呢?
为了建立连接,B首先收到A的同步请求后一定会回应A的同步信号并且给A发送同步请求,这就有一个逻辑连贯,所以把B回应A的同步信号和给A发送同步请求合并成一次握手,这就变成了三次握手。
因为需要通过三次握手成功后才能建立连接,所以黑客可以利用这个三次握手的机制来攻击服务端,我们把这种方式成为洪泛攻击。
通过发送大量伪造原IP地址的报文,发送到服务端 ,这样服务端要回应这个伪造的IP地址,因为是伪造的IP地址,肯定完成不了三次握手,这种半开连接就会占用计算机资源,如果大量的出现这种情况,就会导致真实的报文得不到回应。
如何解决洪泛攻击?
无效的连接监控释放、防火墙
(2).TCP断开连接的时候需要进行四次挥手
为什么握手是三次,挥手却要四次呢?
之前我们分析过,建立连接其实需要的是逻辑上的4次握手,只有通过这四次才能确保双方互相通信的通畅,同理,断开连接的时候也需要双方都向对方提出断开的申请,并且双方也需要回复,所以也是4次。
握手三次是因为,在逻辑上的四次中,第二次和第三次握手的逻辑是连贯的,所以可以合并成一次。
因为TCP连接是端对端全双工的,即两端都可以进行发信息和收信息,当想断开连接的时候,需要双方都向对方提出断开的申请,并且双方也需要回复,所以是4次挥手
为什么挥手的第二次和第三次握手不能合并?
因为不是一方申请断开连接时,另一方也想立刻断开连接,所以会出现第二次和第三次有个时间差,这个时间差导致不能合并,所以挥手在理论上是四次,但是如果我们抓包看的话,会出现挥手三次的情况,因为大多数情况下,第二次和第三次是同时的,在实际中就给合并了,这就是理论和现实的差距,理论和刻板,现实很灵活。
为什么需要TIME_WAITING状态?
MSL是最长报文段寿命,即一个IP报文数据在网络上存活的最长时间,在RFC的文档中定义为2分钟,超过两分钟就会被网络所丢弃,现实中没有这么长的时间,一般是30秒。
1.当服务端发送断开连接请求时,有可能丢包,丢包需要服务端重传,可能出现要客户端等待的情况,所以不能立刻从FIN_WAIT_2状态到CLOSED状态,需要这个TIME_WAITING状态
2.当连接处于TIME_WAITING状态的时候是不允许释放端口的,当服务端还有数据想传过来,有TIME_WAITING的等待,不会出现数据传输到下一个获取这个端口的错误。
2.可靠性
(1).应答确认
TCP将每个数据包都进行了编号,这就是序列号。
序列号的作用:
a、保证可靠性(当接收到的数据总少了某个序号的数据时,能马上知道)
b、保证数据的按序到达
c、提高效率,可实现多次发送,一次确认
d、去除重复数据
数据传输过程中的确认应答处理、重发控制以及重复控制等功能都可以通过序列号来实现
TCP通过确认应答机制实现可靠的数据传输。在TCP的首部中有一个标志位——ACK,此标志位表示确认号是否有效。接收方对于按序到达的数据会进行确认,当标志位ACK=1时确认首部的确认字段有效。进行确认时,确认字段值表示这个值之前的数据都已经按序到达了。而发送方如果收到了已发送的数据的确认报文,则继续传输下一部分数据;而如果等待了一定时间还没有收到确认报文就会启动重传机制。每一个报文都需要对端做确认应答
(2).超时重传
当报文发出后在一定的时间内未收到接收方的确认,发送方就会进行重传(通常是在发出报文段后设定一个闹钟,到点了还没有收到应答则进行重传)。
一种情况是发送包丢失了
这种情况很简单,发送包丢失时,如果一段时间没有收到确认包,则重新发送这个包。
另一种情况是回应包ACK丢失了
当回应包ACK丢失时,因为一段时间没有收到确认包ACK,,则重新发送这个包,因为是ACK丢失,表明接收端已经接收了那个包,这次接收端接收到重复的包就会丢弃掉,并且重发回应的包。
每一个报文都有其序列号,超时重传和应答确认都是靠序列号来控制的
重传时间的确定:
重传时间的确定:报文段发出到收到应答中间有一个报文段的往返时间RTT,显然超时重传时间RTO会略大于这个RTT,TCP会根据网络情况动态的计算RTT,即RTO是不断变化的。在Linux中,超时以500ms为单位进行控制,每次判定超时重发的超时时间都是500ms的整数倍。其规律为:如果重发一次仍得不到应答,就等待2500ms后再进行重传,如果仍然得不到应答就等待4500ms后重传,依次类推,以指数形式递增,重传次数累计到一定次数后,TCP认为网络或对端主机出现异常,就会强行关闭连接。
3.数据排序
一段报文可能分包发送,所以需要序列号,因为每个数据包都有自己的序列号,这样就可以给数据进行排序
4.流量控制
接收端处理数据的速度是有限的,如果发送方发送数据的速度过快,导致接收端的缓冲区满,而发送方继续发送,就会造成丢包,继而引起丢包重传等一系列连锁反应。
因此TCP支持根据接收端的处理能力,来决定发送端的发送速度,这个机制叫做流量控制。tcp通过滑动窗口来进行流量控制
如果主机A是我们的发送方,他在发送数据的时候需要维护一个窗口,接受窗口就是告诉发送方,接收方还有多少的缓存空间,tcp是全双工通信,所以说通信的任何一方都维护着另一方的一个接收窗口
接受窗口(表示表示接收方当前可用的缓存空间)
接受窗口表示接收方还能有多大的缓存空间
因为这些数据是会动态变化的,我们这个接受窗口也是动态变化的,所以我们称这个窗口为滑动窗口那发送方是怎么拿到接收端的滑动窗口大小的数据呢?
tcp是全双工通信,这个时候我们的接收方可以再发送一个报文告诉发送方,在tcp报头中,有一个位置是关于窗口大小的
5.全双工
TCP的两端都可以进行收发报文。
6.拥塞控制
为了避免由于网络拥堵而采取的对发送方发送速率的限制称为拥塞控制
TCP 通过拥塞窗口实现拥塞避免。
每个 TCP 连接的发送方都会去感知网络的拥塞程度,然后去进行拥塞控制,那么 TCP 的发送方是如何感知到当前这个网络存在着拥塞呢,丢包,只要出现了超时就极有可能出现了网络拥堵。那 假设现在出现了网络拥塞,那么 TCP 如何限制传输的速率呢?TCP 的每一端除了维护进行流量控制的滑动窗口外,还会维护一个拥塞窗口(cwnd),拥塞窗口就是对 TCP 发送方的发送速率进行限制的,具体来说的就是 TCP 会控制发送方发送到连接中的但是还没有被接收方确认的数据量。
那我们改变拥塞窗口的大小,就可以调节发送方发送数据的速率。那么发送的速率是多少呢?首先用 rtt 表示一次通信往返所用的时间,那么速率就等于 cwnd/rtt。一般来说 rtt 都是比较固定的,所以调整 cwnd 的值就可以调整发送速率。那我们应该如何确定当前发送速率是多少才合适呢?那就要依靠 TCP 的拥塞控制算法了。拥塞控制算法的实质就是如何来调整 cwnd 的大小。一说到大小就得有衡量单位,那窗口的衡量单位是 mss 最大的传输单元。那我们知道 TCP 分为 TCP 首部和数据两部分,mss 就是去控制数据字段的大小的,那这个值呢是需要通讯双方进行约定的。比如说一般的值呢是一千四百六十个字节。首先发送方发送数据的原则是什么呢,只要网络不堵,我就尽可能多的去发,网络赌的话,我就减小这个窗口的大小。
TCP拥塞控制流程:
TCP拥塞算法共有4种:慢开始、拥塞避免、快重传、快恢复
慢开始
慢开始发生在最开始,这个时候不断试探网络是否把握的住,拥塞窗口(cwnd)以n²的增速开始增长,然后到了设置的阈值,我们就采取拥塞避免的算法每次加一的试探网络,当然后面肯定避免不了超时,我们这时候阈值设为当前阈值的一半,我们重新开始慢开始,到达现在的阈值开始拥塞避免。
拥塞避免
每个传输轮次,拥塞窗口cwnd只能线性加一,而不是像慢开始算法时,每个传输轮次,拥塞窗口cwnd按指数增长。
快速重传
接收器接收到一个不按顺序的数据段,接收器会立马给发送器发送重复确认,如果发送机接收到三个重复确认,它会假定确认件支出的数据段丢失了。这样就不会造成发送方对后面报文出发超时重传,而是提早收到了重传。然后开始快恢复算法。
快恢复算法
发送方一旦收到3个重复确认,就知道现在只是丢失了个别的报文段。于是不启动慢开始算法,而执行快恢复算法;
1.发送方将慢开始门限ssthresh值和拥塞窗口cwnd值调整为当前窗口的一半;开始执行拥塞避免算法。
2.也有的快恢复实现是把快恢复开始时的拥塞窗口cwnd值再增大一些,即等于新的ssthresh + 3。
既然发送方收到3个重复的确认,就表明有3个数据报文段已经离开了网络;
这3个报文段不再消耗网络资源而是停留在接收方的接收缓存中;
可见现在网络中不是堆积了报文段而是减少了3个报文段。因此可以适当把拥塞窗口犷大些。
7.TCP报文格式
8.TCP沾包/分包的处理
沾包和分包的几种情况:
- 正常的理想情况,两个包恰好满足TCP缓冲区的大小或达到TCP等待时长,分别发送两个包;
- 粘包:两个包较小,间隔时间短,发生粘包,合并成一个包发送;
- 拆包:一个包过大,超过缓存区大小,拆分成两个或多个包发送;
- 拆包和粘包:Packet1过大,进行了拆包处理,而拆出去的一部分又与Packet2进行粘包处理。
常见解决方案:
- 发送端将每个包都封装成固定的长度,比如100字节大小。如果不足100字节可通过补0或空等进行填充到指定长度;
- 发送端在每个包的末尾使用固定的分隔符,例如\r\n。如果发生拆包需等待多个包发送过来之后再找到其中的\r\n进行合并;例如,FTP协议;
- 将消息分为头部和消息体,头部中保存整个消息的长度,只有读取到足够长度的消息之后才算是读到了一个完整的消息;
- 通过自定义协议进行粘包和拆包的处理。
首先粘包产生原因:
先说TCP:由于TCP协议本身的机制(面向连接可靠的协议,三次握手四次挥手)客户段与服务端会建立一个链接,数据在链接不断开的情况下,可以持续不断地将多个数据包发往服务端,相当于一个流,但是如果发送的网络数据包太小,那么他本身会启用Nagle算法(当然是可配置是否启用)对较小的数据包进行合并(基于此,TCP的网络延迟要UDP的高些,因为需要合并延时发送)然后再发送(超时或者包大小足够)。这样的话,服务端在接收到消息(数据流)的时候就无法区分哪些数据包是客户端自己分开发送的,这样产生了粘包;还有一种情况,服务端在接收到数据后,然后放到缓冲区中,如果消息没有被及时从缓存区取走,下次在取数据的时候可能就会出现一次取出多个数据包的情况,造成粘包现象(确切来讲,对于基于TCP协议的应用,不应用包来描述,而应该用流的概念来描述),个人认为接收端产生的粘包应该与linux内核处理socket的方式 select/poll轮询机制的线性扫描频度或者跟epoll无关。
再说UDP:本身作为无连接的不可靠的传输协议(适合频繁发送较小的数据包),他不会对数据包进行合并发送(也就没有Nagle算法之说了),他直接是一端发送什么数据,直接就发出去了,既然他不会对数据合并,每一个数据包都是完整的(数据+UDP头+IP头等等发一次数据封装一次)也就没有粘包一说了。
分包产生的原因就简单的多:可能是IP分片传输导致的,也可能是传输过程中丢失部分包导致出现的半包,还有可能就是一个包可能被分成了两次传输,在取数据的时候,先取到了一部分(还可能与接收的缓冲区大小有关系),总之就是一个数据包被分成了多次接收。
解决办法:
粘包与分包的处理方法:
我根据现有的一些开源资料做了如下总结(常用的解决方案):
一个是采用分隔符的方式,即我们在封装要发送的数据包的时候,采用固定的字符作为结尾符(数据中不能含结尾符),这样我们接收到数据包后,如果出现结尾标识,即人为的将粘包分开,如果一个包中没有出现结尾符,认为出现了分包,则等待下个包中出现后 组合成一个完整的数据包,这种方式适合于文本传输的数据,如采用/r/n之类的分隔符;
另一种是采用在数据包中添加长度的方式,即在数据包中的固定位置封装数据包的长度信息(或可计算数据包总长度的信息),服务器接收到数据后,先是解析包长度,然后根据包长度截取数据包(此种方式常出现于自定义协议中),但是有个小问题就是如果客户端第一个数据包数据长度封装的有错误,那么很可能就会导致后面接收到的所有数据包都解析出错(由于TCP建立连接后流式传输机制),只有客户端关闭连接后重新打开才可以消除此问题,我在处理这个问题的时候对数据长度做了校验,会适时的对接收到的有问题的包进行人为的丢弃处理(客户端有自动重发机制,故而在应用层不会导致数据的不完整性);
另一种不建议的方式是TCP采用短连接处理粘包(这个得根据需要来,所以不建议);
三、网络编程
1.网络通信常识
(1).编程中的socket是什么
Socket是应用层与TCP/IP协议族通信的中间软件抽象层,它是一组接口,其实就是一个门面模式。TCP用主机的IP加端口号作为连接的端点,这种端点就叫做套接字(socket)
Socket就是对应用层下面的传输层、网络层、链路层关于网络通信的抽象,作为这三层关于网络通信的门面,为应用层提供网络通信。
(2).服务端、客户端、通信编程关注的三件事
1.连接(客户端、服务端)
2.读网络数据
3.写网络数据
2.Java网络编程之BIO、NIO
(1).原生JDK网络编程之B(Blocking)IO
采用BIO通信模型的服务器,通常由一个独立的Acceptor线程负责监听客户端的连接(即使对于这个连接来说服务端暂时没有工作,也是需要阻塞式的等待),它接收到客户端连接请求之后为每个客户端创建一个新的线程进行链路处理,处理完成之后,通过输出流返回应答给客户端,线程销毁。
该模型最大的问题缺乏弹性伸缩能力,当客户端并发量增加后,服务端的线程个数和客户端并发访问数一致,线程对于JVM是宝贵的系统资源,当线程数膨胀之后,系统的性能将急剧下降,随着并发访问量继续增大,系统会发生线程堆栈溢出、创建新线程失败等问题。
为了改进一个线程一个连接模型,后来演进了一种通过线程池或消息队列实现一个或者多个线程处理N个客户端的模型,但由于底层依然使用同步阻塞I/O,所以被称为“伪异步”
为了解决同步阻塞I/O面临的一个链路需要一个线程处理的问题,对其模型做了优化:通过一个线程池来处理多个客户端的请求接入,线程池的最大线程数可以远大于客户端数。通过线程池来灵活调配线程资源。
将客户端的Socket封装成为一个Task(实现Runnable接口)提交到线程池进行处理。通过设置线程池的max Thread、阻塞队列来调控
异步任务执行逻辑代码
public class TimeServerHandler implements Runnable{
private Socket socket;
public TimeServerHandler(Socket socket) {
this.socket = socket;
}
@Override
public void run() {
BufferedReader br=null;
PrintWriter out=null;
try{
br=new BufferedReader(new InputStreamReader(this.socket.getInputStream()));
out=new PrintWriter(socket.getOutputStream(),true);
String currentTime=null;
String body=null;
for(;;){
body=br.readLine();
if(body==null){
break;
}
System.out.println("The time server receive order:"+body);
currentTime="QUERY TIME ORDER".equalsIgnoreCase(body)?new Date(System.currentTimeMillis())
.toString():"BAD ORDER";
out.println(currentTime);
}
} catch (Exception e) {
if(br!=null){
try {
br.close();
} catch (IOException ex) {
throw new RuntimeException(ex);
}
}
if(out!=null){
out.close();
out=null;
}
if(this.socket!=null){
try {
this.socket.close();
} catch (IOException ex) {
throw new RuntimeException(ex);
}
this.socket=null;
}
}
}
}
客户端
public class TimeClient {
public static void main(String[] args) throws IOException {
Socket socket=new Socket("127.0.0.1",8080);
// BufferedWriter bw=new BufferedWriter(new OutputStreamWriter(socket.getOutputStream()));
// bw.write("QUERY TIME ORDER");
// bw.newLine();
// bw.flush();
PrintWriter pw=new PrintWriter(socket.getOutputStream(),true);
pw.println("QUERY TIME ORDER");
BufferedReader br=new BufferedReader(new InputStreamReader(socket.getInputStream()));
String s = br.readLine();
System.out.println("Now is :"+s);
}
}
服务端接收连接,启动线程驱动
public class TimeServer {
public static void main(String[] args) {
int port=8080;
ServerSocket server=null;
try{
server=new ServerSocket(port);
Socket socket=null;
//创建I/O任务线程池
TimeServerHandlerExecutePool singleExecutor=new TimeServerHandlerExecutePool(50,10000);
for(;;){
socket=server.accept();
singleExecutor.execute(new TimeServerHandler(socket));
}
}catch (IOException e){
e.printStackTrace();
}
}
}
ServerSocket负责绑定IP地址,启动监听端口;Socket负责发起连接操作。连接成功后,双方通过输入和输出流进行同步阻塞式通信。
线程池
public class TimeServerHandlerExecutePool {
private ExecutorService executor;
public TimeServerHandlerExecutePool(int maxPoolSize,int queueSize){
executor=new ThreadPoolExecutor(Runtime.getRuntime()
.availableProcessors(),maxPoolSize,120L, TimeUnit.SECONDS,
new ArrayBlockingQueue<Runnable>(queueSize));
}
public void execute(Runnable task){
executor.execute(task);
}
}
- Socket的输入流进行读取操作时,会一直阻塞,直到:
- 有数据可读
- 可用数据读取完毕
- 发生控制着或I/O异常
- 调用OutputStream的write方法写输出流时,也会被阻塞,直到所有要发送的字节全部写入完毕,或者抛出异常
(2).java原生网络编程之NIO
1.BIO的阻塞和NIO的非阻塞的实质
BIO是面向流的,数据是要等待流传过来的,在调用read和write方法后,它得一直关注这个流,如果现在连接没有在传数据,它就得阻塞起来(所以BIO是同步阻塞的),直到这个流有数据,它就开始工作,或者数据读取完毕流关闭了,这个连接也就结束了。这样一个线程在开启读取或写入流之后,只能给一个连接使用,造成了线程的浪费。
NIO是面向缓冲区的,它读或者写时只用关心当前缓冲区的数据,如果调用了读或者写方法后,这时缓冲区没有数据,就不用阻塞起来,干别的事,当有数据来的时候,会通知线程,有数据了,这时候再去缓冲区拿,这样就可以一个线程,处理多个连接,即NIO是同步非阻塞的。
2.java网络编程的NIO实现细节
(1).NIO三大核心组件
NIO有三大核心组件:Selector选择器、Channel管道、buffer缓冲区。
(2).Selector选择器
Selector选择器,可以检查一个或者多个通道,并且可以确定哪些通道已经准备好读取和写入了,可以实现单个线程管理多个通道,继而管理多个网络连接。
一个线程使用Selector可以监听多个Channel,有四个事件:
1:connect(对应常量值:OP_CONNECT)
2:accept(对应常量值:OP_ACCEPT)
3:read(对应常量值:OP_READ)
4:write(对应常量值:OP_WRITE)
Selector选择器可以监听哪些通道已经注册了事件,通过判断不同的事件,进行处理。
select会监听Channel通道,当Channel中有数据可以读取或者写入时,就会把Channel的引用拷贝一份出来,放到selectKeys集合中,selectKeys中都是监听到的可以进行读取或者写入的通道,那么下面通过遍历selectKeys,然后根据Selector提供的ket.isConnectable()、ket.isAcceptable()、ket.isReadable()、ket.isWritable()判断是哪个事件,然后再进行事件处理。
(3).Channel通道
Channel通道是在一个通道内进行读写,是双向的,可以非阻塞的进行读取和写入,通道始终读取和写入buffer中,也就是读取的时候,先从buffer中读取出来,然后通过通道去传输,写入的时候也是先写入buffer,然后再写入通道中去传输。
(4).Buffer缓冲区
Buffer缓冲区本质上是一个可以写入数据的内存块,类似数组,然后还可以读取数据,它提供了一些方法,可以更加方便的操作,使用Buffer写入和读取数据需要下面四个步骤:
1:将数据写入Buffer缓冲区
2:调用buffer.flip(),转换为读取模式
3:从Buffer缓冲区读取数据
4:调用buffer.clear()或者buffer.compact()转换为写模式
Buffer的工作原理:
capacity容量:作为一个内存块,它肯定有大小,也就是容量
position位置:也就是数据的索引值,写入模式时指写数据的位置,读取模式时指读数据的位置
limit限制:写入模式时,限制等于buffer的容量,读取模式时指写入的数据量
(5).服务端代码实现
public static void main(String[] args) throws IOException {
//创建一个ServerSocketChannel
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
//默认是阻塞的,需要手动设置为非阻塞模式
serverSocketChannel.configureBlocking(false);
//创建一个Selector
Selector selector = Selector.open();
//注册一个感兴趣的事件
//服务端永远是被动的,所以这里只能注册OP_ACCEPT事件
serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
//指定一个服务端的端口号
serverSocketChannel.socket().bind(new InetSocketAddress(8977));
System.out.println("服务端启动成功");
while (true) {
//启动selector,会阻塞,直到有事件为止,可以监听事件的触发
selector.select();
//获取到触发的事件集合
Set<SelectionKey> selectionKeys = selector.selectedKeys();
//遍历事件
Iterator<SelectionKey> iterator = selectionKeys.iterator();
while (iterator.hasNext()) {
SelectionKey key = iterator.next();
//获取已经准备就绪的事件
if (key.isAcceptable()) {
//获取到客户端的Channel
SocketChannel socketChannel = ((ServerSocketChannel)key.channel()).accept();
//设置为非阻塞模式
socketChannel.configureBlocking(false);
//注册事件
socketChannel.register(selector, SelectionKey.OP_READ);
System.out.println("收到一个新的连接:" + socketChannel.getRemoteAddress());
} else if (key.isReadable()) {
try {
SocketChannel socketChannel = (SocketChannel)key.channel();
//读取数据
//创建一个ByteBuffer,并指定容量大小为1024
ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
//read会阻塞
while (socketChannel.isOpen() && socketChannel.read(byteBuffer) != -1) {
//手动判断有没有读取结束
if (byteBuffer.position() > 0) {
break;
}
}
//没有数据就继续执行下面的代码
if(byteBuffer.position() == 0) {
continue;
}
//转换为读取模式
byteBuffer.flip();
byte[] bytes = new byte[byteBuffer.limit()];
byteBuffer.get(bytes);
System.out.println("收到新的数据:" + new String(bytes));
System.out.println("收到新的数据,来自:" + socketChannel.getRemoteAddress());
//写入数据
String writeMsg = "HTTP/1.1 200 OK\r\n" +
"Content-Length: 11\r\n\r\n" +
"hello world";
//为啥没调用转换写入模式,这里新建了一个ByteBuffer
ByteBuffer writeBuffer = ByteBuffer.wrap(writeMsg.getBytes());
while (writeBuffer.hasRemaining()) {
socketChannel.write(writeBuffer);
}
} catch (Exception e) {
e.printStackTrace();
}
}
}
//处理完之后要移除,不然下次还会再集合中
//需要自己去管理集合
iterator.remove();
}
}
(6).客户端代码实现
public static void main(String[] args) throws IOException {
//创建一个SocketChannel
SocketChannel socketChannel = SocketChannel.open();
//设置为非阻塞模式
socketChannel.configureBlocking(false);
//创建一个Selector
Selector selector = Selector.open();
//注册一个感兴趣的事件
socketChannel.register(selector, SelectionKey.OP_CONNECT);
//连接服务端
socketChannel.connect(new InetSocketAddress("localhost", 8977));
while (true) {
//启动selector,会阻塞,直到有事件为止,可以监听事件的触发
selector.select();
//获取到触发的事件集合
Set<SelectionKey> selectionKeys = selector.selectedKeys();
//遍历事件
Iterator<SelectionKey> iterator = selectionKeys.iterator();
while (iterator.hasNext()) {
SelectionKey key = iterator.next();
if (key.isConnectable()) {
if (socketChannel.finishConnect()) {
System.out.println("连接成功:" + socketChannel);
//切换事件为写入
key.interestOps(SelectionKey.OP_WRITE);
}
} else if(key.isWritable()) {
//写入数据
Scanner scanner = new Scanner(System.in);
System.out.println("请输入:");
String writeMsg = scanner.nextLine();
scanner.close();
//ByteBuffer方法的用法请自行百度
ByteBuffer byteBuffer = ByteBuffer.wrap(writeMsg.getBytes());
while (byteBuffer.hasRemaining()) {
socketChannel.write(byteBuffer);
}
key.interestOps(SelectionKey.OP_READ);
} else if (key.isReadable()) {
//获取服务端数据
System.out.println("收到服务端响应:");
ByteBuffer readBuffer = ByteBuffer.allocate(1024);
//read会阻塞
while (socketChannel.isOpen() && socketChannel.read(readBuffer) != -1) {
//手动判断有没有读取结束
if (readBuffer.position() > 0) {
break;
}
}
//转换为读取模式
readBuffer.flip();
byte[] bytes = new byte[readBuffer.limit()];
readBuffer.get(bytes);
System.out.println("收到服务端数据:" + new String(bytes));
}
//处理完之后要移除,不然下次还会再集合中
iterator.remove();
}
}
}
3.IO多路复用的三种实现方式
select,poll,epoll都是操作系统实现IO多路复用的机制。我们知道,I/O多路复用就通过一种机制,可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。
(1).select
客户端操作服务器时就会产生这三种文件描述符(简称fd):writefds(写)、readfds(读)、和 exceptfds(异常)。select 会阻塞住监视 3 类文件描述符,等有数据、可读、可写、出异常或超时就会返回;返回后通过遍历 fdset 整个数组来找到就绪的描述符 fd,然后进行对应的 IO 操作。
优点:
几乎在所有的平台上支持,跨平台支持性好
缺点:
1.由于是采用轮询方式全盘扫描,会随着文件描述符 FD 数量增多而性能下降。
2.每次调用 select(),都需要把 fd 集合从用户态拷贝到内核态,并进行遍历(消息传递都是从内核到用户空间)。
3.单个进程打开的 FD 是有限制(通过FD_SETSIZE设置)的,默认是 1024 个,可修改宏定义,但是效率仍然慢。
(2).poll
基本原理与 select 一致,也是轮询+遍历。唯一的区别就是 poll 没有最大文件描述符限制(使用链表的方式存储 fd)。
缺点:
1.由于是采用轮询方式全盘扫描,会随着文件描述符 FD 数量增多而性能下降。
2.每次调用 select(),都需要把 fd 集合从用户态拷贝到内核态,并进行遍历(消息传递都是从内核到用户空间)。
(3).epoll
handler机制就是模仿的这个epoll
没有 fd 个数限制,用户态拷贝到内核态只需要一次,使用时间通知机制来触发。通过 epoll_ctl 注册 fd,一旦 fd 就绪就会通过 callback 回调机制来激活对应 fd,进行相关的 io 操作。epoll 之所以高性能是得益于它的三个函数:
epoll_create() 系统启动时,在 Linux 内核里面申请一个B+树结构文件系统,返回 epoll 对象,也是一个 fd。
epoll_ctl() 每新建一个连接,都通过该函数操作 epoll 对象,在这个对象里面修改添加删除对应的链接 fd,绑定一个 callback 函数
epoll_wait() 轮训所有的 callback 集合,并完成对应的 IO 操作
优点:
没 fd 这个限制,所支持的 FD 上限是操作系统的最大文件句柄数,1G 内存大概支持 10 万个句柄。效率提高,使用回调通知而不是轮询的方式,不会随着 FD 数目的增加效率下降。内核和用户空间 mmap 同一块内存实现(mmap 是一种内存映射文件的方法,即将一个文件或者其它对象映射到进程的地址空间)
缺点:
epoll 只能工作在linux下。
4.三种IO多路复用的区别
(1).支持一个线程的最大连接数不同
select |
单个进程所能打开的最大连接数有FD_SETSIZE宏定义,其大小是32个整数的大小(32位的机器上是32*32,同理64位机器上的FD_SETSIZE是32*64),当然我们可以对其进行修改,重新编译内核,但是性能可能会受到影响
|
poll |
poll
本质上和
select没有区别,但是它没有最大连接数的限制,原因是它是基于链表来存储的
|
epoll |
连接数基本上只受限于机器的内存大小
|
(2).FD剧增后带来的IO效率问题
select | 因为每次调用时都会对连接进行线性遍历,所以随着FD增加会造成遍历速度慢的线性下降的性能问题 |
poll | 同上 |
epoll |
因为
epoll
内核中实现是根据每个
fd
上的
callback
函数来实
现的,只有活跃的
socket
才会主动调用
callback,所以在活跃
socket
较少的情况下,使用
epoll
没有前面两者的线性下降的性能
问题,但是所有
socket
都很活跃的情况下,可能会有性能问题。
|
(3).消息传递的方式
select |
内核需要将消息传递到用户空间,都需要内核拷贝动作
|
poll |
同上
|
epoll |
epoll
通过内核和用户空间共享一块内存来实现的。
|
5.理解 IO 多路复用机制
小王在 S 城开了一家快递店,负责同城快送服务。小王因为资金限制,雇佣了一批快递员,然后小王发现资金不够了,只够买一辆车送快递。
(1).【经营方式一】
客户每送来一份快递,小王就让一个快递员盯着,然后快递员开车去送快递。慢慢的小王就发现了这种经营方式存在下述问题:
几十个快递员基本上时间都花在了抢车上了,大部分快递员都处在闲置状态,谁抢到了车,谁就能去送快递。
随着快递的增多,快递员也越来越多,小王发现快递店里越来越挤,没办法雇佣新的快递员了。
快递员之间的协调很费时间。
(2).【经营方式二】
小王只雇佣一个快递员。然后呢,客户送来的快递,小王按送达地点标注好,然后依次放在一个地方。最后,那个快递员依次去取快递,一次拿一个,然后开着车去送快递,送好了就回来拿下一个快递。
(3).对比
两种经营方式对比,第二种明显效率更高,更好。在上述比喻中:
每个快递员------------------>每个线程
每个快递-------------------->每个socket(IO流)
快递的送达地点-------------->socket的不同状态
客户送快递请求-------------->来自客户端的请求
小王的经营方式-------------->服务端运行的代码
一辆车---------------------->CPU的核心数
(4).结论
- 【经营方式一】就是传统的并发模型,每个 IO 流(快递)都有一个新的线程(快递员)管理。
- 【经营方式二】就是 IO 多路复用。只有单个线程(一个快递员),通过跟踪每个 IO 流的状态(每个快递的送达地点),来管理多个 IO 流。