Java网络编程中的TCP和UDP通信方式简介

主要通过以下几个方面来解释两种方式的区别(enough talk and let’s look at some code)

  1. TCP方式和UDP的区别
  2. TCP的上层应用Socket实现:Socket,ServerSocket以及代码实现
  3. UDP的上层应用Socket实现:DatagramSocket,DatagramPacket以及代码实现



### 网络协议简介

通过上图知道不管是TCP还是UDP都是互联网络协议中的一层—传输层协议。

图中简单对网络协议做了分层,金典的网络协议是四层,这里给了不同的四层。

  1. 最底层的以太网协议规定了电子信号如何组成数据包(packet),解决了子网内部的点对点通信,但是以太网协议不能解决多个局域网如何互通,这由IP协议解决(IP协议可以连接多个局域网)
  2. IP协议定义了一套自己的地址规则,成为IP地址。它实现了路由功能,允许某个局域网的A主机,向另一个局域网的B主机发送消息(路由器就是基于IP协议,局域网之间靠路由器连接)。
  3. 路由的原理很简单。市场上所有的路由器,背后都有很多网口,要接入很多根网线,路由器内部有一张路由表,规定了A段IP地址走出口一,B段IP地址走出口二。…通过这套指路牌,实现了数据包的转发。
  4. IP协议只是一个地址协议,并不保证数据包的完整,如果路由器丢包(比如缓存满了,新进来的数据包就会丢失),就需要发现丢了哪一个包,以及如何重新发送这个包。这就要靠TCP协议了。

TCP协议

具体的TCP协议可能是一本书的内容,这里管中窥豹一下。简单来说,TCP协议的作用是,保证数据通信的完整性和可靠性,防止丢包。

原来,我们只是简单知道TCP协议的七次==连接==,三挥四别,这里我们来完整了解下他其中的原理。

TCP数据包大小

以太网数据包(pakage)的大小是固定的,最初是1518字节,后来增加到了1522字节。其中,1500字节是负载(payload),22字节是头信息(head)。IP数据包在以太网数据包的负载里面,它也有自己的头信息,最少20字节,所以IP数据包的负载最多1480字节,TCP数据包在IP数据包的负载里面,TCP的头也要占20字节,因此TCP数据包最大负载1460字节。由于,IP和TCP协议往往还有额外的头信心,所以TCP实际负载为1400字节左右,下图会清晰的表明这种关系:

因此,一条1500字节的信息需要两个TCP数据包来承载发送。==HTTP/2协议==的一大改进就是压缩了HTTP协议的头信息,使得一个HTTP请求可以放在一个TCP数据包里面,而不是分成多个,这样就提高了传输速度。

TCP数据包的编号(SEQ)

一个包1400字节,那么发送一个10M大小的数据包,需要发送7100多个包,发送的时候,TCP协议会给每个包编号,一边接收方按照顺序还原,万一发生丢包,也可以知道丢失的是哪个包。一般第一个包编号是随机的。假设我们叫第一个包编号为1号包,这个包负载100字节,那么可以推算出下一个包的编号应该是101,就是说,每个数据包都可以得到两个编号:一个自身的编号,及下一个数据包的编号。几首方由此知道,应该按照什么顺序将他们还原成原始文件。(可以用抓包工具抓来看)

TCP数据包的组装

收到TCP数据包以后,由操作系统来完成组装操作,应用程序是不会直接处理TCP数据包的,而是处理由他封装的例如HTTP协议的数据包。对应用程序来说,不用关心数据通信的细节,应用程序需要的数据放在了TCP数据包里面,有自己的格式(比如常见的HTTP协议),TCP不能标识原始文件大小,这个只能由应用层协议来规定,如HTTP头COntent-length,表示了信息体的大小。对于操作系统来说,就是持续的接收TCP数据包,将它们按照顺序组装好,一个包都不少。操作系统不会去处理TCP数据包里面的数据,一旦组装好TCP数据包,就把他们交给引用程序(怎么组装呢??),TCP数据包里面有个端口(port)就是用来指定转交给监听该端口的应用程序。

如上图:系统根据TCP数据包中端口,将组装好的数据转交给相应的应用程序。如21端口是FTP服务器,25端口是SMTP(邮件协议)服务,80是Web服务器(常用的Http请求)。这些一般都是在服务端的。

慢启动和ACK

服务器发送数据包,当然越快越好,最好一次性全发出去。但是,发得太快,就有可能丢包。带宽小、路由器过热、缓存溢出等许多因素都会导致丢包。线路不好的话,发得越快,丢得越多。最理想的状态是,在线路允许的情况下,达到最高速率。但是我们怎么知道,对方线路的理想速率是多少呢?答案就是慢慢试。==TCP 协议为了做到效率与可靠性的统一,设计了一个慢启动(slow start)机制。开始的时候,发送得较慢,然后根据丢包的情况,调整速率:如果不丢包,就加快发送速度;如果丢包,就降低发送速度。==(NB)。Linux 内核里面设定了(常量==TCP_INIT_CWND==),刚开始通信的时候,发送方一次性发送10个数据包,即”发送窗口”的大小为10。然后停下来,等待接收方的确认,再继续发送。默认情况下,接收方每收到两个 TCP 数据包,就要发送一个确认消息。”确认”的英语是 acknowledgement,所以这个确认消息就简称 ACK。

  • ACK 携带两个信息。
    • 期待要收到下一个数据包的编号。
    • 接收方的接收窗口剩余容量

(图片说明:上图一共4次通信。第一次通信,A 主机发给B 主机的数据包编号是1,长度是100字节,因此第二次通信 B 主机的 ACK 编号是 1 + 100 = 101,第三次通信 A 主机的数据包编号也是 101。同理,第二次通信 B 主机发给 A 主机的数据包编号是1,长度是200字节,因此第三次通信 A 主机的 ACK 是201,第四次通信 B 主机的数据包编号也是201。)

即使对于带宽很大、线路很好的连接,TCP 也总是从10个数据包开始慢慢试,过了一段时间以后,才达到最高的传输速率。这就是 TCP 的慢启动。

数据包的遗失处理

丢包时有发生,TCP的可靠性是怎么保证的呢?简单来说:由于每一数据包都带有编号,如有下一个数据包没收到,那么ACK的编号就不会发生变化。

举例来说,现在收到了4号包,但是没有收到5号包。ACK 就会记录,期待收到5号包。过了一段时间,5号包收到了,那么下一轮 ACK 会更新编号。如果5号包还是没收到,但是收到了6号包或7号包,那么 ACK 里面的编号不会变化,总是显示5号包。==这会导致大量重复内容的 ACK==。如果发送方发现收到了==三个==连续重复的ACK,或者超时没收到任何ACK,就会确认丢包了,及5号包丢了,从而再次发送这个包,通过这种机制,TCP保证了不会有数据包丢失。==(好像TCP会丢包的)== 。下图比较形象说明了这个例子:

(图片说明:Host B 没有收到100号数据包,会连续发出相同的 ACK,触发 Host A 重发100号数据包。)

TCP连接



Java的TCP实现(Socket)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public void client() throws Exception {
// InetAddress.getLocalHost()为客户端请求连接的主机号,此处设置为本地主机,服务进程的端口号是8090
// 主机号和端口号唯一确定了唯一主机上面的唯一进程。
Socket socket = new Socket(InetAddress.getLocalHost(), 8090);
// socket.getOutputStream()获得输出流,通过输出流像主机发送数据。
OutputStream os = socket.getOutputStream();
os.write("黑猫呼叫白猫收到请回复!".getBytes());
// 关闭数据输出,如果不关闭的话服务端并不知道数据传输已经结束还会一直等待。
socket.shutdownOutput();
// 接收server端发送的数据
InputStream is = socket.getInputStream();
int len = 0;
byte[] b = new byte[1024];
while ((len = is.read(b)) != -1) {
String str = new String(b, 0, len);
System.out.println(str);
}
is.close();
os.close();
socket.close();
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

public void server() throws Exception {
// 给服务端一个端口号8090使得客户端可以连接。
ServerSocket ss = new ServerSocket(8090);
// 接受客户端的连接
Socket socket = ss.accept();
// 获得客户端的输入流
InputStream is = socket.getInputStream();
// 输出client端发送的数据
int len = 0;
byte[] b = new byte[1024];
while ((len = is.read(b)) != -1) {
String str = new String(b, 0, len);
System.out.println(str);
}
OutputStream os = socket.getOutputStream();
// 通过输出流向客户端发送数据。
os.write("黑猫这里是白猫,我已收到你的呼叫!".getBytes());
os.close();
// socket.shutdownOutput();
is.close();
socket.close();
ss.close();
}